modernize ticket and accounting

iteration 0
This commit is contained in:
Marcel Arndt
2025-11-24 21:31:43 +01:00
parent c33ab89354
commit b1a70673a4
97 changed files with 13619 additions and 2398 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20.15.1-alpine AS builder
FROM node:20.19.1-alpine AS builder
WORKDIR /app/builder
@@ -0,0 +1,4 @@
{
"componentsPath": "src/app/core/base-components",
"importAlias": "@spartan-ng/helm"
}
@@ -1,5 +1,6 @@
#!/bin/bash
set -xe
: "${API_BASEURL?API_BASEURL is not provided}"
: "${GRAPHQL_ENDPOINT?GRAPHQL_ENDPOINT is not provided}"
: "${GRAPHQL_WS_ENDPOINT?GRAPHQL_WS_ENDPOINT is not provided}"
: "${MS_AUTHENTICATION_CLIENT_ID?MS_AUTHENTICATION_CLIENT_ID is not provided}"
@@ -10,14 +11,14 @@ set -xe
: "${OTLP_SERVICE_NAME?OTLP_SERVICE_NAME is not provided}"
: "${COLLECTOR_SECRET?COLLECTOR_SECRET is not provided}"
env_vars=("GRAPHQL_ENDPOINT" "GRAPHQL_WS_ENDPOINT" "MS_AUTHENTICATION_CLIENT_ID" "MS_AUTHENTICATION_AUTHORITY" "MS_AUTHENTICATION_REDIRECT_URI" "MS_AUTHENTICATION_POST_LOGOUT_REDIRECT_URI" "OTLP_ENDPOINT" "OTLP_SERVICE_NAME" "COLLECTOR_SECRET")
env_vars=("API_BASEURL" "GRAPHQL_ENDPOINT" "GRAPHQL_WS_ENDPOINT" "MS_AUTHENTICATION_CLIENT_ID" "MS_AUTHENTICATION_AUTHORITY" "MS_AUTHENTICATION_REDIRECT_URI" "MS_AUTHENTICATION_POST_LOGOUT_REDIRECT_URI" "OTLP_ENDPOINT" "OTLP_SERVICE_NAME" "COLLECTOR_SECRET")
ls /usr/share/nginx/html/
for var in "${env_vars[@]}"
do
echo "$var ${!var}"
sed -i "s~{{$var}}~${!var}~gi" /usr/share/nginx/html/main*.js
sed -i "s~{{$var}}~${!var}~gi" /usr/share/nginx/html/*.js
done
exec "$@"
+7276 -1343
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -25,8 +25,8 @@
"@apollo/client": "^3.13.8",
"@azure/msal-angular": "^3.0.22",
"@azure/msal-browser": "^3.19.1",
"@grafana/faro-web-sdk": "^1.15.0",
"@grafana/faro-web-tracing": "^1.15.0",
"@ng-icons/core": "^31.4.0",
"@ng-icons/lucide": "^31.4.0",
"@ngneat/dialog": "^5.1.1",
"@ngneat/overview": "^6.0.0",
"@ngxpert/hot-toast": "^3.0.0",
@@ -41,12 +41,16 @@
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-trace-web": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@spartan-ng/brain": "^0.0.1-alpha.561",
"apollo-angular": "^10.0.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"echarts": "^5.5.1",
"graphql": "^16.11.0",
"graphql-ws": "^5.16.0",
"rxjs": "~7.8.0",
"tailwind-merge": "^3.3.1",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
@@ -60,6 +64,7 @@
"@graphql-codegen/typescript-apollo-angular": "^4.0.0",
"@graphql-codegen/typescript-operations": "^4.6.0",
"@material/material-color-utilities": "^0.3.0",
"@spartan-ng/cli": "^0.0.1-alpha.561",
"@tailwindcss/postcss": "^4.1.4",
"@types/jasmine": "~5.1.0",
"autoprefixer": "^10.4.19",
@@ -71,6 +76,7 @@
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.39",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.5.2"
}
}
@@ -17,10 +17,6 @@ import { graphqlProvider } from './graphql.provider';
export const appConfig: ApplicationConfig = {
providers: [
// provideAppInitializer(() => {
// const initializerFn = faroInitializer();
// return initializerFn();
// }),
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
@@ -0,0 +1,5 @@
import { HlmInput } from './lib/hlm-input';
export * from './lib/hlm-input';
export const HlmInputImports = [HlmInput] as const;
@@ -0,0 +1,103 @@
import {
computed,
Directive,
type DoCheck,
effect,
forwardRef,
inject,
Injector,
input,
linkedSignal,
signal,
untracked,
} from '@angular/core';
import { FormGroupDirective, NgControl, NgForm } from '@angular/forms';
import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
import { ErrorStateMatcher, ErrorStateTracker } from '@spartan-ng/brain/forms';
import { hlm } from '../../../utils/src';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const inputVariants = cva(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
{
variants: {
error: {
auto: '[&.ng-invalid.ng-touched]:border-destructive [&.ng-invalid.ng-touched]:ring-destructive/20 dark:[&.ng-invalid.ng-touched]:ring-destructive/40',
true: 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
},
},
defaultVariants: {
error: 'auto',
},
},
);
type InputVariants = VariantProps<typeof inputVariants>;
@Directive({
selector: '[hlmInput]',
providers: [
{
provide: BrnFormFieldControl,
useExisting: forwardRef(() => HlmInput),
},
],
host: {
'[class]': '_computedClass()',
},
})
export class HlmInput implements BrnFormFieldControl, DoCheck {
private readonly _injector = inject(Injector);
private readonly _additionalClasses = signal<ClassValue>('');
private readonly _errorStateTracker: ErrorStateTracker;
private readonly _defaultErrorStateMatcher = inject(ErrorStateMatcher);
private readonly _parentForm = inject(NgForm, { optional: true });
private readonly _parentFormGroup = inject(FormGroupDirective, { optional: true });
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(inputVariants({ error: this._state().error }), this.userClass(), this._additionalClasses()),
);
public readonly error = input<InputVariants['error']>('auto');
protected readonly _state = linkedSignal(() => ({ error: this.error() }));
public readonly ngControl: NgControl | null = this._injector.get(NgControl, null);
public readonly errorState = computed(() => this._errorStateTracker.errorState());
constructor() {
this._errorStateTracker = new ErrorStateTracker(
this._defaultErrorStateMatcher,
this.ngControl,
this._parentFormGroup,
this._parentForm,
);
effect(() => {
const error = this._errorStateTracker.errorState();
untracked(() => {
if (this.ngControl) {
const shouldShowError = error && this.ngControl.invalid && (this.ngControl.touched || this.ngControl.dirty);
this._errorStateTracker.errorState.set(shouldShowError ? true : false);
this.setError(shouldShowError ? true : 'auto');
}
});
});
}
ngDoCheck() {
this._errorStateTracker.updateErrorState();
}
setError(error: InputVariants['error']) {
this._state.set({ error });
}
setClass(classes: string): void {
this._additionalClasses.set(classes);
}
}
@@ -0,0 +1,5 @@
import { HlmLabel } from './lib/hlm-label';
export * from './lib/hlm-label';
export const HlmLabelImports = [HlmLabel] as const;
@@ -0,0 +1,27 @@
import { Directive, computed, input } from '@angular/core';
import { BrnLabel } from '@spartan-ng/brain/label';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Directive({
selector: '[hlmLabel]',
hostDirectives: [
{
directive: BrnLabel,
inputs: ['id'],
},
],
host: {
'[class]': '_computedClass()',
},
})
export class HlmLabel {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 peer-data-[disabled]:cursor-not-allowed peer-data-[disabled]:opacity-50 has-[[disabled]]:cursor-not-allowed has-[[disabled]]:opacity-50',
this.userClass(),
),
);
}
@@ -0,0 +1 @@
export * from './lib/hlm';
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,12 @@
<div class="flex w-full max-w-sm gap-3">
<input
hlmInput
type="file"
(change)="onFileSelected($event)"
#fileInput
id="fileInput"
/>
<button mat-button (click)="onUpload()" [disabled]="!selectedFile">
Hochladen
</button>
</div>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TransportDocumentUploadComponent } from './transport-document-upload.component';
describe('FileUploadComponent', () => {
let component: TransportDocumentUploadComponent;
let fixture: ComponentFixture<TransportDocumentUploadComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TransportDocumentUploadComponent]
})
.compileComponents();
fixture = TestBed.createComponent(TransportDocumentUploadComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,63 @@
import { HttpEventType } from '@angular/common/http';
import { Component, inject, input, output } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmLabelImports } from '@spartan-ng/helm/label';
import { environment } from '../../../../environments/environment';
import { TransportDocumentUploadService } from './transport-document-upload.service';
import { HotToastService } from '@ngxpert/hot-toast';
@Component({
selector: 'app-transport-document-upload',
imports: [HlmInputImports, HlmLabelImports, MatButtonModule],
templateUrl: './transport-document-upload.component.html',
styleUrl: './transport-document-upload.component.scss',
providers: [TransportDocumentUploadService],
})
export class TransportDocumentUploadComponent {
toast = inject(HotToastService)
ticketId = input.required<string>();
uploaded = output<void>()
selectedFile: File | null = null;
uploadProgress = 0;
uploadStatus: string | null = null;
constructor(private uploadService: TransportDocumentUploadService) {}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.selectedFile = input.files[0];
this.uploadProgress = 0;
this.uploadStatus = null;
}
}
onUpload(): void {
if (!this.selectedFile) {
return;
}
const uploadUrl = `${
environment.api.baseUrl
}/tickets/${this.ticketId()}/files`;
this.uploadService.uploadFile(uploadUrl, this.selectedFile).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
this.uploadProgress = Math.round(100 * (event.loaded / event.total));
} else if (event.type === HttpEventType.Response) {
this.selectedFile = null;
this.toast.success("Transportschein erfolgreich hochgeladen")
this.uploaded.emit()
}
},
error: (err) => {
this.uploadProgress = 0;
this.toast.error("Fehler beim Upload, bitte versuchen Sie es erneut")
},
});
}
}
@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpRequest, HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable()
export class TransportDocumentUploadService {
constructor(private http: HttpClient) { }
/**
* Lädt eine Datei zu einem *vom Aufrufer* definierten Endpunkt hoch.
* @param url Der vollständige API-Endpunkt für den Upload
* @param file Die hochzuladende Datei
* @returns Ein Observable mit dem Upload-Fortschritt
*/
uploadFile(url: string, file: File): Observable<HttpEvent<any>> {
const formData: FormData = new FormData();
formData.append('file', file, file.name);
const req = new HttpRequest('POST', url, formData, {
reportProgress: true,
});
return this.http.request(req);
}
}
@@ -27,6 +27,7 @@ export type AccountingFilterArgs = {
export enum AccountingState {
Billable = 'Billable',
Billed = 'Billed',
Pending = 'Pending',
PreparedForBilling = 'PreparedForBilling'
}
@@ -165,6 +166,7 @@ export type Metric = {
export type Mutation = {
__typename?: 'Mutation';
acceptAnomaly: Anomaly;
sendApprovalRequest: Scalars['Boolean']['output'];
setAnomalyToSleep: Anomaly;
setDocumentInfo: Ticket;
};
@@ -175,6 +177,12 @@ export type MutationAcceptAnomalyArgs = {
};
export type MutationSendApprovalRequestArgs = {
email: Scalars['String']['input'];
ticketId: Scalars['String']['input'];
};
export type MutationSetAnomalyToSleepArgs = {
id: Scalars['String']['input'];
};
@@ -523,6 +531,7 @@ export type Tour = {
totalKm: Scalars['Float']['output'];
transportType?: Maybe<Scalars['String']['output']>;
type: Scalars['String']['output'];
updatedAt?: Maybe<Scalars['DateTime']['output']>;
};
export type TourCostBreakdown = {
@@ -701,6 +710,14 @@ export type SetDocumentInfoMutationVariables = Exact<{
export type SetDocumentInfoMutation = { __typename?: 'Mutation', setDocumentInfo: { __typename?: 'Ticket', id: string, currentState: TicketValidationState, approvalState?: ApprovalState | null, errors: Array<string>, notes: Array<string>, isUrgent: boolean, urgency: any, createdAt: any, documentInfo?: { __typename?: 'DocumentInfo', tdLocation?: string | null, approval: { __typename?: 'ApprovalInfo', isNeeded?: boolean | null, whoRequested?: string | null, answeredFrom?: string | null, location?: string | null } } | null, tours: Array<{ __typename?: 'Tour', id: string, done: boolean, direction: string, carName?: string | null, operationId: string, driverName?: string | null, codriverName?: string | null, patientName?: string | null, patientSurname?: string | null, patientStreet?: string | null, patientZip?: string | null, patientCity?: string | null, healthInsurance?: string | null, category?: string | null, transportType?: string | null, ordinanceType?: string | null, startStreet?: string | null, startZip?: string | null, startCity?: string | null, targetStreet?: string | null, targetZip?: string | null, targetCity?: string | null, startDate?: any | null, check: number, occupiedKm: number, totalKm: number, type: string, rangeEndDate?: any | null, startBegin?: string | null, startEnd?: string | null, target?: string | null, targetBegin?: string | null, empty?: string | null }> } };
export type SendApprovalRequestMutationVariables = Exact<{
email: Scalars['String']['input'];
ticketId: Scalars['String']['input'];
}>;
export type SendApprovalRequestMutation = { __typename?: 'Mutation', sendApprovalRequest: boolean };
export type GetUpcomingToursQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1513,6 +1530,22 @@ export const SetDocumentInfoDocument = gql`
export class SetDocumentInfoGQL extends Apollo.Mutation<SetDocumentInfoMutation, SetDocumentInfoMutationVariables> {
override document = SetDocumentInfoDocument;
constructor(apollo: Apollo.Apollo) {
super(apollo);
}
}
export const SendApprovalRequestDocument = gql`
mutation SendApprovalRequest($email: String!, $ticketId: String!) {
sendApprovalRequest(email: $email, ticketId: $ticketId)
}
`;
@Injectable({
providedIn: 'root'
})
export class SendApprovalRequestGQL extends Apollo.Mutation<SendApprovalRequestMutation, SendApprovalRequestMutationVariables> {
override document = SendApprovalRequestDocument;
constructor(apollo: Apollo.Apollo) {
super(apollo);
}
@@ -217,3 +217,7 @@ mutation SetDocumentInfo(
}
}
}
mutation SendApprovalRequest($email: String!, $ticketId: String!) {
sendApprovalRequest(email: $email, ticketId: $ticketId)
}
@@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
import { TimePerTourUpdatedGQL } from '../data-access/graphql/generated/generated';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { tap } from 'rxjs';
interface ActivityMap {
[entityId: string]: number;
}
@Injectable({ providedIn: 'root' })
export class ActivityService {
private readonly STORAGE_KEY = 'user_pending_activities';
private readonly MAX_AGE_MS = 6 * 60 * 1000;
constructor(backendUpdatedGql: TimePerTourUpdatedGQL) {
backendUpdatedGql
.subscribe()
.pipe(
takeUntilDestroyed(),
tap(() => this.handleEtlFinishedEvent())
)
.subscribe();
}
markAsDirty(entityId: string): void {
const activities = this.loadActivities();
activities[entityId] = Date.now();
this.saveActivities(activities)
console.log(activities)
}
isDirty(entityId: string): boolean {
const activities = this.loadActivities();
const timestamp = activities[entityId];
if (!timestamp) return false;
if (Date.now() - timestamp > this.MAX_AGE_MS) {
return false;
}
return true;
}
handleEtlFinishedEvent(): void {
const activities = this.loadActivities();
const now = Date.now();
const safeThreshold = 60 * 1000;
const filteredActivities: ActivityMap = {};
Object.keys(activities).forEach((id) => {
const timestamp = activities[id];
if (now - timestamp < safeThreshold) {
filteredActivities[id] = timestamp;
}
});
this.saveActivities(filteredActivities);
}
private loadActivities(): ActivityMap {
const raw = localStorage.getItem(this.STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
}
private saveActivities(map: ActivityMap): void {
console.log(this.loadActivities())
const now = Date.now();
const cleanMap: ActivityMap = {};
Object.keys(map).forEach((id) => {
if (now - map[id] <= this.MAX_AGE_MS) {
cleanMap[id] = map[id];
}
});
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(cleanMap));
}
}
@@ -1,68 +0,0 @@
// https://github.com/grafana/faro-web-sdk/tree/main
// https://github.com/grafana/faro-web-sdk/blob/main/packages/web-tracing/src/faroMetaAttributesSpanProcessor.ts
import type { Context } from '@opentelemetry/api';
import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web';
// False positive. Package can be resolved.
// eslint-disable-next-line import/no-unresolved
import { ATTR_SESSION_ID } from '@opentelemetry/semantic-conventions/incubating';
import type { Metas } from '@grafana/faro-web-sdk';
export class FaroMetaAttributesSpanProcessor implements SpanProcessor {
constructor(
private processor: SpanProcessor,
private metas: Metas
) {}
forceFlush(): Promise<void> {
return this.processor.forceFlush();
}
onStart(span: Span, parentContext: Context): void {
const session = this.metas.value.session;
if (session?.id) {
span.attributes[ATTR_SESSION_ID] = session.id;
/**
* @deprecated will be removed in the future and has been replaced by ATTR_SESSION_ID (session.id)
*/
span.attributes['session_id'] = session.id;
}
const user = this.metas.value.user ?? {};
if (user.email) {
span.attributes['user.email'] = user.email;
}
if (user.id) {
span.attributes['user.id'] = user.id;
}
if (user.username) {
span.attributes['user.name'] = user.username;
}
if (user.fullName) {
span.attributes['user.full_name'] = user.fullName;
}
if (user.roles) {
span.attributes['user.roles'] = user.roles.split(',').map((role) => role.trim());
}
if (user.hash) {
span.attributes['user.hash'] = user.hash;
}
this.processor.onStart(span, parentContext);
}
onEnd(span: ReadableSpan): void {
this.processor.onEnd(span);
}
shutdown(): Promise<void> {
return this.processor.shutdown();
}
}
@@ -1,50 +0,0 @@
// https://github.com/grafana/faro-web-sdk/tree/main
// https://github.com/grafana/faro-web-sdk/blob/main/packages/web-tracing/src/faroUserActionSpanProcessor.ts
import { type Context, SpanKind } from '@opentelemetry/api';
import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web';
import { USER_ACTION_CANCEL, USER_ACTION_END, USER_ACTION_START } from '@grafana/faro-core';
import { apiMessageBus, type UserActionStartMessage } from '@grafana/faro-web-sdk';
export class FaroUserActionSpanProcessor implements SpanProcessor {
message: UserActionStartMessage | undefined;
constructor(private processor: SpanProcessor) {
apiMessageBus.subscribe((msg) => {
if (msg.type === USER_ACTION_START) {
this.message = msg;
return;
}
if ([USER_ACTION_END, USER_ACTION_CANCEL].includes(msg.type)) {
this.message = undefined;
}
});
}
forceFlush(): Promise<void> {
return this.processor.forceFlush();
}
onStart(span: Span, parentContext: Context): void {
if (span.kind === SpanKind.CLIENT) {
// If the span is created when the message object is available it is created before the user action timeout has been reached so it belongs to the user-action.
// In this case we can add the user action name and parentId to the span attributes.
// If the span is created after the user action timeout span, the message object will be undefined which means the action has been cancelled or is ended.
if (this.message) {
span.attributes['faro.action.user.name'] = this.message?.name;
span.attributes['faro.action.user.parentId'] = this.message?.parentId;
}
}
this.processor.onStart(span, parentContext);
}
onEnd(span: ReadableSpan): void {
this.processor.onEnd(span);
}
shutdown(): Promise<void> {
return this.processor.shutdown();
}
}
@@ -1,122 +0,0 @@
import {
ConsoleTransport,
FetchTransport,
getWebInstrumentations,
initializeFaro,
} from '@grafana/faro-web-sdk';
import {
FaroTraceExporter,
TracingInstrumentation,
} from '@grafana/faro-web-tracing';
import { context, trace } from '@opentelemetry/api';
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { Resource } from '@opentelemetry/resources';
import {
BatchSpanProcessor,
ConsoleSpanExporter,
SimpleSpanProcessor,
WebTracerProvider,
} from '@opentelemetry/sdk-trace-web';
import {
ATTR_SERVICE_NAME,
ATTR_SERVICE_VERSION,
} from '@opentelemetry/semantic-conventions';
import { environment } from '../../../environments/environment';
import { FaroMetaAttributesSpanProcessor } from './external/faro-meta-attributes-span-processor';
import { FaroUserActionSpanProcessor } from './external/faro-user-action-span-processor';
const VERSION = '1.0.0';
export function faroInitializer(): Function {
return async () => {
const faro = initializeFaro({
instrumentations: [
...getWebInstrumentations({
captureConsole: true,
captureConsoleDisabledLevels: [],
}),
new TracingInstrumentation(),
],
transports: [
new FetchTransport({
url: environment.telemetry.otlpEndpoint,
apiKey: environment.telemetry.collectorSecret,
}),
new ConsoleTransport(),
],
trackResources: true,
trackUserActionsPreview: true,
app: {
name: environment.telemetry.serviceName,
version: VERSION,
namespace: 'avicenna',
},
});
const resource = Resource.default().merge(
new Resource({
[ATTR_SERVICE_NAME]: environment.telemetry.serviceName,
[ATTR_SERVICE_VERSION]: VERSION,
})
);
const provider = new WebTracerProvider({
resource,
spanProcessors: [
new SimpleSpanProcessor(new ConsoleSpanExporter()),
new FaroUserActionSpanProcessor(
new FaroMetaAttributesSpanProcessor(
new BatchSpanProcessor(new FaroTraceExporter({ ...faro })),
{
add: () => {},
remove: () => {},
addListener: () => {},
removeListener: () => {},
value: {
session: {
id: 'session-id',
},
user: {
email: 'email',
id: 'id',
username: 'user-short-name',
fullName: 'user-full-name',
roles: 'admin, editor,viewer',
hash: 'hash',
},
},
}
)
),
],
});
provider.register({
propagator: new W3CTraceContextPropagator(),
contextManager: new ZoneContextManager(),
});
const ignoreUrls = [environment.telemetry.otlpEndpoint];
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
// not needed to add the following, but it better shows the intention
'@opentelemetry/instrumentation-document-load': {},
'@opentelemetry/instrumentation-user-interaction': {},
'@opentelemetry/instrumentation-fetch': {
ignoreUrls,
},
'@opentelemetry/instrumentation-xml-http-request': {
ignoreUrls,
},
}),
],
});
faro.api.initOTEL(trace, context);
};
}
@@ -17,7 +17,7 @@ import { DashboardAccountingService } from '../dashboard-accounting.service';
<div class="flex justify-around items-center">
<div class="flex flex-col justify-center items-center">
<div class="text-xl">{{ kpi()?.count }}</div>
<div class="text-sm">Tickets</div>
<div class="text-sm">Touren</div>
</div>
@if(kpi()?.revenueSum && accountingState() !== AccountingState.Billed) {
<div class="flex flex-col justify-center items-center">
@@ -22,34 +22,41 @@ import {
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-item',
template: ` <div class="flex justify-between">
<div class="flex items-center">
<button
mat-icon-button
(click)="notifyCopy($event)"
[cdkCopyToClipboard]="tour.operationId"
>
<mat-icon class="text-sm">content_copy</mat-icon>
</button>
template: `
<div class="flex justify-between">
<div class="flex items-center">
<button
mat-icon-button
(click)="notifyCopy($event)"
[cdkCopyToClipboard]="tour.operationId"
>
<mat-icon class="text-sm">content_copy</mat-icon>
</button>
<a
class="dispolive-btn"
target="_blank"
[href]="'https://avicenna.dispolive.de/fahrbericht/' + tour.id"
>
{{ tour.operationId }}
</div>
<span>{{ tour.ordinanceType }}</span>
</a>
</div>
<div>
@if (accountingState !== AccountingState.Billed) {
<span
[title]="!tour.healthInsurance ? 'Keine Krankenkasse hinterlegt' : ''"
[ngClass]="{ 'bg-yellow-200': !tour.healthInsurance }"
>
{{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }}
</span>
} @else {
{{
tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
}}
}
</div>
`,
<span>{{ tour.ordinanceType }}</span>
</div>
<div>
@if (accountingState !== AccountingState.Billed) {
<span
[title]="!tour.healthInsurance ? 'Keine Krankenkasse hinterlegt' : ''"
[ngClass]="{ 'bg-yellow-200': !tour.healthInsurance }"
>
{{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }}
</span>
} @else {
{{
tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
}}
}
</div>
`,
host: {
class: 'flex flex-col rounded border p-2 m-1 bg-white cursor-pointer',
},
@@ -71,8 +78,8 @@ import {
MatBadgeModule,
ClipboardModule,
NgClass,
CurrencyPipe
],
CurrencyPipe,
],
})
export class AccountingItemComponent implements AfterViewInit {
toast = inject(HotToastService);
@@ -1,4 +1,3 @@
import {
ChangeDetectionStrategy,
Component,
@@ -11,30 +10,28 @@ import {
AccountingState,
} from '../../../core/data-access/graphql/generated/generated';
import { AccountingLaneHeaderFilterComponent } from './accounting-lane-header-filter.component';
import { RoleDirective } from 'src/app/core/components/ms-authentication/role.directive';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-lane-header',
template: `<div></div>
@switch (state) {
@case (AccountingState.Billable) {
@switch (state) { @case(AccountingState.Pending) {
<div>Offen</div>
} @case (AccountingState.Billable) {
<div>Abrechenbar</div>
}
@case (AccountingState.PreparedForBilling) {
} @case (AccountingState.PreparedForBilling) {
<div>für Abrechnung vorbereitet</div>
} @case (AccountingState.Billed) {
<div>Abgerechnet (mit Abweichung)</div>
} }
<div>
für Abrechnung vorbereitet
</div>
}
@case (AccountingState.Billed) {
<div>
Abgerechnet (mit Abweichung)
</div>
}
}
<dks-accounting-lane-header-filter
[state]="state"
(filterChange)="filterChanged($event)"
></dks-accounting-lane-header-filter>`,
<dks-accounting-lane-header-filter
*dksRole="['admin']"
[state]="state"
(filterChange)="filterChanged($event)"
></dks-accounting-lane-header-filter>
</div>`,
host: {
class: 'flex items-center justify-between p-2 bg-secondary-container',
},
@@ -45,7 +42,7 @@ import { AccountingLaneHeaderFilterComponent } from './accounting-lane-header-fi
}
`,
],
imports: [AccountingLaneHeaderFilterComponent],
imports: [AccountingLaneHeaderFilterComponent, RoleDirective],
})
export class AccountingLaneHeaderComponent {
@Input() state!: AccountingState;
@@ -98,7 +98,6 @@ export class AccountingTourCostBreakdownComponent {
servicePositions: any[] | undefined,
idx: number
): number {
console.log(servicePositions, idx);
return (servicePositions ?? [])
.slice(0, idx + 1)
.reduce((sum, { value }) => (sum += value), 0);
@@ -10,6 +10,10 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
<div class="col-span-1 md:col-span-3 flex justify-end items-center">
<!-- Action Buttons -->
</div>
<app-accounting-info-card
class="self-center"
[accountingState]="AccountingState.Pending"
></app-accounting-info-card>
<app-accounting-info-card
class="self-center"
[accountingState]="AccountingState.Billable"
@@ -18,20 +22,16 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
class="self-center"
[accountingState]="AccountingState.PreparedForBilling"
></app-accounting-info-card>
<app-accounting-info-card
class="self-center"
[accountingState]="AccountingState.Billed"
></app-accounting-info-card>
<div>
<dks-accounting-lane
[accountingState]="AccountingState.Billable"
[accountingState]="AccountingState.Pending"
></dks-accounting-lane>
</div>
<dks-accounting-lane
[accountingState]="AccountingState.PreparedForBilling"
[accountingState]="AccountingState.Billable"
></dks-accounting-lane>
<dks-accounting-lane
[accountingState]="AccountingState.Billed"
[accountingState]="AccountingState.PreparedForBilling"
></dks-accounting-lane>
</section>`,
styles: [
@@ -10,7 +10,6 @@ import {
PaginatedAnomaly,
PaginatedAnomalyGroup,
} from '../../core/data-access/graphql/generated/generated';
import { faro, LogLevel } from '@grafana/faro-web-sdk';
@Injectable()
export class AnomaliesService {
@@ -90,12 +89,6 @@ export class AnomaliesService {
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
);
} else {
faro.api.pushLog([`Sleep anomaly ${anomalyId}`], {
context: {
payload: anomalyId,
},
level: LogLevel.TRACE,
})
this.toast.success(
'Die Anomalie wurde für eine Stunde stummgeschaltet!'
);
@@ -119,12 +112,6 @@ export class AnomaliesService {
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
);
} else {
faro.api.pushLog([`Accept anomaly ${anomalyId}`], {
context: {
payload: anomalyId,
},
level: LogLevel.TRACE,
})
this.toast.success('Die Anomalie wurde akzeptiert!');
}
})
@@ -76,6 +76,5 @@ export class AnomalyViewComponent {
}
ngOnInit() {
console.log(this.anomalyId());
}
}
@@ -11,7 +11,6 @@ import {
import { ManagerInfoCardComponent } from '../manager/components/manager-info-card/manager-info-card.component';
import { TicketStateInfoCardComponent } from '../ticket-system/ticket-state-info-card/ticket-state-info-card.component';
import { LinkCardComponent } from './link-card/link-card.component';
import { SkeletonDirective } from '../../core/components/skeleton-rect/skeleton.directive';
@Component({
selector: 'app-home',
@@ -22,7 +21,6 @@ import { SkeletonDirective } from '../../core/components/skeleton-rect/skeleton.
LinkCardComponent,
MatDividerModule,
CurrencyPipe,
SkeletonDirective,
],
template: `
<div class="flex flex-col m-0 md:m-4">
@@ -28,7 +28,6 @@ export class TrendGraphComponent {
private chart?: ECharts;
public readonly options = computed<EChartsOption>(() => {
console.log(this.data());
return this.generateEchartsOptions();
});
@@ -7,11 +7,10 @@ import {
Metric,
} from '../../../../core/data-access/graphql/generated/generated';
import { ManagerDashboardService } from '../../manager-dashboard.service';
import { SkeletonDirective } from '../../../../core/components/skeleton-rect/skeleton.directive';
@Component({
selector: 'app-manager-info-card',
imports: [InfoCardModule, DecimalPipe, SkeletonDirective],
imports: [InfoCardModule, DecimalPipe],
templateUrl: './manager-info-card.component.html',
styleUrl: './manager-info-card.component.scss',
})
@@ -1,29 +1,25 @@
@if (ticket) {
<div class="flex justify-between">
<span class="text-base">
{{ ticket.tours.at(0)?.ordinanceType }}
</span>
<div>
@if (ticket.errors.length) {
<mat-icon
[matBadge]="ticket.errors.length"
matBadgeColor="warn"
[title]="ticket.errors.length + ' Anmerkungen'"
aria-hidden="false"
>
error
</mat-icon>
}
</div>
</div>
<span
>{{ ticket.tours.at(0)?.patientName }}
{{ ticket.tours.at(0)?.patientSurname }}</span
<div class="flex justify-between">
<span class="text-base">
{{ ticket.tours.at(0)?.ordinanceType }}
</span>
<div>
@if (ticket.errors.length) {
<mat-icon
[matBadge]="ticket.errors.length"
matBadgeColor="warn"
[title]="ticket.errors.length + ' Anmerkungen'"
aria-hidden="false"
>
<span
>{{ getToursLength() }} Tour{{
getToursLength() > 1 ? 'en' : ''
}}</span
>
<span class="text-xs self-end">{{ getDistance(ticket.createdAt) }}</span>
error
</mat-icon>
}
</div>
</div>
<span>
{{ ticket.tours.at(0)?.patientName }}
{{ ticket.tours.at(0)?.patientSurname }}
</span>
<span>{{ getToursLength() }} Tour{{ getToursLength() > 1 ? "en" : "" }}</span>
<span class="text-xs self-end">{{ getDistance(ticket.createdAt) }}</span>
}
@@ -1,8 +1,9 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
input,
Input,
OnInit,
} from '@angular/core';
@@ -24,6 +25,10 @@ import CategoryColors from './category-colors';
host: {
class:
'flex flex-col rounded-sm border-l-2 border-r-8 p-2 m-2 bg-white cursor-pointer',
'[class.bg-gray-100]': 'hasActivity',
'[class.bg-white]': '!hasActivity',
'[style.border-left-color]': 'leftBorderColor',
'[style.border-right-color]': 'rightBorderColor',
},
styles: [
`
@@ -35,11 +40,10 @@ import CategoryColors from './category-colors';
imports: [MatIconModule, MatBadgeModule],
})
export class TicketItemComponent implements OnInit {
hasActivity = input(false)
@Input() ticket!: Ticket & { tours: Tour[] };
@HostBinding('style.border-left-color')
leftBorderColor = '#ffffff';
@HostBinding('style.border-right-color')
rightBorderColor = '#ffffff';
@HostBinding('style.background-color')
@@ -1,4 +1,3 @@
import {
ChangeDetectionStrategy,
Component,
@@ -11,50 +10,42 @@ import {
TicketValidationState,
} from '../../../core/data-access/graphql/generated/generated';
import { TicketLaneHeaderFilterComponent } from './ticket-lane-header-filter.component';
import { RoleDirective } from 'src/app/core/components/ms-authentication/role.directive';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-lane-header',
template: `<div></div>
@switch (state) {
@case (TicketValidationState.Created) {
<div>Touren angenommen</div>
}
@case (TicketValidationState.UserInteractionRequired) {
<div>
Genehmigung/Transportschein
</div>
}
@case (TicketValidationState.TdUncertain) {
<div>
Transportschein unklar
</div>
}
@case (TicketValidationState.DocumentsMissing) {
<div>
Dokumente fehlen
</div>
}
@case (TicketValidationState.Archived) {
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-lane-header',
template: `<div></div>
@switch (state) { @case (TicketValidationState.Created) {
<div>Daten ergänzen</div>
} @case (TicketValidationState.UserInteractionRequired) {
<div>Genehmigung einleiten</div>
} @case (TicketValidationState.TdUncertain) {
<div>Transportschein organisieren</div>
} @case (TicketValidationState.DocumentsMissing) {
<div>Dokumente prüfen</div>
} @case (TicketValidationState.Archived) {
<div>Archiv</div>
}
}
<dks-ticket-lane-header-filter
[state]="state"
(filterChange)="filterChanged($event)"
></dks-ticket-lane-header-filter>`,
host: {
class: 'flex items-center justify-between p-2',
},
styles: [
`
} }
<div>
<dks-ticket-lane-header-filter
*dksRole="['admin']"
[state]="state"
(filterChange)="filterChanged($event)"
></dks-ticket-lane-header-filter>
</div>`,
host: {
class: 'flex items-center justify-between p-2',
},
styles: [
`
:host {
color: var(--mat-sys-on-secondary-container);
background-color: var(--mat-sys-secondary-container);
}
`,
],
imports: [TicketLaneHeaderFilterComponent]
],
imports: [TicketLaneHeaderFilterComponent, RoleDirective],
})
export class TicketLaneHeaderComponent {
@Input() state!: TicketValidationState;
@@ -13,6 +13,7 @@
class="mat-elevation-z4 animate-fadeIn"
(click)="openTicketView(ticket)"
[ticket]="ticket"
[hasActivity]="activityService.isDirty(ticket.id)"
>
</dks-ticket-item>
</cdk-virtual-scroll-viewport>
@@ -5,6 +5,7 @@ import {
import { AsyncPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
ViewChild,
@@ -31,6 +32,7 @@ import { TicketItemComponent } from '../ticket-item/ticket-item.component';
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
import { TicketsService } from '../tickets.service';
import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
import { ActivityService } from '../../../core/services/activity.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -58,6 +60,8 @@ import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
export class TicketLaneComponent {
private dialog = inject(DialogService);
private ticketsService = inject(TicketsService);
activityService = inject(ActivityService);
private cdr = inject(ChangeDetectorRef);
private ticketState$ = new BehaviorSubject<TicketValidationState | undefined>(
undefined
@@ -129,10 +133,15 @@ export class TicketLaneComponent {
}
openTicketView(ticket: Ticket & { tours: Tour[] }): void {
this.activityService.markAsDirty(ticket.id);
const dialogRef = this.dialog.open(TicketViewComponent, {
data: ticket,
width: '40vw',
minHeight: '400px',
enableClose: {
backdrop: true,
escape: false,
},
});
dialogRef.afterClosed$
@@ -1,7 +1,7 @@
<!-- <ng-container
*skeleton="isLoading(); repeat: 5; className: 'mr-2'; height: '50px'"
*skeleton="true; repeat: 1; className: 'mr-2'; height: '100px'"
></ng-container> -->
@if (kpi()?.main?.label) {
@if (kpi()?.main?.label != '') {
<app-info-card
[chartValue]="kpi()?.main?.value!"
[chartLabel]="kpi()?.main?.label!"
@@ -19,4 +19,15 @@
</app-info-card-footer>
}
</app-info-card>
} @else {
<app-info-card>
<div class="flex justify-around items-center">
@for(metric of metrics(); track metric.label) {
<div class="flex flex-col justify-center items-center">
<div class="text-xl">{{ metric.value }}</div>
<div class="text-sm">{{ metric.label }}</div>
</div>
}
</div>
</app-info-card>
}
@@ -8,11 +8,10 @@ import {
TicketValidationState,
} from '../../../core/data-access/graphql/generated/generated';
import { TicketsService } from '../tickets.service';
import { SkeletonDirective } from '../../../core/components/skeleton-rect/skeleton.directive';
@Component({
selector: 'app-ticket-state-info-card',
imports: [InfoCardModule, DecimalPipe, SkeletonDirective],
imports: [InfoCardModule, DecimalPipe],
templateUrl: './ticket-state-info-card.component.html',
styleUrl: './ticket-state-info-card.component.scss',
})
@@ -0,0 +1,133 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatOptionModule } from '@angular/material/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TransportDocumentUploadComponent } from 'src/app/core/components/transport-document-upload/transport-document-upload.component';
import {
Ticket,
Tour,
} from 'src/app/core/data-access/graphql/generated/generated';
import { TicketsService } from '../tickets.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { tap } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'app-ticket-approval-request',
template: `<div class="flex items-center p-3">
<form #form="ngForm" class="flex flex-col w-full">
<div class="flex flex-col p-2">
<label>Transportschein hochladen</label>
<app-transport-document-upload
[ticketId]="ticket().id"
(uploaded)="activateRecipientInput()"
class="flex-1"
></app-transport-document-upload>
</div>
<mat-form-field class="p-2">
<mat-label>Empfänger Adresse der Krankenkasse</mat-label>
<input
type="email"
name="approval"
matInput
[readonly]="!allowRecipientInput()"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
[matAutocomplete]="auto"
required
email
data-bwignore="true"
data-1p-ignore="true"
data-lpignore="true"
/>
<mat-autocomplete #auto="matAutocomplete">
@for (option of filteredAddresses(); track option) {
<mat-option [value]="option">{{ option }}</mat-option>
}
</mat-autocomplete>
</mat-form-field>
<button
mat-flat-button
color="primary"
[disabled]="form.invalid"
(click)="saveForm()"
>
Genehmigung anfragen
</button>
</form>
</div>`,
imports: [
MatButtonModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
MatOptionModule,
MatAutocompleteModule,
FormsModule,
TransportDocumentUploadComponent,
],
})
export class TicketApprovalRequestComponent {
ticketService = inject(TicketsService);
ticket = input.required<Ticket & { tours: Tour[] }>();
approvalRequested = output<void>()
allowRecipientInput = signal(false);
addresses = [
'ktw-genehmigung@nds.aok.de',
'service@nordost.aok.de',
'kontakt@nw.aok.de',
'aok@rh.aok.de',
'service@barmer.de',
'service@tk.de',
'service@dak.de',
'kontakt@hek.de',
'info@hkk.de',
'Service@ikk-classic.de',
'kundenservice@ikk-gesundplus.de',
'info@bkk-firmus.de',
'info@bkk-linde.de',
'service@bahn-bkk.de',
'zentrale@kbs.de',
'service@viactiv.de',
'mail@securvita-bkk.de',
'kundencenter@vividabkk.de',
'info@service.mobil-krankenkasse.de',
'info@novitas-bkk.de',
'service@kkh.de',
];
address = signal('');
filteredAddresses = computed(() =>
this.addresses.filter((address) =>
address.toLowerCase().includes(this.address())
)
);
saveForm() {
this.ticketService
.sendApprovalRequest(this.ticket().id, this.address())
.pipe(
tap(() => this.approvalRequested.emit())
)
.subscribe();
}
activateRecipientInput() {
this.allowRecipientInput.set(true);
}
}
@@ -1,5 +1,5 @@
import { NgClass } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { ChangeDetectionStrategy, Component, input, Input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import {
@@ -9,95 +9,69 @@ import {
} from '../../../core/data-access/graphql/generated/generated';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-overview',
template: `<div class="grid grid-cols-4">
<mat-list class="col-span-2" role="list">
<div mat-subheader class="text-sm">Ticket Informationen</div>
<mat-list-item role="listitem">
Typ:
<span class="font-bold">
{{ ticket.tours[0].type }}
</span>
</mat-list-item>
<mat-list-item role="listitem">
Kategorie:
<span class="font-bold">
{{ ticket.tours[0].category }}
</span>
</mat-list-item>
</mat-list>
@if (ticket.approvalState) {
<mat-list class="col-span-2" role="list">
<div mat-subheader class="text-sm">Gehnemigungsinformationen</div>
<mat-list-item role="listitem">
@switch (ticket.approvalState) {
@case (ApprovalState.Free) {
<span>Genehmigungsfrei</span>
}
@case (ApprovalState.Unknown) {
<span
title="Automatische Prüfung nicht möglich"
>Automatische Prüfung nicht möglich</span
>
}
@case (ApprovalState.Required) {
<span
>Genehmigung benötigt</span
>
}
@case (ApprovalState.Requested) {
<span
>Genehmigung angefragt</span
>
}
@case (ApprovalState.Approved) {
<span
>Genehmigung eingegangen</span
>
}
}
</mat-list-item>
</mat-list>
}
@if (ticket.errors.length > 0 || ticket.notes.length > 0) {
<mat-list
class="col-span-2 max-h-96 overflow-y-auto"
role="list"
>
<div mat-subheader class="text-sm">Ticket Anmerkungen</div>
@for (error of ticket.errors; track error) {
<mat-list-item
lines="2"
role="listitem"
>
<mat-icon matListItemIcon class="!text-red-600">error</mat-icon>
<span matListItemTitle>{{ error.split(':').at(0) }}</span>
<span [title]="error.split(':').at(1)">{{
error.split(':').at(1)
}}</span>
</mat-list-item>
}
@for (note of ticket.notes; track note) {
<mat-list-item
lines="2"
role="listitem"
>
<mat-icon matListItemIcon class="!text-yellow-400">warning</mat-icon>
<span matListItemTitle>{{ note.split(':').at(0) }}</span>
<span [title]="note.split(':').at(1)">{{ note.split(':').at(1) }}</span>
</mat-list-item>
}
</mat-list>
}
</div>`,
imports: [
MatListModule,
MatIconModule
]
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-overview',
template: `<div class="grid grid-cols-4">
<mat-list class="col-span-2" role="list">
<div mat-subheader class="text-sm">Ticket Informationen</div>
<mat-list-item role="listitem">
Typ:
<span class="font-bold">
{{ ticket().tours[0].type }}
</span>
</mat-list-item>
<mat-list-item role="listitem">
Kategorie:
<span class="font-bold">
{{ ticket().tours[0].category }}
</span>
</mat-list-item>
</mat-list>
@if (ticket().approvalState) {
<mat-list class="col-span-2" role="list">
<div mat-subheader class="text-sm">Gehnemigungsinformationen</div>
<mat-list-item role="listitem">
@switch (ticket().approvalState) { @case (ApprovalState.Free) {
<span>Genehmigungsfrei</span>
} @case (ApprovalState.Unknown) {
<span title="Automatische Prüfung nicht möglich"
>Automatische Prüfung nicht möglich</span
>
} @case (ApprovalState.Required) {
<span>Genehmigung benötigt</span>
} @case (ApprovalState.Requested) {
<span>Genehmigung angefragt</span>
} @case (ApprovalState.Approved) {
<span>Genehmigung eingegangen</span>
} }
</mat-list-item>
</mat-list>
} @if (ticket().errors.length > 0 || ticket().notes.length > 0) {
<mat-list class="col-span-2 max-h-96 overflow-y-auto" role="list">
<div mat-subheader class="text-sm">Ticket Anmerkungen</div>
@for (error of ticket().errors; track error) {
<mat-list-item lines="2" role="listitem">
<mat-icon matListItemIcon class="!text-red-600">error</mat-icon>
<span matListItemTitle>{{ error.split(':').at(0) }}</span>
<span [title]="error.split(':').at(1)">{{
error.split(':').at(1)
}}</span>
</mat-list-item>
}
<!-- @for (note of ticket.notes; track note) {
<mat-list-item lines="2" role="listitem">
<mat-icon matListItemIcon class="!text-yellow-400">warning</mat-icon>
<span matListItemTitle>{{ note.split(':').at(0) }}</span>
<span [title]="note.split(':').at(1)">{{ note.split(':').at(1) }}</span>
</mat-list-item>
} -->
</mat-list>
}
</div>`,
imports: [MatListModule, MatIconModule],
})
export class TicketOverviewComponent {
@Input() ticket!: Ticket & { tours: Tour[] };
ticket = input.required<Ticket & { tours: Tour[] }>()
ApprovalState = ApprovalState;
}
@@ -4,7 +4,7 @@ import {
ChangeDetectionStrategy,
Component,
Input,
OnInit
OnInit,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -14,11 +14,12 @@ import {
Ticket,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
import { TransportDocumentUploadComponent } from '../../../core/components/transport-document-upload/transport-document-upload.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-tours',
template: ` <div class="max-h-96 overflow-y-auto">
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-tours',
template: ` <div class="max-h-96 overflow-y-auto">
<table mat-table [dataSource]="dataSource" class="w-full">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
@@ -41,7 +42,14 @@ import {
<ng-container matColumnDef="operationId">
<th mat-header-cell *matHeaderCellDef>Einsatznummer</th>
<td mat-cell *matCellDef="let tour">{{ tour.operationId }}</td>
<td mat-cell *matCellDef="let tour">
<a
class="dispolive-btn"
target="_blank"
[href]="'https://avicenna.dispolive.de/fahrbericht/' + tour.id"
>{{ tour.operationId }}</a
>
</td>
</ng-container>
<ng-container matColumnDef="startDate">
<th mat-header-cell *matHeaderCellDef>Anfahrt</th>
@@ -78,13 +86,13 @@ import {
></tr>
</table>
</div>`,
imports: [
NgClass,
DatePipe,
MatButtonModule,
MatCheckboxModule,
MatTableModule,
]
imports: [
NgClass,
DatePipe,
MatButtonModule,
MatCheckboxModule,
MatTableModule,
],
})
export class TicketToursComponent implements OnInit {
@Input() ticket!: Ticket & { tours: Tour[] };
@@ -107,7 +115,6 @@ export class TicketToursComponent implements OnInit {
);
ngOnInit(): void {
console.log(this.ticket.tours)
this.dataSource = [...this.ticket.tours]
.filter(({ check }) => check === 0)
.sort(
@@ -1,10 +1,7 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
EventEmitter,
inject,
Input,
@@ -19,6 +16,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { AuthService } from '../../../core/components/ms-authentication/auth.service';
import {
ApprovalState,
DocumentInfo,
@@ -26,94 +24,91 @@ import {
TicketValidationState,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
import { AuthService } from '../../../core/components/ms-authentication/auth.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-ticket-user-action-required',
template: `<div class="flex flex-col p-3">
<form #form="ngForm" class="flex flex-col w-full">
<div class="flex justify-end pt-4">
<button mat-stroked-button class="mr-2" (click)="form.resetForm()">
Zurücksetzen
</button>
<button
mat-flat-button
color="primary"
[disabled]="form.invalid"
(click)="saveForm()"
>
Speichern
</button>
</div>
<div class="flex flex-col p-2">
<label>Wird eine Genehmigung benötigt?</label>
<mat-radio-group
[(ngModel)]="formModel().approval.isNeeded"
name="approvalNeeded"
required
>
<mat-radio-button [value]="false"> Nein </mat-radio-button>
<mat-radio-button [value]="true"> Ja </mat-radio-button>
</mat-radio-group>
</div>
@if (
(ticket.approvalState === ApprovalState.Required &&
formModel().approval.isNeeded) ||
(ticket.approvalState === ApprovalState.Unknown &&
formModel().approval.isNeeded)
) {
<div class="flex flex-col p-2">
<label>Wer fragte die Genehmigung an?</label>
<mat-radio-group
[(ngModel)]="formModel().approval.whoRequested"
name="who"
required
>
<mat-radio-button value="Avicenna"> Avicenna </mat-radio-button>
<mat-radio-button value="Kunde"> Kunde </mat-radio-button>
</mat-radio-group>
</div>
<mat-form-field class="p-2">
<mat-label>Name des Sachbearbeiters?</mat-label>
<input
type="text"
matInput
id="staff"
[(ngModel)]="formModel().approval.answeredFrom"
name="staff"
required
/>
</mat-form-field>
}
<div class="flex flex-col p-2">
<label>Wo befindet sich der Transportschein?</label>
<mat-radio-group
[(ngModel)]="formModel().tdLocation"
name="tdLocation"
required
>
<mat-radio-button [value]="'company'"> Im Betrieb </mat-radio-button>
<mat-radio-button [value]="'practice'">
In der Praxis
</mat-radio-button>
<mat-radio-button [value]="'customer'">
Beim Kunden
</mat-radio-button>
<mat-radio-button [value]="'uncertain'"> Unklar </mat-radio-button>
</mat-radio-group>
</div>
</form>
</div>`,
<form #form="ngForm" class="flex flex-col w-full">
<div class="flex justify-end pt-4">
<button mat-stroked-button class="mr-2" (click)="form.resetForm()">
Zurücksetzen
</button>
<button
mat-flat-button
color="primary"
[disabled]="form.invalid"
(click)="saveForm()"
>
Speichern
</button>
</div>
<div class="flex flex-col p-2">
<label>Wird eine Genehmigung benötigt?</label>
<mat-radio-group
[(ngModel)]="formModel().approval.isNeeded"
name="approvalNeeded"
required
>
<mat-radio-button [value]="false"> Nein </mat-radio-button>
<mat-radio-button [value]="true"> Ja </mat-radio-button>
</mat-radio-group>
</div>
@if ( (ticket.approvalState === ApprovalState.Required &&
formModel().approval.isNeeded) || (ticket.approvalState ===
ApprovalState.Unknown && formModel().approval.isNeeded) ) {
<div class="flex flex-col p-2">
<label>Wurde die Genehmigung bereits angefragt?</label>
<mat-radio-group
[(ngModel)]="formModel().approval.whoRequested"
name="who"
required
>
<mat-radio-button value="Avicenna"> Ja, durch Avicenna </mat-radio-button>
<mat-radio-button value="Kunde"> Ja, durch den Kunden </mat-radio-button>
<mat-radio-button value="no"> Nein, noch nicht </mat-radio-button>
</mat-radio-group>
</div>
<mat-form-field class="p-2">
<mat-label>Name des Sachbearbeiters?</mat-label>
<input
type="text"
matInput
id="staff"
[(ngModel)]="formModel().approval.answeredFrom"
name="staff"
required
/>
</mat-form-field>
}
<div class="flex flex-col p-2">
<label>Wo befindet sich der Transportschein im Original?</label>
<mat-radio-group
[(ngModel)]="formModel().tdLocation"
name="tdLocation"
required
>
<mat-radio-button [value]="'company'"> Im Betrieb </mat-radio-button>
<mat-radio-button [value]="'practice'">
In der Praxis
</mat-radio-button>
<mat-radio-button [value]="'customer'">
Beim Kunden
</mat-radio-button>
<mat-radio-button [value]="'uncertain'"> Unklar </mat-radio-button>
</mat-radio-group>
</div>
</form>
</div>`,
imports: [
MatButtonModule,
MatIconModule,
MatFormFieldModule,
MatRadioModule,
MatInputModule,
FormsModule
],
FormsModule,
],
})
export class TicketUserActionRequiredComponent implements OnInit {
@Input() ticket!: Ticket & { tours: Tour[] };
@@ -4,29 +4,35 @@
</h3>
<hr />
<mat-tab-group>
@if(ref.data.currentState !== TicketState.Created) {
@if(!!ref.data.approvalState && [ApprovalState.Unknown,
ApprovalState.Required,
ApprovalState.Requested].includes(ref.data.approvalState)) {
<mat-tab label="Genehmigungsanfrage">
<app-ticket-approval-request
[ticket]="ref.data"
(approvalRequested)="ref.close()"
></app-ticket-approval-request>
</mat-tab>
} @if(ref.data.currentState !== TicketState.Created) {
<mat-tab label="Aktionen">
<ng-container>
@switch (ref.data.currentState) {
@case (TicketState.UserInteractionRequired) {
<dks-ticket-user-action-required
[ticket]="ref.data"
(saveFormData)="setDocumentInfo($event)"
></dks-ticket-user-action-required>
}
@case (TicketState.TdUncertain) {
<dks-ticket-td-uncertain
[ticket]="ref.data"
(saveFormData)="setDocumentInfo($event)"
></dks-ticket-td-uncertain>
}
@case (TicketState.DocumentsMissing) {
<dks-ticket-documents-missing
[ticket]="ref.data"
(saveFormData)="setDocumentInfo($event)"
></dks-ticket-documents-missing>
}
}
@switch (ref.data.currentState) { @case
(TicketState.UserInteractionRequired) {
<dks-ticket-user-action-required
[ticket]="ref.data"
(saveFormData)="setDocumentInfo($event)"
></dks-ticket-user-action-required>
} @case (TicketState.TdUncertain) {
<dks-ticket-td-uncertain
[ticket]="ref.data"
(saveFormData)="setDocumentInfo($event)"
></dks-ticket-td-uncertain>
} @case (TicketState.DocumentsMissing) {
<dks-ticket-documents-missing
[ticket]="ref.data"
(saveFormData)="setDocumentInfo($event)"
></dks-ticket-documents-missing>
} }
</ng-container>
</mat-tab>
}
@@ -16,6 +16,7 @@ import { TicketOverviewComponent } from './ticket-overview.component';
import { TicketTdUncertainComponent } from './ticket-td-uncertain.component';
import { TicketToursComponent } from './ticket-tours.component';
import { TicketUserActionRequiredComponent } from './ticket-user-action-required.component';
import { TicketApprovalRequestComponent } from './ticket-approval-request.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -27,6 +28,7 @@ import { TicketUserActionRequiredComponent } from './ticket-user-action-required
TicketOverviewComponent,
TicketToursComponent,
TicketUserActionRequiredComponent,
TicketApprovalRequestComponent,
TicketTdUncertainComponent,
TicketDocumentsMissingComponent,
],
@@ -21,6 +21,7 @@ import {
GetTicketStateMetaQuery,
SearchTicketsGQL,
SearchTicketsQuery,
SendApprovalRequestGQL,
SetDocumentInfoGQL,
SetDocumentInfoMutationVariables,
TicketFilterArgs,
@@ -53,11 +54,33 @@ export class TicketsService {
private readonly searchTicketsGql: SearchTicketsGQL,
private readonly setDocumentInfoGql: SetDocumentInfoGQL,
private readonly ticketStateKpiGql: TicketStateKpiGQL,
private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
private readonly toast: HotToastService
) {
this.updateTickets$.next();
}
sendApprovalRequest(ticketId: string, email: string) {
return this.sendApprovalRequestGql
.mutate({
email,
ticketId,
})
.pipe(
tap((mutationResult) => {
if (mutationResult.errors) {
this.toast.error(
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
);
} else {
this.toast.success(
'Die Genehmigungsanfrage wird versendet.'
);
}
})
);
}
fetchTicketsByState(
state: TicketValidationState,
cursor: string,
@@ -1,9 +1,19 @@
export const environment = {
production: false,
api: {
baseUrl: 'http://localhost:3000/'
},
graphQl: {
endpoint: 'http://localhost:3000/graphql',
ws: 'ws://localhost:3000/graphql',
},
// msAuth: {
// clientId: 'd1f302fb-9060-4aaa-8730-5156fc31a585',
// authority:
// 'https://login.microsoftonline.com/0d2df07a-f64d-446c-8e4e-2807638a3141',
// redirectUri: 'https://dashboard.avicenna.hamburg/',
// postLogoutRedirectUri: 'http://dashboard.avicenna.hamburg/',
// },
msAuth: {
clientId: '9a4a1e7a-9544-439f-9cd3-3c20066a8a82',
authority:
@@ -16,4 +26,4 @@ export const environment = {
otlpEndpoint: 'https://alloy.genius.ceo/collect',
serviceName: 'avicenna.dashboard.dev'
}
};
};
@@ -1,5 +1,8 @@
export const environment = {
production: true,
api: {
baseUrl: '{{API_BASEURL}}'
},
graphQl: {
endpoint: '{{GRAPHQL_ENDPOINT}}',
ws: '{{GRAPHQL_WS_ENDPOINT}}'
@@ -1,5 +1,8 @@
export const environment = {
production: false,
api: {
baseUrl: ''
},
graphQl: {
endpoint: '',
ws: '',
+90 -2
View File
@@ -1,5 +1,12 @@
/* You can add global styles to this file, and also import other style files */
@use "@angular/material" as mat;
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "@angular/cdk/overlay-prebuilt.css";
/* You can add global styles to this file, and also import other style files */
html,
body {
@@ -12,7 +19,8 @@ body {
color: var(--mat-sys-on-surface);
}
.dispolive-btn, .dyflexis-btn {
.dispolive-btn,
.dyflexis-btn {
a[target="_blank"]::after {
content: " \2924"; /* Unicode-Pfeil */
font-size: 0.8em;
@@ -38,3 +46,83 @@ body {
opacity: 1;
}
}
@import "@spartan-ng/brain/hlm-tailwind-preset.css";
:root {
--dialog-backdrop-z-index: 999;
color-scheme: light;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
:root.dark {
color-scheme: dark;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.985 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+14 -2
View File
@@ -20,7 +20,19 @@
"ES2022",
"dom",
"esnext.asynciterable"
]
],
"baseUrl": "./",
"paths": {
"@spartan-ng/helm/input": [
"src/app/core/base-components/input/src/index.ts"
],
"@spartan-ng/helm/utils": [
"src/app/core/base-components/utils/src/index.ts"
],
"@spartan-ng/helm/label": [
"./src/app/core/base-components/label/src/index.ts"
]
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
@@ -28,4 +40,4 @@
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
}