Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel Arndt d448ca6516 WIP 2026-01-19 12:21:48 +01:00
168 changed files with 27849 additions and 40574 deletions
-1
View File
@@ -7,4 +7,3 @@
.DS_Store .DS_Store
node_modules/ node_modules/
dist/
+5 -5
View File
@@ -2,6 +2,11 @@ FROM node:20.19.1-slim AS builder
WORKDIR /app/builder WORKDIR /app/builder
COPY package*.json .
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
# COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
COPY app-hub/dashboard/package*.json ./app-hub/dashboard/
ENV LIBC=glibc ENV LIBC=glibc
ENV npm_config_arch=x64 ENV npm_config_arch=x64
ENV npm_config_platform=linux ENV npm_config_platform=linux
@@ -12,11 +17,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY package*.json .
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
# COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
COPY app-hub/dashboard/package*.json ./app-hub/dashboard/
RUN npm ci --verbose RUN npm ci --verbose
COPY packages/shared-dtos ./packages/shared-dtos/ COPY packages/shared-dtos ./packages/shared-dtos/
+6 -32
View File
@@ -18,7 +18,7 @@
}, },
"architect": { "architect": {
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular-devkit/build-angular:application",
"options": { "options": {
"outputPath": "dist/dashboard", "outputPath": "dist/dashboard",
"index": "src/index.html", "index": "src/index.html",
@@ -37,7 +37,7 @@
], ],
"styles": [ "styles": [
"src/m3-theme.scss", "src/m3-theme.scss",
"../../node_modules/@ngxpert/hot-toast/src/styles/styles.css", "node_modules/@ngxpert/hot-toast/src/styles/styles.css",
"src/tailwind.css", "src/tailwind.css",
"src/styles.scss" "src/styles.scss"
], ],
@@ -49,7 +49,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "500kB",
"maximumError": "2.5MB" "maximumError": "2MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
@@ -80,7 +80,7 @@
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular/build:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "dashboard:build:production" "buildTarget": "dashboard:build:production"
@@ -92,10 +92,10 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular/build:extract-i18n" "builder": "@angular-devkit/build-angular:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"polyfills": [ "polyfills": [
"zone.js", "zone.js",
@@ -119,31 +119,5 @@
} }
} }
} }
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
} }
} }
+27 -26
View File
@@ -11,26 +11,27 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^21.1.0", "@angular/animations": "^19.2.6",
"@angular/cdk": "^21.1.0", "@angular/cdk": "^19.2.9",
"@angular/common": "^21.1.0", "@angular/common": "^19.2.6",
"@angular/compiler": "^21.1.0", "@angular/compiler": "^19.2.6",
"@angular/core": "^21.1.0", "@angular/core": "^19.2.6",
"@angular/forms": "^21.1.0", "@angular/forms": "^19.2.6",
"@angular/material": "^21.1.0", "@angular/material": "^19.2.9",
"@angular/material-date-fns-adapter": "^21.1.0", "@angular/material-date-fns-adapter": "^19.2.9",
"@angular/platform-browser": "^21.1.0", "@angular/platform-browser": "^19.2.6",
"@angular/platform-browser-dynamic": "^21.1.0", "@angular/platform-browser-dynamic": "^19.2.6",
"@angular/router": "^21.1.0", "@angular/router": "^19.2.6",
"@apollo/client": "^4.1.0", "@apollo/client": "^3.13.8",
"@avicenna/shared-dtos": "^1.0.0",
"@azure/msal-angular": "^3.0.22", "@azure/msal-angular": "^3.0.22",
"@azure/msal-browser": "^3.19.1", "@azure/msal-browser": "^3.19.1",
"@hyperdx/browser": "^0.22.0", "@hyperdx/browser": "^0.22.0",
"@ng-icons/core": "^33.0.0", "@ng-icons/core": "^31.4.0",
"@ng-icons/lucide": "^33.0.0", "@ng-icons/lucide": "^31.4.0",
"@ngneat/dialog": "^5.1.1", "@ngneat/dialog": "^5.1.1",
"@ngneat/overview": "^7.0.0", "@ngneat/overview": "^6.0.0",
"@ngxpert/hot-toast": "^6.0.0", "@ngxpert/hot-toast": "^3.0.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-web": "^0.45.1", "@opentelemetry/auto-instrumentations-web": "^0.45.1",
"@opentelemetry/context-zone": "^1.30.1", "@opentelemetry/context-zone": "^1.30.1",
@@ -42,14 +43,14 @@
"@opentelemetry/resources": "^1.30.1", "@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-trace-web": "^1.30.1", "@opentelemetry/sdk-trace-web": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@spartan-ng/brain": "0.0.1-alpha.605", "@spartan-ng/brain": "^0.0.1-alpha.561",
"@tanstack/angular-query-experimental": "^5.90.20", "@tanstack/angular-query-experimental": "^5.90.20",
"apollo-angular": "^13.0.0", "apollo-angular": "^10.0.3",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"graphql": "^16.12.0", "graphql": "^16.11.0",
"graphql-ws": "^5.16.0", "graphql-ws": "^5.16.0",
"ngx-scrollbar": "^19.1.1", "ngx-scrollbar": "^19.1.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -58,16 +59,16 @@
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.1.0", "@angular-devkit/build-angular": "^19.2.7",
"@angular/cli": "^21.1.0", "@angular/cli": "^19.2.7",
"@angular/compiler-cli": "^21.1.0", "@angular/compiler-cli": "^19.2.6",
"@angular/localize": "^21.1.0", "@angular/localize": "^19.2.6",
"@graphql-codegen/cli": "^5.0.5", "@graphql-codegen/cli": "^5.0.5",
"@graphql-codegen/typescript": "^4.1.6", "@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-apollo-angular": "^4.0.0", "@graphql-codegen/typescript-apollo-angular": "^4.0.0",
"@graphql-codegen/typescript-operations": "^4.6.0", "@graphql-codegen/typescript-operations": "^4.6.0",
"@material/material-color-utilities": "^0.3.0", "@material/material-color-utilities": "^0.3.0",
"@spartan-ng/cli": "0.0.1-alpha.605", "@spartan-ng/cli": "^0.0.1-alpha.561",
"@tailwindcss/postcss": "^4.1.4", "@tailwindcss/postcss": "^4.1.4",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
@@ -77,10 +78,10 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.3", "postcss": "^8.4.39",
"prettier": "^3.8.0", "prettier": "^3.8.0",
"tailwindcss": "^4.1.4", "tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3" "typescript": "~5.5.2"
} }
} }
@@ -18,6 +18,7 @@ import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module'; import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
import { GlobalErrorHandler } from './core/telemetry/global-error-handler';
import { graphqlProvider } from './graphql.provider'; import { graphqlProvider } from './graphql.provider';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -42,6 +43,10 @@ window.__TANSTACK_QUERY_CLIENT__ = queryClient;
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()), provideRouter(routes, withComponentInputBinding()),
provideAnimationsAsync(), provideAnimationsAsync(),
@@ -61,18 +61,6 @@ export const routes: Routes = [
title: 'Ticketsystem', title: 'Ticketsystem',
}, },
}, },
{
path: 'playground',
loadComponent: () =>
import('./pages/playground/playground.component').then(
(m) => m.PlaygroundComponent
),
canActivate: [MsalGuard, roleGuard],
data: {
title: 'Playground',
roles: ['manager', 'admin'],
},
},
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
@@ -10,5 +10,5 @@ import type { ClassValue } from 'clsx';
}) })
export class HlmCardFooter { export class HlmCardFooter {
public readonly userClass = input<ClassValue>('', { alias: 'class' }); public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-4', this.userClass())); protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-6', this.userClass()));
} }
@@ -12,7 +12,7 @@ export class HlmCardHeader {
public readonly userClass = input<ClassValue>('', { alias: 'class' }); public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => protected readonly _computedClass = computed(() =>
hlm( hlm(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4', '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
this.userClass(), this.userClass(),
), ),
); );
@@ -3,7 +3,7 @@ import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority'; import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx'; import type { ClassValue } from 'clsx';
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm', { export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', {
variants: {}, variants: {},
defaultVariants: {}, defaultVariants: {},
}); });
@@ -3,6 +3,7 @@ import { Injectable, type TemplateRef, inject } from '@angular/core';
import { import {
type BrnDialogOptions, type BrnDialogOptions,
BrnDialogService, BrnDialogService,
DEFAULT_BRN_DIALOG_OPTIONS,
cssClassesToArray, cssClassesToArray,
} from '@spartan-ng/brain/dialog'; } from '@spartan-ng/brain/dialog';
import { HlmDialogContent } from './hlm-dialog-content'; import { HlmDialogContent } from './hlm-dialog-content';
@@ -21,6 +22,8 @@ export class HlmDialogService {
public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) { public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) {
const mergedOptions = { const mergedOptions = {
...DEFAULT_BRN_DIALOG_OPTIONS,
...(options ?? {}), ...(options ?? {}),
backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`), backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
context: { context: {
@@ -1,5 +0,0 @@
import { HlmSkeleton } from './lib/hlm-skeleton';
export * from './lib/hlm-skeleton';
export const HlmSkeletonImports = [HlmSkeleton] as const;
@@ -1,14 +0,0 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
@Directive({
selector: '[hlmSkeleton],hlm-skeleton',
host: {
'data-slot': 'skeleton',
},
})
export class HlmSkeleton {
constructor() {
classes(() => 'bg-accent block rounded-md motion-safe:animate-pulse');
}
}
@@ -1,9 +1,4 @@
import { import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { BrnTabs } from '@spartan-ng/brain/tabs'; import { BrnTabs } from '@spartan-ng/brain/tabs';
import { hlm } from '@spartan-ng/helm/utils'; import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx'; import type { ClassValue } from 'clsx';
@@ -14,7 +9,7 @@ import type { ClassValue } from 'clsx';
hostDirectives: [ hostDirectives: [
{ {
directive: BrnTabs, directive: BrnTabs,
inputs: ['orientation', 'activationMode', 'brnTabs: tab'], inputs: ['orientation', 'direction', 'activationMode', 'brnTabs: tab'],
outputs: ['tabActivated'], outputs: ['tabActivated'],
}, },
], ],
@@ -27,7 +22,5 @@ export class HlmTabs {
public readonly tab = input.required<string>(); public readonly tab = input.required<string>();
public readonly userClass = input<ClassValue>('', { alias: 'class' }); public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => protected readonly _computedClass = computed(() => hlm('flex flex-col gap-2', this.userClass()));
hlm('flex flex-col gap-2', this.userClass()),
);
} }
@@ -1,257 +1,6 @@
import { isPlatformBrowser } from '@angular/common';
import {
DestroyRef,
effect,
ElementRef,
HostAttributeToken,
inject,
Injector,
PLATFORM_ID,
runInInjectionContext,
} from '@angular/core';
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) { export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// Global map to track class managers per element
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
// Global mutation observer for all elements
let globalObserver: MutationObserver | null = null;
const observedElements = new Set<HTMLElement>();
interface ElementClassManager {
element: HTMLElement;
sources: Map<number, { classes: Set<string>; order: number }>;
baseClasses: Set<string>;
isUpdating: boolean;
nextOrder: number;
hasInitialized: boolean;
}
let sourceCounter = 0;
/**
* This function dynamically adds and removes classes for a given element without requiring
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
*
* 1. This will merge the existing classes on the element with the new classes.
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
* 3. Multiple calls to this function on the same element will be merged efficiently.
*/
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
runInInjectionContext(options.injector ?? inject(Injector), () => {
const elementRef = options.elementRef ?? inject(ElementRef);
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
const element = elementRef.nativeElement;
// Create unique identifier for this source
const sourceId = sourceCounter++;
// Get or create the class manager for this element
let manager = elementClassManagers.get(element);
if (!manager) {
// Initialize base classes from variation (host attribute 'class')
const initialBaseClasses = new Set<string>();
if (baseClasses) {
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
}
manager = {
element,
sources: new Map(),
baseClasses: initialBaseClasses,
isUpdating: false,
nextOrder: 0,
hasInitialized: false,
};
elementClassManagers.set(element, manager);
// Setup global observer if needed and register this element
setupGlobalObserver(platformId);
observedElements.add(element);
}
// Assign order once at registration time
const sourceOrder = manager.nextOrder++;
function updateClasses(): void {
// Get the new classes from the computed function
const newClasses = toClassList(computed());
// Update this source's classes, keeping the original order
manager!.sources.set(sourceId, {
classes: new Set(newClasses),
order: sourceOrder,
});
// Update the element
updateElement(manager!);
}
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
// Remove this source from the manager
manager!.sources.delete(sourceId);
// If no more sources, clean up the manager
if (manager!.sources.size === 0) {
cleanupManager(element);
} else {
// Update element without this source's classes
updateElement(manager!);
}
});
/**
* We need this effect to track changes to the computed classes. Ideally, we would use
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
* effect which works in both browser and SSR.
*/
effect(updateClasses);
});
}
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
function setupGlobalObserver(platformId: Object): void {
if (isPlatformBrowser(platformId) && !globalObserver) {
// Create single global observer that watches the entire document
globalObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target as HTMLElement;
const manager = elementClassManagers.get(element);
// Only process elements we're managing
if (manager && observedElements.has(element)) {
if (manager.isUpdating) continue; // Ignore changes we're making
// Update base classes to include any externally added classes
const currentClasses = toClassList(element.className);
const allSourceClasses = new Set<string>();
// Collect all classes from all sources
for (const source of manager.sources.values()) {
for (const className of source.classes) {
allSourceClasses.add(className);
}
}
// Any classes not from sources become new base classes
manager.baseClasses.clear();
for (const className of currentClasses) {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
}
updateElement(manager);
}
}
}
});
// Start observing the entire document for class attribute changes
globalObserver.observe(document, {
attributes: true,
attributeFilter: ['class'],
subtree: true, // Watch all descendants
});
}
}
function updateElement(manager: ElementClassManager): void {
if (manager.isUpdating) return; // Prevent recursive updates
manager.isUpdating = true;
// Handle initialization: capture base classes after first source registration
if (!manager.hasInitialized && manager.sources.size > 0) {
// Get current classes on element (may include SSR classes)
const currentClasses = toClassList(manager.element.className);
// Get all classes that will be applied by sources
const allSourceClasses = new Set<string>();
for (const source of manager.sources.values()) {
source.classes.forEach((className) => allSourceClasses.add(className));
}
// Only consider classes as "base" if they're not produced by any source
// This prevents SSR-rendered classes from being preserved as base classes
currentClasses.forEach((className) => {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
});
manager.hasInitialized = true;
}
// Get classes from all sources, sorted by registration order (later takes precedence)
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
const allSourceClasses: string[] = [];
for (const [, source] of sortedSources) {
allSourceClasses.push(...source.classes);
}
// Combine base classes with all source classes, ensuring base classes take precedence
const classesToApply =
allSourceClasses.length > 0 || manager.baseClasses.size > 0
? twMerge(clsx([...allSourceClasses, ...manager.baseClasses]))
: '';
// Apply the classes to the element
if (manager.element.className !== classesToApply) {
manager.element.className = classesToApply;
}
manager.isUpdating = false;
}
function cleanupManager(element: HTMLElement): void {
// Remove from global tracking
observedElements.delete(element);
elementClassManagers.delete(element);
// If no more elements being tracked, cleanup global observer
if (observedElements.size === 0 && globalObserver) {
globalObserver.disconnect();
globalObserver = null;
}
}
interface ClassesOptions {
elementRef?: ElementRef<HTMLElement>;
injector?: Injector;
}
// Cache for parsed class lists to avoid repeated string operations
const classListCache = new Map<string, string[]>();
function toClassList(className: string | ClassValue[]): string[] {
// For simple string inputs, use cache to avoid repeated parsing
if (typeof className === 'string' && classListCache.has(className)) {
return classListCache.get(className)!;
}
const result = clsx(className)
.split(' ')
.filter((c) => c.length > 0);
// Cache string results, but limit cache size to prevent memory growth
if (typeof className === 'string' && classListCache.size < 1000) {
classListCache.set(className, result);
}
return result;
}
@@ -1,19 +0,0 @@
export const accountingKeys = {
all: ['accounting'] as const,
lists: () => [...accountingKeys.all, 'list'] as const,
byState: (accountingState: string) =>
[...accountingKeys.lists(), { accountingState }] as const,
byStatePaginate: (
accountingState: string,
page: number | undefined,
take: number,
) => [...accountingKeys.lists(), { accountingState, page, take }] as const,
details: () => [...accountingKeys.all, 'detail'] as const,
byTourId: (tourId: string) => [...accountingKeys.details(), tourId] as const,
cars: () => [...accountingKeys.all, 'cars'] as const,
byCarAndDate: (carName: string, date: string) =>
[...accountingKeys.cars(), { carName, date }] as const,
};
@@ -1,133 +0,0 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { inject } from '@angular/core';
import {
injectQuery,
keepPreviousData,
} from '@tanstack/angular-query-experimental';
import { environment } from 'src/environments/environment';
import { accountingKeys } from './accounting.keys';
import { lastValueFrom } from 'rxjs';
import {
AccountingListDto,
OptimizationCarDto,
PaginatedResult,
PaginatedTourDto,
TourCostBreakdownDto,
TourDto,
TourStateMetaDto,
} from '@avicenna/shared-dtos';
const BASE_URL = environment.api.baseUrl;
const API_URL = '/accounting';
export function useTours(
accountingState: () => string,
page: () => number | undefined,
take: () => number,
) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: accountingKeys.byStatePaginate(accountingState(), page(), take()),
queryFn: () => {
let params = new HttpParams()
.set('state', accountingState())
.set('take', take());
if (!!page()) {
params = params.set('page', page()!);
}
return lastValueFrom(
http.get<PaginatedResult<AccountingListDto>>(
`${BASE_URL}${API_URL}/tours`,
{
params,
},
),
);
},
placeholderData: keepPreviousData,
}));
}
export function useTourDetail(tourId: () => string) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: accountingKeys.byTourId(tourId()),
queryFn: () =>
lastValueFrom(
http.get<TourDto>(`${BASE_URL}${API_URL}/tours/${tourId()}`),
),
}));
}
export function useAccountingStateMeta(accountingState: () => string) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: accountingKeys.byState(accountingState()),
queryFn: () => {
const params = new HttpParams().set('state', accountingState());
return lastValueFrom(
http.get<TourStateMetaDto>(`${BASE_URL}${API_URL}/state-meta`, {
params,
}),
);
},
}));
}
export function useTourCostBreakdown(operationId: () => string) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: accountingKeys.byTourId(operationId()),
queryFn: () => {
return lastValueFrom(
http.get<TourCostBreakdownDto>(
`${BASE_URL}${API_URL}/cost-breakdown/${operationId()}`,
),
);
},
enabled: !!operationId(),
}));
}
export function useOptimizationCar(
carName: () => string | undefined | null,
date: () => Date | string | undefined | null,
) {
const http = inject(HttpClient);
return injectQuery(() => {
const car = carName();
const d = date();
// Datum sicher formatieren für den Query Key und API Param
let dateStr = '';
if (d) {
// Quick & Dirty ISO yyyy-MM-dd, alternativ date-fns format(d, 'yyyy-MM-dd')
dateStr = new Date(d).toISOString().split('T')[0];
}
return {
queryKey: accountingKeys.byCarAndDate(car ?? '', dateStr),
queryFn: () => {
const params = new HttpParams().set('operation-day', dateStr);
return lastValueFrom(
http.get<OptimizationCarDto>(`${BASE_URL}${API_URL}/cars/${car}`, {
params,
}),
);
},
enabled: !!car && !!dateStr,
staleTime: 1000 * 60 * 5,
};
});
}
@@ -1,23 +0,0 @@
import { format } from 'date-fns';
export const managerKeys = {
all: ['manager'] as const,
overview: (ordinanceTypes: string) => [
...managerKeys.all,
'overview',
{ ordinanceTypes },
] as const,
dayview: (ordinanceType: string, day: Date | string) => [
...managerKeys.all,
'dayview',
{ ordinanceType, day: format(new Date(day), 'yyyy-MM-dd') },
] as const,
monthview: (ordinanceType: string, day: Date | string) => [
...managerKeys.all,
'monthview',
{ ordinanceType, day: format(new Date(day), 'yyyy-MM-dd') },
] as const,
};
@@ -1,74 +0,0 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { inject } from '@angular/core';
import {
TimePerTourKPISummaryDto,
TimePerTourMetricDto,
} from '@avicenna/shared-dtos';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
import { managerKeys } from './manager.keys';
const BASE_URL = environment.api.baseUrl;
const API_URL = '/manager';
export function getOverviewQuery(ordinanceTypes: string) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: managerKeys.overview(ordinanceTypes),
queryFn: () => {
const params = new HttpParams().set('ordinanceType', ordinanceTypes);
return lastValueFrom(
http.get<TimePerTourKPISummaryDto[]>(`${BASE_URL}${API_URL}/overview`, {
params,
}),
);
},
}));
}
export function getDayviewQuery(
day: () => Date | string,
ordinanceType: () => string,
) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: managerKeys.dayview(ordinanceType(), day()),
queryFn: () => {
const params = new HttpParams()
.set('day', new Date(day()).toISOString())
.set('ordinanceType', ordinanceType());
return lastValueFrom(
http.get<TimePerTourMetricDto[]>(`${BASE_URL}${API_URL}/dayview`, {
params,
}),
);
},
}));
}
export function getMonthviewQuery(
day: () => Date | string,
ordinanceType: () => string,
) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: managerKeys.monthview(ordinanceType(), day()),
queryFn: () => {
const params = new HttpParams()
.set('day', new Date(day()).toISOString())
.set('ordinanceType', ordinanceType());
return lastValueFrom(
http.get<TimePerTourMetricDto[]>(`${BASE_URL}${API_URL}/monthview`, {
params,
}),
);
},
}));
}
@@ -0,0 +1,11 @@
import { ErrorHandler, Injectable } from '@angular/core';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error: any) {
if (error instanceof Error) {
// faro.api.pushError(error);
}
console.error(error);
}
}
@@ -15,7 +15,6 @@ import {
lucideBell, lucideBell,
lucideCircleAlert, lucideCircleAlert,
lucideCircleCheck, lucideCircleCheck,
lucideInbox,
lucideSend, lucideSend,
lucideSquareCheck, lucideSquareCheck,
lucideStamp, lucideStamp,
@@ -51,8 +50,7 @@ import { ticketKeys } from '../../ticket.keys';
lucideSend, lucideSend,
lucideBell, lucideBell,
lucideSquareCheck, lucideSquareCheck,
lucideCircleAlert, lucideCircleAlert
lucideInbox,
}), }),
], ],
template: `<section template: `<section
@@ -60,7 +58,8 @@ import { ticketKeys } from '../../ticket.keys';
class="flex flex-col overflow-hidden border-l-4 py-4" class="flex flex-col overflow-hidden border-l-4 py-4"
[class.border-l-muted]="view().requirement === 'NOT_REQUIRED'" [class.border-l-muted]="view().requirement === 'NOT_REQUIRED'"
[class.border-l-yellow-500]=" [class.border-l-yellow-500]="
view().status !== 'GRANTED' && view().requirement === 'REQUIRED' view().status !== 'GRANTED' &&
view().requirement === 'REQUIRED'
" "
[class.border-l-green-500]="view().status === 'GRANTED'" [class.border-l-green-500]="view().status === 'GRANTED'"
> >
@@ -69,7 +68,8 @@ import { ticketKeys } from '../../ticket.keys';
class="h-9 w-9 flex items-center justify-center rounded-full shrink-0" class="h-9 w-9 flex items-center justify-center rounded-full shrink-0"
[class.bg-muted]="view().requirement === 'NOT_REQUIRED'" [class.bg-muted]="view().requirement === 'NOT_REQUIRED'"
[class.bg-yellow-100]=" [class.bg-yellow-100]="
view().status !== 'GRANTED' && view().requirement === 'REQUIRED' view().status !== 'GRANTED' &&
view().requirement === 'REQUIRED'
" "
[class.bg-green-100]="view().status === 'GRANTED'" [class.bg-green-100]="view().status === 'GRANTED'"
> >
@@ -116,15 +116,12 @@ import { ticketKeys } from '../../ticket.keys';
} }
</div> </div>
<p class="text-xs text-muted-foreground mt-1"> <p class="text-xs text-muted-foreground mt-1">
<!-- FIXME: move to backend -->
@if (view().requirement === 'NOT_REQUIRED') { @if (view().requirement === 'NOT_REQUIRED') {
Für diese Fahrt ist keine Genehmigung notwendig. Für diese Fahrt ist keine Genehmigung notwendig.
} @else if (view().status === 'NOT_STARTED') { } @else if (view().status === 'NOT_STARTED') {
Noch keine Anfrage gestellt. Noch keine Anfrage gestellt.
} @else if (view().status === 'GRANTED') { } @else if (view().status === 'GRANTED') {
Genehmigung erteilt Genehmigung erteilt
} @else if (view().status === 'EXTERNAL_REQUEST') {
Wartet auf Rückmeldung des Kunden
} @else { } @else {
Wartet auf Rückmeldung der Kasse. Wartet auf Rückmeldung der Kasse.
} }
@@ -209,19 +206,6 @@ import { ticketKeys } from '../../ticket.keys';
} }
} }
@if (view().actions.registerExternalRequest.isPossible) {
<button
hlmBtn
variant="outline"
class="w-full gap-2"
(click)="registerRequest()"
[disabled]="isSubmitting()"
>
<ng-icon hlmIcon name="lucideInbox" size="xs" />
{{ view().actions.registerExternalRequest.label }}
</button>
}
@if (view().actions.sendReminder.isPossible) { @if (view().actions.sendReminder.isPossible) {
<button <button
hlmBtn hlmBtn
@@ -346,13 +330,6 @@ export class TicketApprovalComponent {
); );
} }
async registerRequest(): Promise<void> {
this.executeAction(
() => this.actionsService.registerExternalRequest(this.ticketId()),
'Externe Anfrage registriert',
);
}
async sendReminder(): Promise<void> { async sendReminder(): Promise<void> {
this.executeAction( this.executeAction(
() => this.actionsService.sendReminder(this.ticketId()), () => this.actionsService.sendReminder(this.ticketId()),
@@ -14,7 +14,7 @@ import { HlmIcon } from '@spartan-ng/helm/icon';
@Component({ @Component({
selector: 'app-ticket-validation-errors', selector: 'app-ticket-validation-errors',
imports: [HlmCardImports, HlmButtonImports, NgIcon, HlmIcon], imports: [HlmCardImports, HlmButtonImports, NgIcon, HlmIcon, RouterLink],
providers: [ providers: [
provideIcons({ provideIcons({
lucideTriangleAlert, lucideTriangleAlert,
@@ -1,12 +1,6 @@
<div class="w-full"> <div class="w-full">
@if (!isUploading()) { @if (!isUploading()) {
<label <label
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
[class.border-primary]="isDragging()"
[class.bg-primary\/10]="isDragging()"
[class.border-muted-foreground\/25]="!isDragging()"
class="group flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/5 border-muted-foreground/25 hover:bg-muted/20 hover:border-primary/50 transition-all" class="group flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/5 border-muted-foreground/25 hover:bg-muted/20 hover:border-primary/50 transition-all"
> >
<div <div
@@ -16,21 +10,11 @@
hlmIcon hlmIcon
name="lucideCloudUpload" name="lucideCloudUpload"
size="lg" size="lg"
[class.scale-125]="isDragging()"
[class.text-primary]="isDragging()"
class="mb-3 text-muted-foreground group-hover:text-primary group-hover:scale-110 transition-transform duration-300" class="mb-3 text-muted-foreground group-hover:text-primary group-hover:scale-110 transition-transform duration-300"
/> />
<p class="text-sm text-foreground font-medium"> <p class="text-sm text-foreground font-medium">Klicken zum Hochladen</p>
@if (isDragging()) { <p class="text-xs text-muted-foreground mt-1">PDF oder Bild (Max 5MB)</p>
Loslassen zum Hochladen
} @else {
Klicken oder Datei hierher ziehen
}
</p>
<p class="text-xs text-muted-foreground mt-1">
PDF oder Bild (Max 5MB)
</p>
</div> </div>
<input <input
@@ -51,7 +35,7 @@
size="sm" size="sm"
class="text-primary animate-pulse" class="text-primary animate-pulse"
/> />
<span class="text-sm font-medium truncate max-w-50">{{ <span class="text-sm font-medium truncate max-w-[200px]">{{
fileName() fileName()
}}</span> }}</span>
</div> </div>
@@ -63,9 +47,7 @@
></div> ></div>
</div> </div>
<div <div class="flex justify-between w-full mt-2 text-xs text-muted-foreground">
class="flex justify-between w-full mt-2 text-xs text-muted-foreground"
>
<span>Lade hoch...</span> <span>Lade hoch...</span>
<span>{{ uploadProgress() }}%</span> <span>{{ uploadProgress() }}%</span>
</div> </div>
@@ -45,32 +45,6 @@ export class TransportDocumentUploadComponent {
uploadProgress = signal(0); uploadProgress = signal(0);
fileName = signal<string | null>(null); fileName = signal<string | null>(null);
isDragging = signal(false);
onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDragging.set(true);
}
onDragLeave(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDragging.set(false);
}
onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
this.isDragging.set(false);
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
this.startUpload(file)
}
}
onFileSelected(event: Event): void { onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) { if (input.files && input.files.length > 0) {
@@ -10,42 +10,24 @@ export class TicketActionsService {
private http = inject(HttpClient); private http = inject(HttpClient);
private baseUrl = environment.api.baseUrl; private baseUrl = environment.api.baseUrl;
async receivePhysicalTransportDocument(ticketId: string): Promise<void> {
return lastValueFrom(
this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/transport-document/receive`,
{},
),
);
}
async overrideApproval(ticketId: string): Promise<void> { async overrideApproval(ticketId: string): Promise<void> {
return lastValueFrom( return lastValueFrom(
this.http.post<void>( this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/override`, `${this.baseUrl}/tickets/${ticketId}/approval/override`,
{}, {}
), )
); );
} }
async requestApproval( async requestApproval(
ticketId: string, ticketId: string,
recipientEmail: string, recipientEmail: string
): Promise<void> { ): Promise<void> {
return lastValueFrom( return lastValueFrom(
this.http.post<void>( this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/request`, `${this.baseUrl}/tickets/${ticketId}/approval/request`,
{ recipient: recipientEmail }, { recipient: recipientEmail }
), )
);
}
async registerExternalRequest(ticketId: string): Promise<void> {
return lastValueFrom(
this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/register-request`,
{},
),
); );
} }
@@ -53,8 +35,8 @@ export class TicketActionsService {
return lastValueFrom( return lastValueFrom(
this.http.post<void>( this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/remind`, `${this.baseUrl}/tickets/${ticketId}/approval/remind`,
{}, {}
), )
); );
} }
@@ -62,8 +44,8 @@ export class TicketActionsService {
return lastValueFrom( return lastValueFrom(
this.http.post<void>( this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/approve`, `${this.baseUrl}/tickets/${ticketId}/approval/approve`,
{}, {}
), )
); );
} }
} }
@@ -3,10 +3,7 @@ export const ticketKeys = {
// Listen (Kanban Lanes) // Listen (Kanban Lanes)
lists: () => [...ticketKeys.all, 'list'] as const, lists: () => [...ticketKeys.all, 'list'] as const,
byLane: (lane: string, page: number) => byLane: (lane: string, page: number) => [...ticketKeys.lists(), { lane, page }] as const,
[...ticketKeys.lists(), { lane, page }] as const,
search: (searchTerm: string) =>
[...ticketKeys.lists(), { searchTerm }] as const,
// Details // Details
details: () => [...ticketKeys.all, 'detail'] as const, details: () => [...ticketKeys.all, 'detail'] as const,
@@ -1,5 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { ticketKeys } from './ticket.keys';
import { import {
PaginatedResult, PaginatedResult,
TicketDetailDto, TicketDetailDto,
@@ -9,9 +11,7 @@ import {
injectQuery, injectQuery,
keepPreviousData, keepPreviousData,
} from '@tanstack/angular-query-experimental'; } from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { ticketKeys } from './ticket.keys';
const BASE_URL = environment.api.baseUrl; const BASE_URL = environment.api.baseUrl;
const API_URL = '/tickets'; const API_URL = '/tickets';
@@ -24,29 +24,14 @@ export function useTicketLane(lane: () => string, page: () => number) {
queryFn: async () => { queryFn: async () => {
return lastValueFrom( return lastValueFrom(
http.get<PaginatedResult<TicketListItemDto>>( http.get<PaginatedResult<TicketListItemDto>>(
`${BASE_URL}${API_URL}?stage=${lane()}&page=${page()}`, `${BASE_URL}${API_URL}?stage=${lane()}&page=${page()}`
), )
); );
}, },
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
})); }));
} }
export function useTicketSearch(search: () => string) {
const http = inject(HttpClient);
return injectQuery(() => ({
queryKey: ticketKeys.search(search()),
queryFn: async () =>
lastValueFrom(
http.get<TicketListItemDto[]>(
`${BASE_URL}${API_URL}/search?q=${search()}`,
),
),
enabled: !!search(),
}));
}
export function useTicketDetail(ticketId: () => string) { export function useTicketDetail(ticketId: () => string) {
const http = inject(HttpClient); const http = inject(HttpClient);
@@ -55,7 +40,7 @@ export function useTicketDetail(ticketId: () => string) {
enabled: !!ticketId(), enabled: !!ticketId(),
queryFn: async () => { queryFn: async () => {
return lastValueFrom( return lastValueFrom(
http.get<TicketDetailDto>(`${BASE_URL}${API_URL}/${ticketId()}`), http.get<TicketDetailDto>(`${BASE_URL}${API_URL}/${ticketId()}`)
); );
}, },
})); }));
@@ -1,20 +1,15 @@
import { ApplicationConfig, inject } from '@angular/core'; import { ApplicationConfig, inject } from '@angular/core';
import { import { ApolloClientOptions, InMemoryCache, split } from '@apollo/client/core';
ApolloClientOptions,
ApolloLink,
InMemoryCache,
split,
} from '@apollo/client/core';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities'; import { getMainDefinition } from '@apollo/client/utilities';
import { provideApollo } from 'apollo-angular'; import { Apollo, APOLLO_OPTIONS } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http'; import { HttpLink } from 'apollo-angular/http';
import { OperationDefinitionNode } from 'graphql'; import { OperationDefinitionNode } from 'graphql';
import { createClient } from 'graphql-ws'; import { createClient } from 'graphql-ws';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
const uri = environment.graphQl.endpoint; // <-- add the URL of the GraphQL server here const uri = environment.graphQl.endpoint; // <-- add the URL of the GraphQL server here
export function apolloOptionsFactory(): ApolloClientOptions { export function apolloOptionsFactory(): ApolloClientOptions<any> {
const httpLink = inject(HttpLink); const httpLink = inject(HttpLink);
const http = httpLink.create({ uri }); const http = httpLink.create({ uri });
@@ -22,18 +17,18 @@ export function apolloOptionsFactory(): ApolloClientOptions {
const ws = new GraphQLWsLink( const ws = new GraphQLWsLink(
createClient({ createClient({
url: environment.graphQl.ws, url: environment.graphQl.ws,
}), })
); );
const link = split( const link = split(
({ query }) => { ({ query }) => {
const { kind, operation } = getMainDefinition( const { kind, operation } = getMainDefinition(
query, query
) as OperationDefinitionNode; ) as OperationDefinitionNode;
return kind === 'OperationDefinition' && operation === 'subscription'; return kind === 'OperationDefinition' && operation === 'subscription';
}, },
ws, ws,
http, http
); );
return { return {
link, link,
@@ -42,35 +37,9 @@ export function apolloOptionsFactory(): ApolloClientOptions {
} }
export const graphqlProvider: ApplicationConfig['providers'] = [ export const graphqlProvider: ApplicationConfig['providers'] = [
provideApollo(() => { Apollo,
const httpLink = inject(HttpLink); {
provide: APOLLO_OPTIONS,
const http = httpLink.create({ uri }); useFactory: apolloOptionsFactory,
const ws = new GraphQLWsLink(
createClient({
url: environment.graphQl.ws,
}),
);
const link = ApolloLink.split(
({ query }) => {
const { kind, operation } = getMainDefinition(
query,
) as OperationDefinitionNode;
return kind === 'OperationDefinition' && operation === 'subscription';
}, },
ws,
http,
);
return {
link,
cache: new InMemoryCache(),
};
}),
// Apollo,
// {
// provide: APOLLO_OPTIONS,
// useFactory: apolloOptionsFactory,
// },
]; ];
@@ -1,5 +1,5 @@
import { ClipboardModule } from '@angular/cdk/clipboard'; import { ClipboardModule } from '@angular/cdk/clipboard';
import { CurrencyPipe, DatePipe, NgClass } from '@angular/common'; import { CurrencyPipe, NgClass } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -11,121 +11,97 @@ import {
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { AccountingListDto } from '@avicenna/shared-dtos';
import { HotToastService } from '@ngxpert/hot-toast'; import { HotToastService } from '@ngxpert/hot-toast';
import { AccountingState } from '../../../core/data-access/graphql/generated/generated'; import {
import { NgIcon, provideIcons } from '@ng-icons/core'; AccountingState,
import { HlmIcon } from '@spartan-ng/helm/icon'; Ticket,
import { lucideCalendar } from '@ng-icons/lucide'; TicketValidationState,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-item', selector: 'dks-accounting-item',
template: ` template: `
<div <div class="flex justify-between">
class="group relative flex flex-col justify-between rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all hover:shadow-md hover:border-primary/50 mb-2" <div class="flex items-center">
<button
mat-icon-button
(click)="notifyCopy($event)"
[cdkCopyToClipboard]="tour.operationId"
> >
<div class="flex items-start justify-between p-3 pb-1"> <mat-icon class="text-sm">content_copy</mat-icon>
<div class="flex flex-col"> </button>
<a
class="dispolive-btn"
target="_blank"
[href]="'https://avicenna.dispolive.de/fahrbericht/' + tour.id"
>
{{ tour.operationId }}
</a>
</div>
<span>{{ tour.ordinanceType }}</span>
</div>
<div>
@if (accountingState !== AccountingState.Billed) {
<span <span
class="text-[10px] uppercase text-muted-foreground font-medium tracking-wider" [title]="!tour.healthInsurance ? 'Keine Krankenkasse hinterlegt' : ''"
[ngClass]="{ 'bg-yellow-200': !tour.healthInsurance }"
> >
Ref {{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }}
</span> </span>
<span } @else {
class="font-mono text-xs text-foreground/80 font-semibold group-hover:text-primary transition-colors" {{
> tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
#{{ tour.operationId }} }}
</span> }
</div>
<div class="flex items-center justify-center gap-2 py-2">
<div
[class]="
tour.isTicketOpen
? 'border-sky-700 text-sky-700 bg-sky-50/10'
: 'border-border text-muted-foreground/30'
"
class="flex px-2 py-1 items-center justify-center rounded-md text-xs font-bold border-2 transition-all"
title="Ticket Offen"
>
TO
</div>
<div
[class]="
tour.hasRevenueDeviation
? 'border-sky-700 text-sky-700 bg-sky-50/10'
: 'border-border text-muted-foreground/30'
"
class="flex px-2 py-1 items-center justify-center rounded-md text-xs font-bold border-2 transition-all"
title="Abrechnungsabweichung"
>
AA
</div>
<div
[class]="
tour.hasOptimizationPotential
? 'border-sky-700 text-sky-700 bg-sky-50/10'
: 'border-border text-muted-foreground/30'
"
class="flex px-2 py-1 items-center justify-center rounded-md text-xs font-bold border-2 transition-all"
title="Optimierungspotenzial"
>
AO
</div>
</div>
<span
class="inline-flex items-center rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground ring-1 ring-inset ring-gray-500/10"
>
{{ tour.ordinanceType }}
</span>
</div>
<div
class="flex items-end justify-between border-t border-border/50 bg-muted/5 p-3"
>
<div class="flex items-center gap-1 text-muted-foreground">
<ng-icon hlmIcon size="sm" name="lucideCalendar" class="opacity-80"></ng-icon>
<span class="text-xs font-medium">
{{ tour.startDate | date: 'dd.MM.yy' }}
</span>
</div>
<div class="flex flex-col items-end">
<span class="text-sm font-bold tracking-tight text-foreground">
{{ tour.revenue | currency: 'EUR' : 'symbol' : '1.2-2' }}
</span>
</div>
</div>
</div> </div>
`, `,
host: {
class: 'flex flex-col rounded border p-2 m-1 bg-white cursor-pointer',
},
styles: [
`
@use '@angular/material' as mat;
:host {
@include mat.icon-button-overrides(
(
icon-size: 14,
)
);
}
`,
],
imports: [ imports: [
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
MatBadgeModule, MatBadgeModule,
ClipboardModule, ClipboardModule,
NgClass,
CurrencyPipe, CurrencyPipe,
DatePipe,
NgIcon,
HlmIcon,
],
providers: [
provideIcons({
lucideCalendar,
}),
], ],
}) })
export class AccountingItemComponent { export class AccountingItemComponent implements AfterViewInit {
toast = inject(HotToastService); toast = inject(HotToastService);
@Input() tour!: AccountingListDto; @Input() tour!: Tour & { ticket: Ticket };
@Input() accountingState!: AccountingState; @Input() accountingState!: AccountingState;
@HostBinding('style.border-color')
borderColor = '#ffffff';
AccountingState = AccountingState; AccountingState = AccountingState;
ngAfterViewInit(): void {
if (
this.tour.ticket.currentState !== TicketValidationState.Archived &&
(this.accountingState === AccountingState.Billable ||
this.accountingState === AccountingState.PreparedForBilling)
) {
this.borderColor = '#dc2626';
}
}
notifyCopy(event: Event) { notifyCopy(event: Event) {
event.stopPropagation(); event.stopPropagation();
this.toast.success('Einsatznummer kopiert!', { this.toast.success('Einsatznummer kopiert!', {
@@ -1,69 +1,28 @@
<dks-accounting-lane-header <dks-accounting-lane-header
class="border-b border-border/50 bg-card z-10" class="shadow"
[state]="accountingState()" [state]="accountingState"
(filterChange)="filterChanged($event)"
></dks-accounting-lane-header> ></dks-accounting-lane-header>
@if (tours$ | async; as tours) {
<div class="overflow-y-auto p-3 space-y-3 scroll-smooth"> <cdk-virtual-scroll-viewport
@if (toursQuery.isPending()) { itemSize="86"
<div (scrolledIndexChange)="fetchNextPage()"
class="flex h-20 items-center justify-center text-muted-foreground text-sm animate-pulse"
> >
Lade Touren...
</div>
}
@if (toursQuery.data(); as result) {
@for (tour of result.data; track tour.tourId) {
<dks-accounting-item <dks-accounting-item
class="block animate-in fade-in zoom-in-95 duration-200" *cdkVirtualFor="let tour of tours; trackBy: trackByFn"
[accountingState]="accountingState()" class="animate-fadeIn"
style="box-shadow: var(--mat-sys-level2);"
[accountingState]="accountingState"
[tour]="tour" [tour]="tour"
(click)="openTourView(tour)" (click)="openTourView(tour)"
> >
</dks-accounting-item> </dks-accounting-item>
} </cdk-virtual-scroll-viewport>
@if (result.data.length === 0) { <!-- <ng-container *ngIf="accountingStateMeta$ | async as accountingStateMeta">
<div <dks-accounting-lane-footer
class="flex h-28 flex-col items-center justify-center rounded-xl border border-dashed border-muted-foreground/25 bg-muted/50 p-4 text-center text-sm text-muted-foreground" [tourStateMeta]="accountingStateMeta"
> [withTourCount]="true"
Keine Touren in dieser Lane. [withRevenueSum]="accountingState !== AccountingState.Billed"
</div> ></dks-accounting-lane-footer>
} </ng-container> -->
} }
</div>
<nav
hlmPagination
class="border-t border-border bg-card/50 p-2 backdrop-blur-[2px]"
>
<ul hlmPaginationContent class="justify-between px-2 w-full">
<li hlmPaginationItem>
<hlm-pagination-previous
class="h-8 w-8"
variant="ghost"
iconOnly
(click)="prevPage()"
[class.opacity-50]="!hasPrevPage()"
[class.pointer-events-none]="!hasPrevPage()"
></hlm-pagination-previous>
</li>
<li hlmPaginationItem class="text-xs font-medium text-muted-foreground">
@if (toursQuery.data(); as res) {
Seite {{ page() }} / {{ res.meta.totalPages }}
} @else {
- / -
}
</li>
<li hlmPaginationItem>
<hlm-pagination-next
class="h-8 w-8"
variant="ghost"
iconOnly
(click)="nextPage()"
[class.opacity-50]="!hasNextPage()"
[class.pointer-events-none]="!hasNextPage()"
></hlm-pagination-next>
</li>
</ul>
</nav>
@@ -1,23 +1,36 @@
import { import {
ScrollingModule CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'; } from '@angular/cdk/scrolling';
import { AsyncPipe } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
computed, Input,
ViewChild,
inject, inject,
input,
signal
} from '@angular/core'; } from '@angular/core';
import { AccountingListDto, PaginatedResult } from '@avicenna/shared-dtos';
import { DialogService } from '@ngneat/dialog'; import { DialogService } from '@ngneat/dialog';
import { HlmPaginationImports } from '@spartan-ng/helm/pagination';
import { tap } from 'rxjs';
import { useTours } from 'src/app/core/data-access/accounting/accounting.queries';
import { import {
AccountingState BehaviorSubject,
Observable,
combineLatest,
debounceTime,
filter,
map,
mergeMap,
scan,
switchMap,
tap,
} from 'rxjs';
import {
AccountingFilterArgs,
AccountingState,
Ticket,
Tour,
} from '../../../core/data-access/graphql/generated/generated'; } from '../../../core/data-access/graphql/generated/generated';
import { AccountingTourViewComponent } from '../accounting-tour-view/accounting-tour-view.component'; import { AccountingTourViewComponent } from '../accounting-tour-view/accounting-tour-view.component';
import { DashboardAccountingService } from '../dashboard-accounting.service';
import { AccountingItemComponent } from './accounting-item.component'; import { AccountingItemComponent } from './accounting-item.component';
import { AccountingLaneHeaderComponent } from './accounting-lane-header.component'; import { AccountingLaneHeaderComponent } from './accounting-lane-header.component';
@@ -26,49 +39,99 @@ import { AccountingLaneHeaderComponent } from './accounting-lane-header.componen
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './accounting-lane.component.html', templateUrl: './accounting-lane.component.html',
host: { host: {
class: class: 'rounded h-full w-full grid',
'grid grid-rows-[auto_1fr_auto] h-full w-full rounded-2xl border border-border bg-muted/5 shadow-sm overflow-hidden',
}, },
styles: [
`
:host {
box-shadow: var(--mat-sys-level1);
background-color: var(--mat-sys-surface-container-low);
grid-template-rows: auto 1fr auto;
}
`,
],
imports: [ imports: [
AccountingLaneHeaderComponent, AccountingLaneHeaderComponent,
AccountingItemComponent, AccountingItemComponent,
ScrollingModule, AsyncPipe,
HlmPaginationImports, ScrollingModule
], ],
}) })
export class AccountingLaneComponent { export class AccountingLaneComponent {
private dialog = inject(DialogService) private dialog = inject(DialogService);
accountingState = input.required<AccountingState>(); private accountingService = inject(DashboardAccountingService);
page = signal(1); private accountingState$ = new BehaviorSubject<AccountingState | undefined>(
private readonly take = signal<number>(10); undefined
);
private accountingFilter$ = new BehaviorSubject<AccountingFilterArgs>({});
toursQuery = useTours(this.accountingState, this.page, this.take); @Input() set accountingState(accountingState: AccountingState) {
this.accountingState$.next(accountingState);
hasNextPage = computed(() => {
const data: PaginatedResult<AccountingListDto> | undefined =
this.toursQuery.data();
if (!data) return false;
return this.page() < data.meta.totalPages;
});
hasPrevPage = computed(() => this.page() > 1);
prevPage(): void {
if (this.hasPrevPage()) {
this.page.update((p) => p - 1);
} }
get accountingState() {
return this.accountingState$.getValue() as AccountingState;
} }
nextPage(): void { @ViewChild(CdkVirtualScrollViewport)
if (this.hasNextPage()) { viewport!: CdkVirtualScrollViewport;
this.page.update((p) => p + 1);
} AccountingState = AccountingState;
take = 20;
hasNextPage = true;
nextPage = '';
cursor$ = new BehaviorSubject<string>('');
accountingStateMeta$ = this.accountingState$.pipe(
filter(Boolean),
switchMap((accountingState) =>
this.accountingService.fetchAccountingStateMeta(accountingState)
)
);
tours$: Observable<(Tour & { ticket: Ticket })[]>;
constructor() {
const batchMap: Observable<Record<string, Tour & { ticket: Ticket }>> =
combineLatest([this.cursor$, this.accountingFilter$]).pipe(
debounceTime(200),
mergeMap(([cursor, filter]) =>
this.fetchTours(cursor, filter).pipe(
map((tours) => ({ tours, cursor }))
)
),
scan((acc, { tours, cursor }) => {
const normalizedCursor = this.getNormalizedCursor(cursor);
const existingTours = Object.entries(acc)
.filter(
([id]) =>
id.split('_').at(0) !== normalizedCursor ||
Object.keys(tours).includes(id.split('_').at(1) ?? '')
)
.reduce(
(acc, [id, tour]) => ({
...acc,
[`${normalizedCursor}_${id}`]: tour,
}),
{} as Record<string, Tour & { ticket: Ticket }>
);
return { ...existingTours, ...tours };
}, {} as Record<string, Tour & { ticket: Ticket }>)
);
this.tours$ = batchMap.pipe(
map((tourNodes) =>
Object.values(tourNodes).sort(
({ createdAt: aCreated }, { createdAt: bCreated }) =>
new Date(bCreated).getTime() - new Date(aCreated).getTime()
)
)
);
} }
openTourView(tour: AccountingListDto): void { openTourView(tour: Tour & { ticket: Ticket }): void {
const dialogRef = this.dialog.open(AccountingTourViewComponent, { const dialogRef = this.dialog.open(AccountingTourViewComponent, {
data: tour.tourId, data: tour,
width: '60vw', width: '60vw',
minHeight: '400px', minHeight: '400px',
}); });
@@ -76,4 +139,59 @@ export class AccountingLaneComponent {
.pipe(tap((result) => result && this.openTourView(result))) .pipe(tap((result) => result && this.openTourView(result)))
.subscribe(); .subscribe();
} }
trackByFn(index: number, tour: Tour): string {
return tour.id;
}
fetchNextPage() {
if (!this.hasNextPage) {
return;
}
const start = this.viewport.getRenderedRange().start;
const end = this.viewport.getRenderedRange().end;
const total = this.viewport.getDataLength();
if (this.cursor$.getValue() !== '' && start === 0) {
this.cursor$.next('');
} else if (total - end < 5) {
this.cursor$.next(this.nextPage);
}
}
private fetchTours(
cursor: string,
filter: AccountingFilterArgs
): Observable<Record<string, Tour & { ticket: Ticket }>> {
const normalizedCursor = this.getNormalizedCursor(cursor);
return this.accountingService
.fetchToursByState(this.accountingState, cursor, filter, this.take)
.pipe(
tap(({ endCursor, hasNextPage }) => {
this.nextPage = endCursor;
this.hasNextPage = hasNextPage;
}),
map(({ nodes }) =>
nodes?.reduce(
(acc, cur) => ({
...acc,
[`${normalizedCursor}_${cur.id}`]: cur as Tour & {
ticket: Ticket;
},
}),
{} as Record<string, Tour & { ticket: Ticket }>
)
)
);
}
private getNormalizedCursor(cursor: string) {
return cursor === '' ? 'MA==' : cursor;
}
filterChanged(filter: AccountingFilterArgs) {
this.accountingFilter$.next(filter);
this.cursor$.next('');
}
} }
@@ -1,291 +1,105 @@
import { CurrencyPipe, DecimalPipe, NgClass } from '@angular/common'; import { CurrencyPipe, NgClass, NgStyle } from '@angular/common';
import { import { Component, effect, input, signal } from '@angular/core';
Component, import { MatDividerModule } from '@angular/material/divider';
computed,
effect,
inject,
input,
signal,
} from '@angular/core';
import { TourDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideAlertTriangle,
lucideCheckCircle2,
lucideReceipt,
} from '@ng-icons/lucide';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { tap } from 'rxjs'; import { tap } from 'rxjs';
import { import {
GetTourCostBreakdownGQL, GetTourCostBreakdownGQL,
GetTourCostBreakdownQuery, GetTourCostBreakdownQuery,
Tour,
} from '../../../core/data-access/graphql/generated/generated'; } from '../../../core/data-access/graphql/generated/generated';
@Component({ @Component({
selector: 'app-accounting-tour-cost-breakdown', selector: 'app-accounting-tour-cost-breakdown',
imports: [ imports: [MatDividerModule, CurrencyPipe, NgClass],
CurrencyPipe,
DecimalPipe,
NgClass,
HlmCardImports,
NgIcon,
HlmIcon,
],
providers: [
provideIcons({ lucideReceipt, lucideAlertTriangle, lucideCheckCircle2 }),
],
template: ` template: `
<section @if (costBreakdown() !== undefined) {
hlmCard <div class="px-4 py-2 flex justify-between">
class="m-4 border-border shadow-sm bg-card text-card-foreground overflow-hidden" <div class="flex flex-col text-gray-600 text-sm">
> <span class="font-medium">Tarif</span>
<div hlmCardHeader class="border-b bg-muted/20 px-6 py-4"> <span>
<div hlmCardTitle> {{ costBreakdown()?.tariffDescription }}
<span
class="text-xs font-medium text-muted-foreground uppercase tracking-wider block mb-1"
>
Angewandter Tarif
</span> </span>
</div>
<div class="flex flex-col text-gray-600 text-sm">
<span class="font-medium">Gültigkeitszeitraum</span>
<span>
{{ costBreakdown()?.tariffValidityPeriod }}
</span>
</div>
</div>
<mat-divider></mat-divider>
<div class="px-4 py-2 grid *:py-1">
<div <div
class="font-semibold text-foreground text-sm flex items-center gap-2" class="w-full justify-self-start"
style="background-color: var(--mat-sys-secondary-container)"
> >
<ng-icon
hlmIcon
name="lucideReceipt"
size="xs"
class="text-muted-foreground"
/>
{{ costBreakdown()?.tariffDescription || 'Lade Tarifdaten...' }}
</div>
</div>
<div hlmCardAction>
<span
class="text-xs font-medium text-muted-foreground uppercase tracking-wider block mb-1"
>
Gültigkeit
</span>
<div class="font-medium text-foreground text-sm">
{{ costBreakdown()?.tariffValidityPeriod || '—' }}
</div>
</div>
</div>
@if (costBreakdown()) {
<div hlmCardContent class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead
class="text-xs text-muted-foreground uppercase bg-gray-50/50 border-b border-border"
>
<tr>
<th scope="col" class="px-6 py-3 font-medium">
Beschreibung Beschreibung
</th>
<th scope="col" class="px-6 py-3 font-medium text-right">
Einzelwert
</th>
<th scope="col" class="px-6 py-3 font-medium text-right">
Kumulativ
</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@for (pos of calculatedRows(); track $index) {
<tr class="hover:bg-muted/50 transition-colors">
<td class="px-6 py-3 font-medium text-foreground">
{{ pos.item.description }}
</td>
<td
class="px-6 py-3 text-right font-mono text-muted-foreground"
>
{{
pos.item.value / 100
| currency: 'EUR' : 'symbol' : '.2' : 'de'
}}
</td>
<td
class="px-6 py-3 text-right font-mono font-medium text-foreground"
>
{{
pos.runningTotal / 100
| currency: 'EUR' : 'symbol' : '.2' : 'de'
}}
</td>
</tr>
}
</tbody>
</table>
</div> </div>
</div> <!-- <div
class="w-full justify-self-center"
<div class="border-t border-border p-4 bg-gray-50/30"> style="background-color: var(--mat-sys-secondary-container)"
@if (!hasDeviation()) { >
Typ
</div> -->
<div <div
class="flex items-center justify-between p-2 rounded-lg border border-green-200 bg-green-50 text-green-800" class="w-full justify-self-end"
style="background-color: var(--mat-sys-secondary-container)"
> >
<div class="flex items-center gap-3"> Wert
</div>
<div <div
class="bg-green-100 h-8 w-8 flex justify-center items-center rounded-full" class="w-full justify-self-end"
style="background-color: var(--mat-sys-secondary-container)"
> >
<ng-icon Summe
hlmIcon
name="lucideCheckCircle2"
class="text-green-600"
size="sm"
/>
</div> </div>
<div class="flex flex-col"> @for (servicePosition of costBreakdown()?.servicePositions; track $index;
<span class="text-sm font-bold">Umsatz abgeglichen</span> let o = $odd) {
<span class="text-xs opacity-80" <div class="w-full" [ngClass]="{ 'bg-gray-100': o }">
>Berechnung deckt sich mit Dispo-System</span {{ servicePosition.description }}
>
</div> </div>
</div> <!-- <div class="justify-self-center w-full break-all" [ngClass]="{ 'bg-gray-100': o }">
<span class="text-lg font-bold font-mono px-2"> {{ servicePosition.variant }}
</div> -->
<div class="justify-self-end w-full" [ngClass]="{ 'bg-gray-100': o }">
{{ {{
totalCalculated() | currency: 'EUR' : 'symbol' : '.2' : 'de' servicePosition.value / 100
| currency : 'EUR' : 'symbol' : '.2' : 'de'
}} }}
</span>
</div> </div>
} @else { <div class="justify-self-end w-full" [ngClass]="{ 'bg-gray-100': o }">
<div
class="flex flex-col gap-3 p-3 rounded-lg border border-amber-200 bg-amber-50 text-amber-900"
>
<div
class="flex items-center gap-2 pb-2 border-b border-amber-200/60"
>
<ng-icon
hlmIcon
name="lucideAlertTriangle"
class="text-amber-600"
size="sm"
/>
<span class="font-bold text-sm">Umsatz-Abweichung erkannt</span>
</div>
<div class="grid grid-cols-2! gap-4 text-sm">
<div class="flex flex-col">
<span
class="text-xs uppercase text-amber-700/70 font-semibold"
>System Berechnung</span
>
<span class="font-mono font-medium">
{{ {{
totalCalculated() sumServicePositionToIndex(costBreakdown()?.servicePositions, $index) /
| currency: 'EUR' : 'symbol' : '.2' : 'de' 100 | currency : 'EUR' : 'symbol' : '.2' : 'de'
}} }}
</span>
</div>
<div class="flex flex-col text-right">
<span
class="text-xs uppercase text-amber-700/70 font-semibold"
>Dispolive</span
>
<span class="font-mono font-medium">
{{
tour().revenueDispoLive ?? 0
| currency: 'EUR' : 'symbol' : '.2' : 'de'
}}
</span>
</div>
</div>
<div
class="pt-2 flex justify-between items-center bg-white/50 p-2 rounded border border-amber-100"
>
<span class="text-xs font-semibold text-amber-800"
>Differenz</span
>
<span class="font-mono font-bold text-red-600">
{{
getDeviation() | currency: 'EUR' : 'symbol' : '.2' : 'de'
}}
</span>
</div>
</div> </div>
} }
</div> </div>
} @else {
<div
class="p-8 text-center text-muted-foreground text-sm animate-pulse"
>
Berechne Positionen...
</div>
}
</section>
`,
styles: `
.grid {
grid-template-columns: 3fr 1fr 1fr;
} }
`, `,
styles: `.grid {grid-template-columns: 3fr 1fr 1fr;}`,
}) })
export class AccountingTourCostBreakdownComponent { export class AccountingTourCostBreakdownComponent {
private gql = inject(GetTourCostBreakdownGQL); tour = input.required<Tour>();
tour = input.required<TourDto>();
costBreakdown = signal< costBreakdown = signal<
GetTourCostBreakdownQuery['tourCostBreakdown'] | undefined GetTourCostBreakdownQuery['tourCostBreakdown'] | undefined
>(undefined); >(undefined);
constructor() { constructor(private readonly tourCostBreakdownGql: GetTourCostBreakdownGQL) {
// Data Fetching Effect effect(() => {
effect((onCleanup) => { tourCostBreakdownGql
const operationId = this.tour().operationId; .fetch({ operationId: this.tour().operationId })
if (!operationId) return; .pipe(tap((res) => this.costBreakdown.set(res.data.tourCostBreakdown)))
const sub = this.gql
.fetch({ variables: { operationId } })
.pipe(tap((res) => this.costBreakdown.set(res.data?.tourCostBreakdown)))
.subscribe(); .subscribe();
onCleanup(() => sub.unsubscribe());
}); });
} }
// --- Computed Logic für das Template --- sumServicePositionToIndex(
servicePositions: any[] | undefined,
/** * Berechnet die Zeilen inklusive Running Total vorab, idx: number
* damit wir keine Funktionen im Template aufrufen müssen. ): number {
*/ return (servicePositions ?? [])
calculatedRows = computed(() => { .slice(0, idx + 1)
const positions = this.costBreakdown()?.servicePositions || []; .reduce((sum, { value }) => (sum += value), 0);
let runningTotal = 0; }
return positions.map((item) => {
runningTotal += item.value;
return { item, runningTotal };
});
});
/** Gesamtsumme unserer Berechnung (letzter Wert des Running Totals) */
totalCalculated = computed(() => {
const rows = this.calculatedRows();
return rows.length > 0 ? rows[rows.length - 1].runningTotal / 100 : 0;
});
/** Prüfung auf Abweichung (Toleranz von 1 Cent wegen Rundung ist oft sinnvoll) */
hasDeviation = computed(() => {
const calculated = this.totalCalculated();
const external = this.tour().revenueDispoLive ?? 0; // Fallback auf 0 falls null
// Wenn revenueDispoLive null/undefined ist, behandeln wir es als 0 oder als "kein Vergleich möglich"?
// Hier Annahme: Wenn null, dann ignorieren wir den Vergleich (oder Error).
// Ich setze es mal so: Wenn external existiert UND abweicht -> Error.
if (
this.tour().revenueDispoLive === null ||
this.tour().revenueDispoLive === undefined
)
return false;
return Math.abs(calculated - external) > 1; // > 1 Cent Toleranz
});
/** Differenz Betrag */
getDeviation = computed(() => {
return this.totalCalculated() - (this.tour().revenueDispoLive ?? 0);
});
} }
@@ -1,377 +0,0 @@
import { DatePipe } from '@angular/common';
import { Component, computed, input } from '@angular/core';
import { OptimizationTourDto, TourDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideAlertTriangle,
lucideArrowRight,
lucideExternalLink,
} from '@ng-icons/lucide';
import { HlmButton } from '@spartan-ng/helm/button';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { useOptimizationCar } from 'src/app/core/data-access/accounting/accounting.queries';
import { simulateOptimization } from './optimization.utils';
@Component({
selector: 'app-accounting-tour-optimization',
imports: [DatePipe, NgIcon, HlmIcon, HlmButton],
providers: [
provideIcons({ lucideArrowRight, lucideAlertTriangle, lucideExternalLink }),
],
template: `@if (carQuery.isPending()) {
<div class="p-4 text-sm text-muted-foreground animate-pulse">
Berechne Simulation...
</div>
}
@if (vm(); as state) {
<div
class="flex flex-col gap-4 p-4 rounded-lg m-2"
>
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span
class="text-xs font-bold text-muted-foreground uppercase tracking-wider"
>
Abrechnungsoptimierung
</span>
<div class="flex items-baseline gap-2">
<div class="flex justify-center items-center gap-1">
<span class="text-sm font-mono font-medium">
#{{ state.original.operationId }}
</span>
<a
[href]="
'https://avicenna.dispolive.de/fahrbericht/' +
state.original.id
"
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center justify-center rounded-sm text-gray-800/60 transition-colors hover:text-gray-900 hover:bg-gray-100/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Fahrbericht im externen System öffnen"
aria-label="Fahrbericht öffnen"
>
<ng-icon name="lucideExternalLink" class="size-3" />
</a>
</div>
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border"
[class.bg-green-500_10]="
state.original.optimizationInMinutes > 0
"
[class.text-green-600]="
state.original.optimizationInMinutes > 0
"
[class.border-green-200]="
state.original.optimizationInMinutes > 0
"
[class.bg-red-500_10]="state.original.optimizationInMinutes < 0"
[class.text-red-600]="state.original.optimizationInMinutes < 0"
[class.border-red-200]="
state.original.optimizationInMinutes < 0
"
>
{{ state.original.optimizationInMinutes > 0 ? '+' : ''
}}{{ state.original.optimizationInMinutes }} Min
</span>
</div>
</div>
</div>
<div class="grid grid-cols-5 gap-2 text-center text-xs">
<div
class="contents px-2 text-[10px] text-muted-foreground uppercase mb-1"
>
<div>Anfahrt</div>
<div>Ankunft Abholort</div>
<div>Abfahrt</div>
<div>Ankunft Zielort</div>
<div>Frei</div>
</div>
<div class="contents text-muted-foreground/70">
<div>{{ state.original.departedForPickup | date: 'HH:mm' }}</div>
<div>{{ state.original.arrivedAtPickup | date: 'HH:mm' }}</div>
<div>{{ state.original.departedPickup | date: 'HH:mm' }}</div>
<div>{{ state.original.arrivedAtTarget | date: 'HH:mm' }}</div>
<div>{{ state.original.completed | date: 'HH:mm' }}</div>
</div>
<div class="contents font-bold text-foreground">
<div
[class.text-amber-600]="
isChanged(
state.original.departedForPickup,
state.simulated.departedForPickup
)
"
>
{{ state.simulated.departedForPickup | date: 'HH:mm' }}
</div>
<div
[class.text-amber-600]="
isChanged(
state.original.arrivedAtPickup,
state.simulated.arrivedAtPickup
)
"
>
{{ state.simulated.arrivedAtPickup | date: 'HH:mm' }}
</div>
<div
[class.text-amber-600]="
isChanged(
state.original.departedPickup,
state.simulated.departedPickup
)
"
>
{{ state.simulated.departedPickup | date: 'HH:mm' }}
</div>
<div
[class.text-amber-600]="
isChanged(
state.original.arrivedAtTarget,
state.simulated.arrivedAtTarget
)
"
>
{{ state.simulated.arrivedAtTarget | date: 'HH:mm' }}
</div>
<div
[class.text-amber-600]="
isChanged(state.original.completed, state.simulated.completed)
"
>
{{ state.simulated.completed | date: 'HH:mm' }}
</div>
</div>
</div>
@if (state.conflicts.length > 0) {
<div
class="mt-4 flex flex-col gap-3 rounded-lg bg-red-50/50 border border-red-200 p-3"
>
<div class="flex items-center gap-2 text-red-700 mb-1">
<ng-icon
hlmIcon
name="lucideAlertTriangle"
size="sm"
class="stroke-2"
/>
<span class="text-xs font-bold uppercase tracking-wider">
{{ state.conflicts.length }} Zeitkonflikt(e) erkannt
</span>
</div>
@for (conflict of state.conflicts; track conflict.id) {
<div
class="flex flex-col gap-2 rounded border border-red-100 bg-white/60 p-2 shadow-sm"
>
<div
class="flex justify-between items-baseline border-b border-red-100 pb-1 mb-1"
>
<div class="flex items-center justify-center gap-1">
<span class="font-mono text-xs font-bold text-red-900">
#{{ conflict.operationId }}
</span>
<a
[href]="
'https://avicenna.dispolive.de/fahrbericht/' +
conflict.id
"
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center justify-center rounded-sm text-red-800/60 transition-colors hover:text-red-900 hover:bg-red-100/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Fahrbericht im externen System öffnen"
aria-label="Fahrbericht öffnen"
>
<ng-icon name="lucideExternalLink" class="size-3" />
</a>
</div>
<span class="text-xs text-red-900">{{
conflict.startDate | date: 'HH:mm - dd.MM.yy'
}}</span>
<span class="text-[10px] uppercase text-red-400 font-medium">
Kollidierende Tour
</span>
</div>
<div class="grid grid-cols-5 gap-2 text-center text-xs">
<div
class="contents px-2 text-[10px] text-muted-foreground uppercase mb-1"
>
<div>Anfahrt</div>
<div>Ankunft Abholort</div>
<div>Abfahrt</div>
<div>Ankunft Zielort</div>
<div>Frei</div>
</div>
<div
[class.text-red-600]="
isTimeInConflict(
conflict.departedForPickup,
state.simulated
)
"
[class.font-extrabold]="
isTimeInConflict(
conflict.departedForPickup,
state.simulated
)
"
[class.bg-red-100]="
isTimeInConflict(
conflict.departedForPickup,
state.simulated
)
"
class="rounded py-0.5 transition-colors"
>
{{ conflict.departedForPickup | date: 'HH:mm' }}
</div>
<div
[class.text-red-600]="
isTimeInConflict(
conflict.arrivedAtPickup,
state.simulated
)
"
[class.font-extrabold]="
isTimeInConflict(
conflict.arrivedAtPickup,
state.simulated
)
"
[class.bg-red-100]="
isTimeInConflict(
conflict.arrivedAtPickup,
state.simulated
)
"
class="rounded py-0.5 transition-colors"
>
{{ conflict.arrivedAtPickup | date: 'HH:mm' }}
</div>
<div
[class.text-red-600]="
isTimeInConflict(conflict.departedPickup, state.simulated)
"
[class.font-extrabold]="
isTimeInConflict(conflict.departedPickup, state.simulated)
"
[class.bg-red-100]="
isTimeInConflict(conflict.departedPickup, state.simulated)
"
class="rounded py-0.5 transition-colors"
>
{{ conflict.departedPickup | date: 'HH:mm' }}
</div>
<div
[class.text-red-600]="
isTimeInConflict(
conflict.arrivedAtTarget,
state.simulated
)
"
[class.font-extrabold]="
isTimeInConflict(
conflict.arrivedAtTarget,
state.simulated
)
"
[class.bg-red-100]="
isTimeInConflict(
conflict.arrivedAtTarget,
state.simulated
)
"
class="rounded py-0.5 transition-colors"
>
{{ conflict.arrivedAtTarget | date: 'HH:mm' }}
</div>
<div
[class.text-red-600]="
isTimeInConflict(conflict.completed, state.simulated)
"
[class.font-extrabold]="
isTimeInConflict(conflict.completed, state.simulated)
"
[class.bg-red-100]="
isTimeInConflict(conflict.completed, state.simulated)
"
class="rounded py-0.5 transition-colors"
>
{{ conflict.completed | date: 'HH:mm' }}
</div>
</div>
</div>
}
</div>
}
</div>
}`,
})
export class AccountingTourOptimizationComponent {
tour = input.required<TourDto>();
// 1. Inputs ableiten
// Hinweis: Checke, ob dein TourDto 'carName' hat. Falls nicht, müssen wir es anders übergeben.
// Ich nehme hier an, es ist da, wie vorhin besprochen.
carName = computed(() => this.tour().carName ?? 'UNKNOWN_CAR');
tourDate = computed(() => this.tour().startDate);
private queryCarName = computed(() => this.carName().split('1/').at(1));
// 2. Data Fetching
carQuery = useOptimizationCar(this.queryCarName, this.tourDate);
// 3. View Model Berechnung
vm = computed(() => {
const allTours = this.carQuery.data()?.tours;
const currentTourId = this.tour().id;
console.log(this.tour());
if (!allTours) return null;
// Finde die volle Tour im geladenen Set (da das Input-DTO evtl. unvollständig ist)
// oder nutze das Input-DTO und caste es auf OptimizationTourDto
const focusTour = allTours.find((t) => t.id === currentTourId);
if (!focusTour) return null;
// Simulation starten
return simulateOptimization(focusTour, allTours);
});
isTimeInConflict(
timeStr: string | null,
simulatedTour: OptimizationTourDto,
): boolean {
if (!timeStr) return false;
const time = new Date(timeStr).getTime();
// Start der Simulation
const simStart = new Date(simulatedTour.departedForPickup!).getTime();
// Ende der Simulation (Fallback falls completed fehlt, wie in der Utils-Logik)
const simEnd = simulatedTour.completed
? new Date(simulatedTour.completed).getTime()
: simStart + 30 * 60 * 1000;
// Prüfung: Ist der Zeitstempel >= Start UND <= Ende der Simulation?
// Wir nutzen >= und <= für "harte" Kollisionen
return time >= simStart && time <= simEnd;
}
// Helper für das Template
isChanged(orig: string | null, sim: string | null): boolean {
if (!orig || !sim) return false;
return new Date(orig).getTime() !== new Date(sim).getTime();
}
}
@@ -1,307 +1,87 @@
import { DatePipe, DecimalPipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { TourDto } from '@avicenna/shared-dtos'; import { MatDividerModule } from '@angular/material/divider';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { MatIconModule } from '@angular/material/icon';
import { import { MatTabsModule } from '@angular/material/tabs';
lucideAmbulance, import { Tour } from '../../../core/data-access/graphql/generated/generated';
lucideCalendar,
lucideCheckCircle2,
lucideClock,
lucideMapPin,
lucideUser,
lucideUsers,
} from '@ng-icons/lucide';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { format } from 'date-fns';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-overview', selector: 'dks-accounting-tour-overview',
template: ` template: `
<div class="p-4 min-h-125 space-y-6"> <div class="px-4 py-2 flex justify-between">
<div class="flex justify-between items-start"> <div class="flex flex-col text-gray-600 text-sm">
<div> <span class="font-medium">Startzeit</span>
<h2
class="text-xl font-bold tracking-tight text-foreground flex items-center gap-2"
>
#{{ tour().operationId }}
@if (tour().done) {
<ng-icon
hlmIcon
name="lucideCheckCircle2"
class="text-green-600"
size="sm"
/>
}
</h2>
<div
class="flex items-center gap-3 mt-1 text-sm text-muted-foreground"
>
<span class="flex items-center gap-1">
<ng-icon hlmIcon name="lucideCalendar" size="xs" />
{{ tour().startDate | date: 'dd.MM.yyyy' }}
</span>
<span class="w-1 h-1 bg-border rounded-full"></span>
<span>{{ tour().category || 'Standardfahrt' }}</span>
</div>
</div>
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
[class.bg-green-100]="tour().done"
[class.text-green-800]="tour().done"
[class.border-green-200]="tour().done"
[class.bg-gray-100]="!tour().done"
[class.text-gray-800]="!tour().done"
[class.border-gray-200]="!tour().done"
>
{{ tour().done ? 'ABGESCHLOSSEN' : 'OFFEN' }}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
<div class="md:col-span-5 space-y-6">
<section
hlmCard
class="border-border shadow-sm bg-card text-card-foreground"
>
<div
hlmCardHeader
class="border-b bg-muted/20 flex flex-row justify-between items-center space-y-0"
>
<h3 hlmCardTitle class="text-sm font-semibold">Einsatzmittel</h3>
</div>
<div hlmCardContent>
<dl class="space-y-4">
@if (tour().carName) {
<div>
<dt
class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1"
>
Fahrzeug
</dt>
<dd
class="text-sm font-medium text-foreground flex items-center gap-2"
>
<span
class="p-1.5 bg-blue-50 text-blue-600 rounded-md flex items-center justify-center"
>
<ng-icon hlmIcon name="lucideAmbulance" size="sm" />
</span>
{{ tour().carName }}
</dd>
</div>
}
<div class="grid grid-cols-2 gap-4">
@if (tour().driverName) {
<div>
<dt
class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1"
>
Fahrer
</dt>
<dd
class="text-sm text-foreground flex items-center gap-2"
>
<ng-icon
hlmIcon
name="lucideUser"
class="text-muted-foreground"
size="xs"
/>
{{ tour().driverName }}
</dd>
</div>
}
@if (tour().codriverName) {
<div>
<dt
class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1"
>
Beifahrer
</dt>
<dd
class="text-sm text-foreground flex items-center gap-2"
>
<ng-icon
hlmIcon
name="lucideUsers"
class="text-muted-foreground"
size="xs"
/>
{{ tour().codriverName }}
</dd>
</div>
}
</div>
</dl>
</div>
</section>
@if (tour().patientName) {
<section
hlmCard
class="border-border shadow-sm bg-card text-card-foreground"
>
<div
hlmCardHeader
class="border-b bg-muted/20 flex flex-row justify-between items-center space-y-0"
>
<h3 hlmCardTitle class="text-sm font-semibold">Patient</h3>
</div>
<div hlmCardContent>
<div class="flex items-start gap-3">
<div
class="h-10 w-10 rounded-full bg-indigo-50 text-indigo-600 flex items-center justify-center font-bold text-sm border border-indigo-100 shrink-0"
>
{{ getInitials(tour().patientName, tour().patientSurname) }}
</div>
<div>
<p class="font-medium text-foreground">
{{ tour().patientName }} {{ tour().patientSurname }}
</p>
<p class="text-sm text-muted-foreground mt-0.5">
{{ tour().patientStreet }}<br />
{{ tour().patientZip }} {{ tour().patientCity }}
</p>
@if (tour().healthInsurance) {
<div
class="mt-3 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-muted text-muted-foreground"
>
{{ tour().healthInsurance }}
</div>
}
</div>
</div>
</div>
</section>
}
</div>
<div class="md:col-span-7">
<section
hlmCard
class="border-border shadow-sm bg-card text-card-foreground h-full flex flex-col"
>
<div
hlmCardHeader
class="border-b bg-muted/20 flex flex-row justify-between items-center space-y-0"
>
<h3 hlmCardTitle class="text-sm font-semibold">
Verlauf & Route
</h3>
@if (tour().totalKm) {
<span class="text-xs text-muted-foreground font-mono">
{{ tour().totalKm | number: '.1-3' : 'de' }} km
</span>
}
</div>
<div hlmCardContent class="flex-1 flex flex-col px-6 relative">
<div class="relative pl-8 pb-10 group z-10">
<div
class="absolute left-[11px] top-4 -bottom-4 w-0.5 bg-border -z-10"
></div>
<div
class="absolute left-0 top-1 h-6 w-6 rounded-full border-2 border-background bg-background shadow-sm flex items-center justify-center"
>
<div class="h-2.5 w-2.5 rounded-full bg-green-500"></div>
</div>
<div class="flex flex-col">
<span
class="text-xs text-muted-foreground mb-0.5 flex items-center gap-1"
>
<ng-icon hlmIcon name="lucideClock" size="xs" />
Start • {{ tour().startDate | date: 'HH:mm' }} Uhr
</span>
<h4 class="text-base font-semibold text-foreground">
{{ tour().startInstitution || 'Startort' }}
</h4>
<p class="text-sm text-muted-foreground mt-0.5">
{{ tour().startStreet }}, {{ tour().startZip }}
{{ tour().startCity }}
</p>
</div>
</div>
<div class="relative pl-8 group z-10">
<div
class="absolute left-0 top-1 h-6 w-6 rounded-full border-2 border-background bg-background shadow-sm flex items-center justify-center"
>
<div class="h-2.5 w-2.5 rounded-full bg-red-500"></div>
</div>
<div class="flex flex-col">
<span
class="text-xs text-muted-foreground mb-0.5 flex items-center gap-1"
>
<ng-icon hlmIcon name="lucideMapPin" size="xs" />
Ziel •
{{ getEndTime(tour()) }} Uhr
</span>
<h4 class="text-base font-semibold text-foreground">
{{ tour().targetInstitution || 'Zielort' }}
</h4>
<p class="text-sm text-muted-foreground mt-0.5">
{{ tour().targetStreet }}, {{ tour().targetZip }}
{{ tour().targetCity }}
</p>
</div>
</div>
</div>
@if (tour().createdAt) {
<div
hlmCardFooter
class="mt-auto border-t text-xs text-muted-foreground flex justify-end gap-4 relative z-10"
>
<span> <span>
Erstellt: {{ tour().createdAt | date: 'dd.MM.yyyy HH:mm' }} {{ tour.startDate | date : 'dd.MM.yyyy' }}
{{ tour.startDate | date : 'HH:mm' }} Uhr
</span> </span>
</div> </div>
<div class="flex flex-col text-gray-600 text-sm">
@if (tour.createdAt) {
<span class="font-medium">Erstellt am</span>
}
@if (tour.createdAt) {
<span>
{{ tour.createdAt | date : 'dd.MM.yyyy' }}
{{ tour.createdAt | date : 'HH:mm' }} Uhr
</span>
} }
</section>
</div> </div>
</div> </div>
<mat-divider></mat-divider>
@if (tour.driverName || tour.codriverName || tour.carName) {
<div
class="flex flex-wrap md:flex-nowrap justify-between px-4 py-2"
>
@if (tour.carName) {
<div
class="flex flex-col pb-1 md:pb-0"
>
<span class="font-semibold pb-1">Fahrzeug</span>
{{ tour.carName }}
</div>
}
@if (tour.driverName) {
<div
class="flex flex-col pb-1 md:pb-0"
>
<span class="font-semibold pb-1">Fahrer</span>
<span>{{ tour.driverName }}</span>
</div>
}
@if (tour.codriverName) {
<div class="flex flex-col">
<span class="font-semibold pb-1">Beifahrer</span>
<span>{{ tour.codriverName }}</span>
</div>
}
</div>
}
<div class="flex flex-col px-4 py-2">
<span class="font-semibold pb-1">Patient</span>
<span>{{ tour.patientName }} {{ tour.patientSurname }}</span>
<span>{{ tour.patientStreet }}</span>
<span>{{ tour.patientZip }} {{ tour.patientCity }}</span>
</div>
<div class="flex flex-wrap md:flex-nowrap px-4 py-2">
<div class="w-full md:w-1/2 flex flex-col pb-1 md:pb-0">
<span class="font-semibold pb-1">Start</span>
<span>{{ tour.startInstitution }}</span>
<span>{{ tour.startStreet }}</span>
<span>{{ tour.startZip }} {{ tour.startCity }}</span>
</div>
<div class="w-full md:w-1/2 flex flex-col">
<span class="font-semibold pb-1">Ziel</span>
<span>{{ tour.targetInstitution }}</span>
<span>{{ tour.targetStreet }}</span>
<span>{{ tour.targetZip }} {{ tour.targetCity }}</span>
</div>
</div> </div>
`, `,
styles: [], styles: [],
imports: [DatePipe, DecimalPipe, HlmCardImports, NgIcon, HlmIcon], imports: [MatTabsModule, MatIconModule, MatDividerModule, DatePipe]
providers: [
provideIcons({
lucideAmbulance,
lucideUser,
lucideUsers,
lucideClock,
lucideCalendar,
lucideCheckCircle2,
lucideMapPin,
}),
],
}) })
export class AccountingTourOverviewComponent { export class AccountingTourOverviewComponent {
tour = input.required<TourDto>(); @Input() tour!: Tour;
getInitials(name: string | null, surname: string | null): string {
const n = name?.charAt(0) || '';
const s = surname?.charAt(0) || '';
return (n + s).toUpperCase();
}
getShortId(id: string): string {
return id ? id.substring(0, 8) : '';
}
getStartTime(tour: TourDto): string {
return tour.startEnd
? tour.startEnd
: format(new Date(tour.startDate), 'HH:MM');
}
getEndTime(tour: TourDto): string {
return tour.targetBegin ? tour.targetBegin : '--:--';
}
} }
@@ -1,9 +1,9 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider'; import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list'; import { MatListModule } from '@angular/material/list';
import { TourDto } from '@avicenna/shared-dtos'; import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/graphql/generated/generated';
import { TicketValidationState } from '../../../core/data-access/graphql/generated/generated';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -16,19 +16,34 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
Status: Status:
@switch (tour.ticket.currentState) { @switch (tour.ticket.currentState) {
@case (TicketValidationState.Created) { @case (TicketValidationState.Created) {
<span class="font-bold">Touren angenommen </span> <span
class="font-bold"
>Touren angenommen
</span>
} }
@case (TicketValidationState.UserInteractionRequired) { @case (TicketValidationState.UserInteractionRequired) {
<span class="font-bold">Genehmigung / Transportschein </span> <span
class="font-bold"
>Genehmigung / Transportschein
</span>
} }
@case (TicketValidationState.TdUncertain) { @case (TicketValidationState.TdUncertain) {
<span class="font-bold">Transportschein unklar </span> <span
class="font-bold"
>Transportschein unklar
</span>
} }
@case (TicketValidationState.DocumentsMissing) { @case (TicketValidationState.DocumentsMissing) {
<span class="font-bold">Dokumente fehlen </span> <span
class="font-bold"
>Dokumente fehlen
</span>
} }
@case (TicketValidationState.Archived) { @case (TicketValidationState.Archived) {
<span class="font-bold">Archiviert </span> <span
class="font-bold"
>Archiviert
</span>
} }
} }
</mat-list-item> </mat-list-item>
@@ -46,11 +61,17 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
@if (tour.ticket.errors.length > 0 || tour.ticket.notes.length > 0) { @if (tour.ticket.errors.length > 0 || tour.ticket.notes.length > 0) {
<mat-list class="col-span-2 max-h-96 overflow-y-auto" role="list"> <mat-list
class="col-span-2 max-h-96 overflow-y-auto"
role="list"
>
<div mat-subheader class="text-sm">Ticket Anmerkungen</div> <div mat-subheader class="text-sm">Ticket Anmerkungen</div>
@for (error of tour.ticket.errors; track error) { @for (error of tour.ticket.errors; track error) {
<mat-list-item lines="2" role="listitem"> <mat-list-item
<mat-icon matListItemIcon class="text-red-600!">error</mat-icon> lines="2"
role="listitem"
>
<mat-icon matListItemIcon class="!text-red-600">error</mat-icon>
<span matListItemTitle>{{ error.split(':').at(0) }}</span> <span matListItemTitle>{{ error.split(':').at(0) }}</span>
<span [title]="error.split(':').at(1)">{{ <span [title]="error.split(':').at(1)">{{
error.split(':').at(1) error.split(':').at(1)
@@ -58,10 +79,11 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
</mat-list-item> </mat-list-item>
} }
@for (note of tour.ticket.notes; track note) { @for (note of tour.ticket.notes; track note) {
<mat-list-item lines="2" role="listitem"> <mat-list-item
<mat-icon matListItemIcon class="text-yellow-400!" lines="2"
>warning</mat-icon role="listitem"
> >
<mat-icon matListItemIcon class="!text-yellow-400">warning</mat-icon>
<span matListItemTitle>{{ note.split(':').at(0) }}</span> <span matListItemTitle>{{ note.split(':').at(0) }}</span>
<span [title]="note.split(':').at(1)">{{ <span [title]="note.split(':').at(1)">{{
note.split(':').at(1) note.split(':').at(1)
@@ -73,10 +95,14 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
</div> </div>
`, `,
styles: [], styles: [],
imports: [MatIconModule, MatListModule, MatDividerModule], imports: [
MatIconModule,
MatListModule,
MatDividerModule
]
}) })
export class AccountingTourTicketComponent { export class AccountingTourTicketComponent {
@Input() tour!: TourDto; @Input() tour!: Tour & { ticket: Ticket };
TicketValidationState = TicketValidationState; TicketValidationState = TicketValidationState;
} }
@@ -1,65 +1,51 @@
import { import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { TourDto } from '@avicenna/shared-dtos';
import { DialogRef } from '@ngneat/dialog'; import { DialogRef } from '@ngneat/dialog';
import { AccountingTourCostBreakdownComponent } from './accounting-tour-cost-breakdown.component'; import {
Ticket,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
import { AccountingTourOverviewComponent } from './accounting-tour-overview.component'; import { AccountingTourOverviewComponent } from './accounting-tour-overview.component';
import { AccountingTourTicketComponent } from './accounting-tour-ticket.component'; import { AccountingTourTicketComponent } from './accounting-tour-ticket.component';
import { useTourDetail } from 'src/app/core/data-access/accounting/accounting.queries'; import { AccountingTourCostBreakdownComponent } from './accounting-tour-cost-breakdown.component';
import { AccountingTourOptimizationComponent } from "./accounting-tour-optimization.component";
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-view', selector: 'dks-accounting-tour-view',
template: ` @if (tourQuery.data(); as tour) { template: `<h3 class="py-2 px-3 !mb-0">
<h3 class="py-2 px-3 mb-0!"> {{ ref.data.ordinanceType }} -
{{ tour.ordinanceType }} - {{ ref.data.patientName }}
{{ tour.patientName }} {{ ref.data.patientSurname }}
{{ tour.patientSurname }}
</h3> </h3>
<hr /> <hr />
<mat-tab-group class="overflow-auto"> <mat-tab-group>
<mat-tab label="Übersicht">
<dks-accounting-tour-overview
[tour]="tour"
></dks-accounting-tour-overview>
</mat-tab>
<mat-tab label="Umsatzberechnung"> <mat-tab label="Umsatzberechnung">
<app-accounting-tour-cost-breakdown <app-accounting-tour-cost-breakdown
[tour]="tour" [tour]="ref.data"
></app-accounting-tour-cost-breakdown> ></app-accounting-tour-cost-breakdown>
</mat-tab> </mat-tab>
@if(tourQuery.data()?.optimizationInMinutes != 0) { <mat-tab label="Übersicht">
<mat-tab label="Abrechnungsoptimierung"> <dks-accounting-tour-overview
<app-accounting-tour-optimization [tour]="ref.data"
[tour]="tour" ></dks-accounting-tour-overview>
></app-accounting-tour-optimization>
</mat-tab> </mat-tab>
} <mat-tab label="Ticket">
<!-- <mat-tab label="Ticket">
<dks-accounting-tour-ticket <dks-accounting-tour-ticket
[tour]="ref.data" [tour]="ref.data"
></dks-accounting-tour-ticket> ></dks-accounting-tour-ticket>
</mat-tab> --> </mat-tab>
</mat-tab-group> </mat-tab-group> `,
}`,
styles: [], styles: [],
imports: [ imports: [
MatTabsModule, MatTabsModule,
MatIconModule, MatIconModule,
AccountingTourOverviewComponent, AccountingTourOverviewComponent,
AccountingTourCostBreakdownComponent, AccountingTourTicketComponent,
AccountingTourOptimizationComponent AccountingTourCostBreakdownComponent
], ],
}) })
export class AccountingTourViewComponent { export class AccountingTourViewComponent {
ref = inject<DialogRef<string, undefined | any>>(DialogRef); ref =
tourId = signal(this.ref.data); inject<DialogRef<Tour & { ticket: Ticket }, undefined | any>>(DialogRef);
tourQuery = useTourDetail(this.tourId);
} }
@@ -1,122 +0,0 @@
import { addMinutes, subMinutes, isBefore, isAfter, parseISO } from 'date-fns';
import { OptimizationTourDto } from '@avicenna/shared-dtos';
export interface SimulatedTourResult {
original: OptimizationTourDto;
simulated: OptimizationTourDto;
conflicts: OptimizationTourDto[];
}
const MIN_DISTANCE = 2 * 60 * 1000;
/**
* Simuliert die Verschiebung einer Tour und prüft auf Kollisionen.
*/
export function simulateOptimization(
focusTour: OptimizationTourDto,
allTours: OptimizationTourDto[],
): SimulatedTourResult {
const sim = structuredClone(focusTour);
const getTime = (isoStr: string | null) =>
isoStr ? new Date(isoStr).getTime() : null;
let currentStart = getTime(sim.arrivedAtPickup) ?? getTime(sim.startDate)!;
// Die Verschiebung anwenden
currentStart += sim.optimizationInMinutes * 60 * 1000;
// Datum zurückschreiben
sim.arrivedAtPickup = new Date(currentStart).toISOString();
if (sim.optimizationInMinutes < 0) {
const prevTime = getTime(sim.departedForPickup);
const currTime = getTime(sim.arrivedAtPickup);
if (prevTime && currTime && prevTime >= currTime) {
sim.departedForPickup = new Date(currTime - MIN_DISTANCE).toISOString();
}
}
if (sim.optimizationInMinutes > 0) {
const timestamps = [
// 'departedForPickup',
'arrivedAtPickup',
'departedPickup',
'arrivedAtTarget',
'completed',
] as const;
// const adjustTime = (nextTime: number, currTime: number) =>
// nextTime <= currTime ? currTime + MIN_DISTANCE : nextTime;
// const optimizedTime = getTime(sim.arrivedAtPickup);
for (let i = 1; i < timestamps.length; i++) {
const prevKey = timestamps[i - 1];
const currKey = timestamps[i];
const prevTime = getTime(sim[prevKey]);
const currTime = getTime(sim[currKey]);
if (prevTime && currTime && currTime - prevTime < MIN_DISTANCE) {
sim[currKey] = new Date(prevTime + MIN_DISTANCE).toISOString();
} else {
break;
}
}
}
// 3. Interne Konsistenz herstellen (Domino-Effekt)
// Wir definieren die Kette der Zeitstempel
// 3a. Forward Propagation (Nachfolgende schieben)
// Wir starten beim zweiten Element und vergleichen mit dem Vorgänger
// let wasPrevAdjusted = false;
// for (let i = 1; i < timestamps.length; i++) {
// const prevKey = timestamps[i - 1];
// const currKey = timestamps[i];
// const prevTime = getTime(sim[prevKey]);
// let currTime = getTime(sim[currKey]);
// if (prevTime && currTime) {
// // Regel: curr muss mindestens prev + 2 Min sein
// const minAllowed = prevTime + 2 * 60 * 1000;
// if (currTime < minAllowed) {
// // Konflikt intern: Schieben!
// currTime = minAllowed;
// sim[currKey] = new Date(currTime).toISOString();
// wasPrevAdjusted = true;
// } else {
// wasPrevAdjusted = false;
// }
// }
// }
// 3b. Backward Propagation (Theoretisch für Zeiten VOR dem Start, falls vorhanden)
// Da 'departedForPickup' unser Start ist, gibt es davor meist nichts relevantes im DTO für die Kollision,
// aber falls du z.B. 'startDate' als Plan-Zeit auch anpassen willst:
// (Hier lassen wir es erstmal weg, da der Algorithmus meist vorwärts fließt).
// 4. Kollisionsprüfung (Extern)
// Zeitraum der simulierten Tour bestimmen
const simStart = getTime(sim.arrivedAtPickup)!;
const simEnd = getTime(sim.completed) ?? simStart + 30 * 60 * 1000; // Fallback falls completed fehlt
const conflicts = allTours.filter((other) => {
// Sich selbst nicht prüfen
if (other.id === focusTour.id) return false;
const otherStart =
getTime(other.arrivedAtPickup) ?? getTime(other.startDate)!;
const otherEnd = getTime(other.completed) ?? otherStart + 1 * 60 * 1000; // Fallback
// Overlap Logik: (StartA < EndB) && (EndA > StartB)
return simStart < otherEnd && simEnd > otherStart;
}).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return {
original: focusTour,
simulated: sim,
conflicts: conflicts,
};
}
@@ -22,10 +22,11 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
class="self-center" class="self-center"
[accountingState]="AccountingState.PreparedForBilling" [accountingState]="AccountingState.PreparedForBilling"
></app-accounting-info-card> ></app-accounting-info-card>
<div>
<dks-accounting-lane <dks-accounting-lane
[accountingState]="AccountingState.Pending" [accountingState]="AccountingState.Pending"
></dks-accounting-lane> ></dks-accounting-lane>
</div>
<dks-accounting-lane <dks-accounting-lane
[accountingState]="AccountingState.Billable" [accountingState]="AccountingState.Billable"
></dks-accounting-lane> ></dks-accounting-lane>
@@ -40,8 +41,8 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
height: calc(100% - 64px); height: calc(100% - 64px);
} }
section { section {
/* grid-template-rows: 2rem auto 1fr; */ grid-template-rows: 2rem auto 1fr;
grid-template-rows: 2rem 1fr; /* grid-template-rows: 2rem 1fr; */
} }
`, `,
], ],
@@ -34,7 +34,7 @@ export class DashboardAccountingService {
} }
}), }),
shareReplay(1), shareReplay(1),
startWith(undefined), startWith(undefined)
); );
updateTours$ = new Subject<void>(); updateTours$ = new Subject<void>();
@@ -42,7 +42,7 @@ export class DashboardAccountingService {
constructor( constructor(
private readonly getAccountingToursGql: GetAccountingToursGQL, private readonly getAccountingToursGql: GetAccountingToursGQL,
private readonly getAccountingStateMetaGql: GetAccountingStateMetaGQL, private readonly getAccountingStateMetaGql: GetAccountingStateMetaGQL,
private readonly toast: HotToastService, private readonly toast: HotToastService
) { ) {
this.updateTours$.next(); this.updateTours$.next();
} }
@@ -51,10 +51,13 @@ export class DashboardAccountingService {
state: AccountingState, state: AccountingState,
cursor: string, cursor: string,
filters: AccountingFilterArgs, filters: AccountingFilterArgs,
take = 20, take = 20
): Observable<GetAccountingToursQuery['accountingTours']> { ): Observable<GetAccountingToursQuery['accountingTours']> {
const ticketsQuery = this.getAccountingToursGql.watch({ const ticketsQuery = this.getAccountingToursGql.watch({
variables: { state, cursor, filters, take }, state,
cursor,
filters,
take,
}); });
const ticketsUpdate = combineLatest([ const ticketsUpdate = combineLatest([
@@ -62,7 +65,7 @@ export class DashboardAccountingService {
this.updateTours$, this.updateTours$,
]).pipe( ]).pipe(
switchMap(() => ticketsQuery.refetch()), switchMap(() => ticketsQuery.refetch()),
startWith(undefined), startWith(undefined)
); );
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe( return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
@@ -72,21 +75,17 @@ export class DashboardAccountingService {
}), }),
retry({ delay: 1000, count: 3 }), retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData, updatedData ? updatedData : firstData
),
map(
(response) =>
response.data
?.accountingTours as GetAccountingToursQuery['accountingTours'],
), ),
map((response) => response.data.accountingTours)
); );
} }
fetchAccountingStateMeta( fetchAccountingStateMeta(
state: AccountingState, state: AccountingState
): Observable<GetAccountingStateMetaQuery['accountingStateMeta']> { ): Observable<GetAccountingStateMetaQuery['accountingStateMeta']> {
const ticketStateMetaQuery = this.getAccountingStateMetaGql.watch({ const ticketStateMetaQuery = this.getAccountingStateMetaGql.watch({
variables: { state }, state,
}); });
const toursUpdate = combineLatest([ const toursUpdate = combineLatest([
@@ -94,7 +93,7 @@ export class DashboardAccountingService {
this.updateTours$, this.updateTours$,
]).pipe( ]).pipe(
switchMap(() => ticketStateMetaQuery.refetch()), switchMap(() => ticketStateMetaQuery.refetch()),
startWith(undefined), startWith(undefined)
); );
return combineLatest([toursUpdate, ticketStateMetaQuery.valueChanges]).pipe( return combineLatest([toursUpdate, ticketStateMetaQuery.valueChanges]).pipe(
@@ -104,13 +103,9 @@ export class DashboardAccountingService {
}), }),
retry({ delay: 1000, count: 3 }), retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData, updatedData ? updatedData : firstData
),
map(
(response) =>
response.data
?.accountingStateMeta as GetAccountingStateMetaQuery['accountingStateMeta'],
), ),
map((response) => response.data.accountingStateMeta)
); );
} }
} }
@@ -2,13 +2,13 @@ import { EventEmitter, Injectable } from '@angular/core';
import { HotToastService } from '@ngxpert/hot-toast'; import { HotToastService } from '@ngxpert/hot-toast';
import { catchError, delay, EMPTY, map, Observable, take, tap } from 'rxjs'; import { catchError, delay, EMPTY, map, Observable, take, tap } from 'rxjs';
import { import {
AcceptAnomalyGQL,
GetAnomaliesGQL, GetAnomaliesGQL,
GetGroupedAnomaliesGQL, GetGroupedAnomaliesGQL,
SetAnomalyToSleepGQL,
AcceptAnomalyGQL,
GetUpcomingToursGQL, GetUpcomingToursGQL,
PaginatedAnomaly, PaginatedAnomaly,
PaginatedAnomalyGroup, PaginatedAnomalyGroup,
SetAnomalyToSleepGQL,
} from '../../core/data-access/graphql/generated/generated'; } from '../../core/data-access/graphql/generated/generated';
@Injectable() @Injectable()
@@ -23,86 +23,98 @@ export class AnomaliesService {
private readonly setAnomalyToSleepGql: SetAnomalyToSleepGQL, private readonly setAnomalyToSleepGql: SetAnomalyToSleepGQL,
private readonly acceptAnomalyGql: AcceptAnomalyGQL, private readonly acceptAnomalyGql: AcceptAnomalyGQL,
private readonly upcomingToursGql: GetUpcomingToursGQL, private readonly upcomingToursGql: GetUpcomingToursGQL,
private readonly toast: HotToastService, private readonly toast: HotToastService
) { ) {
this.upcomingToursQuery = this.upcomingToursGql.watch({ this.upcomingToursQuery = this.upcomingToursGql.watch(
pollInterval: 60 * 1000, {},
}); { pollInterval: 60 * 1000 }
);
this.upcomingTours$ = this.upcomingToursQuery.valueChanges; this.upcomingTours$ = this.upcomingToursQuery.valueChanges;
} }
getAnomaliesByPage( getAnomaliesByPage(
cursor: string, cursor: string,
take: number = 10, take: number = 10
): Observable<PaginatedAnomaly> { ): Observable<PaginatedAnomaly> {
return this.getAnomaliesGql return this.getAnomaliesGql
.fetch({ variables: { cursor, take }, fetchPolicy: 'no-cache' }) .fetch(
{ cursor, take },
{
fetchPolicy: 'no-cache',
}
)
.pipe( .pipe(
map((queryResult) => queryResult.data?.anomalies as PaginatedAnomaly), map((queryResult) => queryResult.data.anomalies),
catchError((err) => { catchError((err) => {
console.error(err); console.error(err);
return EMPTY; return EMPTY;
}), })
); );
} }
getGroupedAnomaliesByPage( getGroupedAnomaliesByPage(
cursor: string, cursor: string,
take: number = 10, take: number = 10
): Observable<PaginatedAnomalyGroup> { ): Observable<PaginatedAnomalyGroup> {
return this.getGroupedAnomaliesGql return this.getGroupedAnomaliesGql
.fetch({ variables: { cursor, take }, fetchPolicy: 'no-cache' }) .fetch(
{ cursor, take },
{
fetchPolicy: 'no-cache',
}
)
.pipe( .pipe(
map( map(
(queryResult) => (queryResult) =>
queryResult.data queryResult.data
?.groupedAnomalies as unknown as PaginatedAnomalyGroup, .groupedAnomalies as unknown as PaginatedAnomalyGroup
), ),
catchError((err) => { catchError((err) => {
console.error(err); console.error(err);
return EMPTY; return EMPTY;
}), })
); );
} }
setAnomalyToSleep(anomalyId: string): void { setAnomalyToSleep(anomalyId: string): void {
this.setAnomalyToSleepGql this.setAnomalyToSleepGql
.mutate({ variables: { id: anomalyId } }) .mutate({ id: anomalyId })
.pipe( .pipe(
take(1), take(1),
tap(() => this.anomaliesRefresh$.emit()), tap(() => this.anomaliesRefresh$.emit()),
tap((mutationResult) => { tap((mutationResult) => {
if (mutationResult.error) { if (mutationResult.errors) {
this.toast.error( this.toast.error(
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal', 'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
); );
} else { } else {
this.toast.success( this.toast.success(
'Die Anomalie wurde für eine Stunde stummgeschaltet!', 'Die Anomalie wurde für eine Stunde stummgeschaltet!'
); );
} }
}), })
) )
.subscribe(); .subscribe();
} }
acceptAnomaly(anomalyId: string): void { acceptAnomaly(anomalyId: string): void {
this.acceptAnomalyGql this.acceptAnomalyGql
.mutate({ variables: { id: anomalyId } }) .mutate({ id: anomalyId })
.pipe( .pipe(
take(1), take(1),
delay(10), delay(10),
tap(() => this.anomaliesRefresh$.emit()), tap(() => this.anomaliesRefresh$.emit()),
tap((mutationResult) => { tap((mutationResult) => {
if (mutationResult.error) { if (mutationResult.errors) {
this.toast.error( this.toast.error(
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal', 'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
); );
} else { } else {
this.toast.success('Die Anomalie wurde akzeptiert!'); this.toast.success('Die Anomalie wurde akzeptiert!');
} }
}), })
) )
.subscribe(); .subscribe();
} }
@@ -43,25 +43,31 @@ export class AnomalyViewComponent {
constructor() { constructor() {
effect(() => { effect(() => {
this.anomalyByIdGql this.anomalyByIdGql
.fetch({ variables: { id: this.anomalyId()} }) .fetch({ id: this.anomalyId() })
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
tap((res) => this.anomaly.set(res.data?.anomaly)) tap((res) => this.anomaly.set(res.data.anomaly))
) )
.subscribe(); .subscribe();
this.associatedEntitiesGql this.associatedEntitiesGql
.fetch({ variables:{id: this.anomalyId()} }) .fetch({ id: this.anomalyId() })
.pipe( .pipe(
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
map((res) => res.data?.associatedEntities), map((res) => res.data.associatedEntities),
tap( tap(
(props) => { ({
if (props?.tour) this.tours.set(props?.tour as Tour[]); tour,
if (props?.attendanceRegistration) attendanceRegistration,
this.attendanceRegistrations.set(props?.attendanceRegistration); plannedTime,
if (props?.plannedTime) this.plannedTimes.set(props?.plannedTime); workTime,
if (props?.workTime) this.workTimes.set(props?.workTime); employee,
if (props?.employee) this.employees.set(props?.employee); }) => {
if (tour) this.tours.set(tour as Tour[]);
if (attendanceRegistration)
this.attendanceRegistrations.set(attendanceRegistration);
if (plannedTime) this.plannedTimes.set(plannedTime);
if (workTime) this.workTimes.set(workTime);
if (employee) this.employees.set(employee);
} }
) )
) )
@@ -16,7 +16,7 @@ import {
templateUrl: './tours-per-time-kpi.component.html', templateUrl: './tours-per-time-kpi.component.html',
styleUrls: ['./tours-per-time-kpi.component.css'], styleUrls: ['./tours-per-time-kpi.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false, standalone: false
}) })
export class ToursPerTimeKPIComponent implements OnInit { export class ToursPerTimeKPIComponent implements OnInit {
@Input() ordinanceType!: string; @Input() ordinanceType!: string;
@@ -27,16 +27,13 @@ export class ToursPerTimeKPIComponent implements OnInit {
currentTimeSlot$!: Observable<number>; currentTimeSlot$!: Observable<number>;
constructor( constructor(
private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL, private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.toursPerTimeKPI$ = this.getToursPerTimeKPIGql this.toursPerTimeKPI$ = this.getToursPerTimeKPIGql
.watch({ .watch({ ordinanceType: this.ordinanceType }, { pollInterval: 60000 })
variables: { ordinanceType: this.ordinanceType }, .valueChanges.pipe(map((result) => result.data.toursPerTimeKPI));
pollInterval: 60000,
})
.valueChanges.pipe(map((result) => result.data?.toursPerTimeKPI as GetTourAmountPerTimeKpiQuery['toursPerTimeKPI']));
this.currentTimeSlot$ = combineLatest([ this.currentTimeSlot$ = combineLatest([
interval(60000).pipe(startWith(0)), interval(60000).pipe(startWith(0)),
this.toursPerTimeKPI$, this.toursPerTimeKPI$,
@@ -46,9 +43,9 @@ export class ToursPerTimeKPIComponent implements OnInit {
this.isInInterval({ this.isInInterval({
start: kpi.interval.start, start: kpi.interval.start,
end: kpi.interval.end, end: kpi.interval.end,
}), })
), )
), )
); );
} }
@@ -21,9 +21,9 @@ export class UpcomingToursComponent {
private readonly diaolog: DialogService private readonly diaolog: DialogService
) { ) {
this.upcomingTours$ = this.anomaliesService.upcomingTours$.pipe( this.upcomingTours$ = this.anomaliesService.upcomingTours$.pipe(
map((result) => result.data?.upcomingTours), map((result) => result.data.upcomingTours),
map((tours) => map((tours) =>
(tours ?? []) tours
.map((tour) => ({ .map((tour) => ({
...tour, ...tour,
colorClass: colorClass:
@@ -151,7 +151,7 @@ import { LinkCardComponent } from './link-card/link-card.component';
<app-manager-info-card ordinanceType="KTW"></app-manager-info-card> <app-manager-info-card ordinanceType="KTW"></app-manager-info-card>
<!-- <app-manager-info-card ordinanceType="ALL"></app-manager-info-card> --> <!-- <app-manager-info-card ordinanceType="ALL"></app-manager-info-card> -->
</div> </div>
<!-- <div <div
class="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4" class="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
> >
<div class="col-span-4 text-lg">Ticket System - Bearbeitungsstatus</div> <div class="col-span-4 text-lg">Ticket System - Bearbeitungsstatus</div>
@@ -167,7 +167,7 @@ import { LinkCardComponent } from './link-card/link-card.component';
<app-ticket-state-info-card <app-ticket-state-info-card
[ticketState]="TicketState.DocumentsMissing" [ticketState]="TicketState.DocumentsMissing"
></app-ticket-state-info-card> ></app-ticket-state-info-card>
</div> --> </div>
</div> </div>
`, `,
styles: ``, styles: ``,
@@ -210,7 +210,7 @@ export class HomeComponent implements OnInit {
effect(() => { effect(() => {
this.homeDashboardGql this.homeDashboardGql
.fetch() .fetch()
.pipe(tap((res) => this.kpis.set(res.data?.homeDashboardKpis))) .pipe(tap((res) => this.kpis.set(res.data.homeDashboardKpis)))
.subscribe(); .subscribe();
}); });
} }
@@ -1,22 +1,22 @@
@if (kpiQuery.isPending()) { <!-- <ng-container
<hlm-skeleton class="h-75 w-92.5"></hlm-skeleton> *skeleton="isLoading(); repeat: 5; className: 'mr-2'; height: '50px'"
} ></ng-container> -->
@if (kpiQuery.data(); as result) { @if (kpi()?.main?.label) {
<app-info-card <app-info-card
[chartValue]="result.main.value" [chartValue]="kpi()?.main?.value!"
[chartLabel]="result.main.label" [chartLabel]="kpi()?.main?.label!"
[title]="ordinanceType()" [title]="ordinanceType()"
> >
<app-info-card-content <app-info-card-content
[value]="result.secondary.value | number: '.0-1' : 'de'" [value]="kpi()?.secondary?.value | number : '.0-1' : 'de'"
[label]="result.secondary.label" [label]="kpi()?.secondary?.label"
></app-info-card-content> ></app-info-card-content>
@for (metric of result.additional; track $index) { @for (metric of kpi()?.additional; track $index) {
<app-info-card-footer <app-info-card-footer
[value]="metric.value | number: '.0-2' : 'de'" [value]="metric.value | number : '.0-2' : 'de'"
[label]="metric.label" [label]="metric.label"
> >
</app-info-card-footer> </app-info-card-footer>
} }
</app-info-card> </app-info-card>
} }
@@ -1,50 +1,38 @@
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { Component, effect, inject, input, signal } from '@angular/core';
import { Component, inject, input } from '@angular/core'; import { tap } from 'rxjs';
import { KpiInfoDto } from '@avicenna/shared-dtos';
import { HlmSkeleton } from '@spartan-ng/helm/skeleton';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
import { InfoCardModule } from '../../../../core/components/info-card/info-card.component'; import { InfoCardModule } from '../../../../core/components/info-card/info-card.component';
import {
KpiInfo,
Metric,
} from '../../../../core/data-access/graphql/generated/generated';
import { ManagerDashboardService } from '../../manager-dashboard.service';
@Component({ @Component({
selector: 'app-manager-info-card', selector: 'app-manager-info-card',
imports: [InfoCardModule, DecimalPipe, HlmSkeleton], imports: [InfoCardModule, DecimalPipe],
templateUrl: './manager-info-card.component.html', templateUrl: './manager-info-card.component.html',
styleUrl: './manager-info-card.component.scss', styleUrl: './manager-info-card.component.scss',
}) })
export class ManagerInfoCardComponent { export class ManagerInfoCardComponent {
private readonly http = inject(HttpClient); private service = inject(ManagerDashboardService);
isLoading = signal<boolean>(false);
ordinanceType = input.required<string>(); ordinanceType = input.required<string>();
kpi = signal<KpiInfo | undefined>(undefined);
kpiQuery = injectQuery(() => ({ metrics = signal<Metric[]>([]);
queryKey: ['home'],
queryFn: () =>
lastValueFrom(
this.http.get<KpiInfoDto>(
`${environment.api.baseUrl}/kpis/manager?ordinanceType=${this.ordinanceType()}`,
),
),
}));
// private service = inject(ManagerDashboardService);
// isLoading = signal<boolean>(false);
// kpi = signal<KpiInfo | undefined>(undefined);
// metrics = signal<Metric[]>([]);
constructor() { constructor() {
// effect(() => { effect(() => {
// this.isLoading.set(true); this.isLoading.set(true);
// this.service this.service
// .fetchManagerKpi(this.ordinanceType()) .fetchManagerKpi(this.ordinanceType())
// .pipe( .pipe(
// tap((res) => this.metrics.set(res.additional)), tap((res) => this.metrics.set(res.additional)),
// tap((res) => this.kpi.set(res)), tap((res) => this.kpi.set(res)),
// tap(() => this.isLoading.set(false)), tap(() => this.isLoading.set(false))
// ) )
// .subscribe(); .subscribe();
// }); });
} }
} }
@@ -24,14 +24,12 @@ import { TimePerTourSummaryComponent } from '../time-per-tour-summary/time-per-t
MatSlideToggleModule, MatSlideToggleModule,
], ],
templateUrl: './metric-summary.component.html', templateUrl: './metric-summary.component.html',
styleUrls: ['./metric-summary.component.css'], styleUrls: ['./metric-summary.component.css']
}) })
export class MetricSummaryComponent { export class MetricSummaryComponent {
@Input() set metrics(metrics: TimePerTourMetric[] | undefined) { @Input() set metrics(metrics: TimePerTourMetric[]) {
if (metrics) {
this._metrics$.next(metrics); this._metrics$.next(metrics);
} }
}
@Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour; @Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour;
@Input() metricTimeScale!: MetricTimeScale; @Input() metricTimeScale!: MetricTimeScale;
@Input() labelFormat!: string; @Input() labelFormat!: string;
@@ -43,7 +41,7 @@ export class MetricSummaryComponent {
vm$ = this._metrics$.pipe( vm$ = this._metrics$.pipe(
map((metrics) => ({ map((metrics) => ({
metrics, metrics,
})), }))
); );
getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string { getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string {
@@ -53,7 +51,7 @@ export class MetricSummaryComponent {
return 'd.M.yy'; return 'd.M.yy';
} else { } else {
throw new Error( throw new Error(
'No label format for time scale defined, TimeScale: ' + timeScale, 'No label format for time scale defined, TimeScale: ' + timeScale
); );
} }
} }
@@ -9,7 +9,13 @@ import { summarizeMetrics } from '../../util/summarize-metrics';
@Component({ @Component({
selector: 'dks-view-type-selector', selector: 'dks-view-type-selector',
imports: [NgClass, AsyncPipe, CurrencyPipe, MatIconModule, MatCardModule], imports: [
NgClass,
AsyncPipe,
CurrencyPipe,
MatIconModule,
MatCardModule
],
templateUrl: './view-type-selector.component.html', templateUrl: './view-type-selector.component.html',
styleUrls: ['./view-type-selector.component.css'], styleUrls: ['./view-type-selector.component.css'],
}) })
@@ -17,16 +23,14 @@ export class ViewTypeSelectorComponent {
@Input() viewType!: DashboardViewType; @Input() viewType!: DashboardViewType;
@Input() currentMetric!: DiagrammType; @Input() currentMetric!: DiagrammType;
@Input() selected = false; @Input() selected = false;
@Input() set metrics(metrics: TimePerTourMetric[] | undefined) { @Input() set metrics(metrics: TimePerTourMetric[]) {
if (metrics) {
this.metrics$.next(metrics); this.metrics$.next(metrics);
} }
}
@Output() viewTypeSelected = new EventEmitter<void>(); @Output() viewTypeSelected = new EventEmitter<void>();
private metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]); private metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
metricSummary$ = this.metrics$.pipe( metricSummary$ = this.metrics$.pipe(
map((metrics) => summarizeMetrics(metrics)), map((metrics) => summarizeMetrics(metrics))
); );
DiagrammType = DiagrammType; DiagrammType = DiagrammType;
@@ -8,7 +8,7 @@
</button> </button>
<div class="flex items-center"> <div class="flex items-center">
<span class="pr-2"> <span class="pr-2">
{{ vm.selectedDate | date: "dd.MM.yyyy" }} {{ vm.selectedDate | date : "dd.MM.yyyy" }}
</span> </span>
<input <input
[formControl]="selectedDate" [formControl]="selectedDate"
@@ -1,18 +1,14 @@
import { Component, signal } from '@angular/core'; import { Component } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { MatChipListboxChange } from '@angular/material/chips'; import { MatChipListboxChange } from '@angular/material/chips';
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { addDays, addHours, format, isSunday } from 'date-fns'; import { addDays, addHours, format, isSunday } from 'date-fns';
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
filter,
map, map,
Observable,
startWith, startWith,
switchMap,
} from 'rxjs'; } from 'rxjs';
import { getDayviewQuery } from 'src/app/core/data-access/managers/manager.queries';
import { groupBy } from '../../../core/util/group-by'; import { groupBy } from '../../../core/util/group-by';
import { ManagerDashboardService } from '../manager-dashboard.service'; import { ManagerDashboardService } from '../manager-dashboard.service';
import { import {
@@ -27,7 +23,7 @@ import { mergeMetrics } from '../util/merge-metric';
selector: 'dks-dayview', selector: 'dks-dayview',
templateUrl: './dayview.component.html', templateUrl: './dayview.component.html',
styleUrls: ['./dayview.component.css'], styleUrls: ['./dayview.component.css'],
standalone: false, standalone: false
}) })
export class DayviewComponent { export class DayviewComponent {
OrdinanceTypes = OrdinanceTypes; OrdinanceTypes = OrdinanceTypes;
@@ -37,39 +33,49 @@ export class DayviewComponent {
metricTimeScale = MetricTimeScale.Dayview; metricTimeScale = MetricTimeScale.Dayview;
selectedDate = new FormControl<Date>(new Date()); selectedDate = new FormControl<Date>(new Date());
selectedDate$ = this.selectedDate.valueChanges.pipe( selectedDate$ = this.selectedDate.valueChanges.pipe(
startWith(this.selectedDate.value as Date), startWith(this.selectedDate.value),
map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date)), map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date))
) as Observable<Date>; );
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue); selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
selectedViewType$ = new BehaviorSubject<DashboardViewType>( selectedViewType$ = new BehaviorSubject<DashboardViewType>(
DashboardViewType.Gesamt, DashboardViewType.Gesamt
); );
vm$; vm$;
private readonly dateSignal = toSignal(this.selectedDate$, {
initialValue: new Date(),
});
btwQuery = getDayviewQuery(this.dateSignal, signal(OrdinanceTypes.BTW));
tswQuery = getDayviewQuery(this.dateSignal, signal(OrdinanceTypes.TSW));
ktwQuery = getDayviewQuery(this.dateSignal, signal(OrdinanceTypes.KTW));
constructor( constructor(
private readonly managerDashboardService: ManagerDashboardService, private readonly managerDashboardService: ManagerDashboardService
) { ) {
this.vm$ = combineLatest([ this.vm$ = combineLatest([
toObservable(this.btwQuery.data), this.selectedDate$.pipe(
toObservable(this.tswQuery.data), switchMap((selectedDate) =>
toObservable(this.ktwQuery.data), this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
OrdinanceTypes.BTW,
selectedDate ?? new Date()
)
)
),
this.selectedDate$.pipe(
switchMap((selectedDate) =>
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
OrdinanceTypes.TSW,
selectedDate ?? new Date()
)
)
),
this.selectedDate$.pipe(
switchMap((selectedDate) =>
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
OrdinanceTypes.KTW,
selectedDate ?? new Date()
)
)
),
this.selectedMetric$, this.selectedMetric$,
this.selectedDate$, this.selectedDate$,
this.managerDashboardService.isLoadingDayview$, this.managerDashboardService.isLoadingDayview$,
this.selectedViewType$, this.selectedViewType$,
]).pipe( ]).pipe(
filter(
([btwMetrics, tswMetrics, ktwMetrics]) =>
!!btwMetrics && !!tswMetrics && !!ktwMetrics,
),
map( map(
([ ([
btwMetrics, btwMetrics,
@@ -90,17 +96,13 @@ export class DayviewComponent {
combinedMetrics: mergeMetrics( combinedMetrics: mergeMetrics(
Object.values( Object.values(
groupBy( groupBy(
[ [...btwMetrics, ...tswMetrics, ...ktwMetrics],
...(btwMetrics as TimePerTourMetricDto[]), ({ interval: { start } }) => format(new Date(start), 'HH:mm')
...(tswMetrics as TimePerTourMetricDto[]), )
...(ktwMetrics as TimePerTourMetricDto[]), )
],
({ interval: { start } }) => format(new Date(start), 'HH:mm'),
),
),
),
}),
), ),
})
)
); );
} }
@@ -111,7 +113,7 @@ export class DayviewComponent {
addDayToSelectedDate(days: number): void { addDayToSelectedDate(days: number): void {
const nextSelectedDate = addDays( const nextSelectedDate = addDays(
this.selectedDate.value ?? new Date(), this.selectedDate.value ?? new Date(),
days, days
); );
this.selectedDate.setValue(nextSelectedDate); this.selectedDate.setValue(nextSelectedDate);
} }
@@ -57,9 +57,7 @@ export class ManagerDashboardService {
ordinanceType: string ordinanceType: string
): Observable<ManagerKpiQuery['managerKpi']> { ): Observable<ManagerKpiQuery['managerKpi']> {
const managerKpiQuery = this.managerKpiGql.watch({ const managerKpiQuery = this.managerKpiGql.watch({
variables: {
ordinanceType, ordinanceType,
}
}); });
const update = this.timePerTourUpdate$.pipe( const update = this.timePerTourUpdate$.pipe(
@@ -76,48 +74,44 @@ export class ManagerDashboardService {
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData updatedData ? updatedData : firstData
), ),
map((response) => response.data?.managerKpi as ManagerKpiQuery['managerKpi']) map((response) => response.data.managerKpi)
); );
} }
// getTimePerTourKPIByOrdinanceTypes( getTimePerTourKPIByOrdinanceTypes(
// ordinanceType: string ordinanceType: string
// ): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> { ): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> {
// const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({ const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({
// variables: { ordinanceType,
// ordinanceType, });
// }
// });
// const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe( const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
// switchMap(() => timePerTourKPIQuery.refetch()), switchMap(() => timePerTourKPIQuery.refetch()),
// startWith(undefined) startWith(undefined)
// ); );
// return combineLatest([ return combineLatest([
// timePerTourKPIUpdate, timePerTourKPIUpdate,
// timePerTourKPIQuery.valueChanges, timePerTourKPIQuery.valueChanges,
// ]).pipe( ]).pipe(
// catchError((error) => { catchError((error) => {
// console.error(error); console.error(error);
// return throwError(() => error); return throwError(() => error);
// }), }),
// retry({ delay: 1000, count: 3 }), retry({ delay: 1000, count: 3 }),
// map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
// updatedData ? updatedData : firstData updatedData ? updatedData : firstData
// ), ),
// map((response) => response.data?.toursPerTimeKPI as GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']) map((response) => response.data.toursPerTimeKPI)
// ); );
// } }
getTimePerTourMetricByOrdinanceTypeForDay( getTimePerTourMetricByOrdinanceTypeForDay(
ordinanceType: string, ordinanceType: string,
day: Date day: Date
): Observable<GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']> { ): Observable<GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']> {
const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({ const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({
variables: {
ordinanceType, ordinanceType,
day, day,
}
}); });
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe( const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
@@ -140,7 +134,7 @@ export class ManagerDashboardService {
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData updatedData ? updatedData : firstData
), ),
map((response) => response.data?.timePerTourMetricForDay as GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']) map((response) => response.data.timePerTourMetricForDay)
); );
} }
@@ -152,12 +146,10 @@ export class ManagerDashboardService {
> { > {
const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch( const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch(
{ {
variables: {
ordinanceType, ordinanceType,
day, day,
}, },
errorPolicy: 'all' { errorPolicy: 'all' }
},
); );
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe( const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
@@ -181,10 +173,10 @@ export class ManagerDashboardService {
updatedData ? updatedData : firstData updatedData ? updatedData : firstData
), ),
map((response) => { map((response) => {
if (response.error || response.error) { if (response.error || response.errors) {
console.error(response.error, response.error); console.error(response.error, response.errors);
} }
return response.data?.timePerTourMetricForMonth as GetTimePerTourMetricForMonthQuery['timePerTourMetricForMonth']; return response.data.timePerTourMetricForMonth;
}) })
); );
} }
@@ -1,19 +1,15 @@
import { Component, signal } from '@angular/core'; import { Component } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { MatChipListboxChange } from '@angular/material/chips'; import { MatChipListboxChange } from '@angular/material/chips';
import { MatDatepicker } from '@angular/material/datepicker'; import { MatDatepicker } from '@angular/material/datepicker';
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns'; import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns';
import { import {
BehaviorSubject, BehaviorSubject,
combineLatest, combineLatest,
filter,
map, map,
Observable,
startWith, startWith,
switchMap,
} from 'rxjs'; } from 'rxjs';
import { getMonthviewQuery } from 'src/app/core/data-access/managers/manager.queries';
import { groupBy } from '../../../core/util/group-by'; import { groupBy } from '../../../core/util/group-by';
import { ManagerDashboardService } from '../manager-dashboard.service'; import { ManagerDashboardService } from '../manager-dashboard.service';
import { import {
@@ -38,39 +34,48 @@ export class MonthviewComponent {
metricTimeScale = MetricTimeScale.Monthview; metricTimeScale = MetricTimeScale.Monthview;
selectedDate = new FormControl<Date>(addDays(startOfMonth(new Date()), 1)); selectedDate = new FormControl<Date>(addDays(startOfMonth(new Date()), 1));
selectedDate$ = this.selectedDate.valueChanges.pipe( selectedDate$ = this.selectedDate.valueChanges.pipe(
startWith(this.selectedDate.value as Date), startWith(this.selectedDate.value)
map((data) => data), );
) as Observable<Date>;
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue); selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
selectedViewType$ = new BehaviorSubject<DashboardViewType>( selectedViewType$ = new BehaviorSubject<DashboardViewType>(
DashboardViewType.Gesamt, DashboardViewType.Gesamt
); );
vm$; vm$;
private readonly dateSignal = toSignal(this.selectedDate$, {
initialValue: new Date(),
});
btwQuery = getMonthviewQuery(this.dateSignal, signal(OrdinanceTypes.BTW));
tswQuery = getMonthviewQuery(this.dateSignal, signal(OrdinanceTypes.TSW));
ktwQuery = getMonthviewQuery(this.dateSignal, signal(OrdinanceTypes.KTW));
constructor( constructor(
private readonly managerDashboardService: ManagerDashboardService, private readonly managerDashboardService: ManagerDashboardService
) { ) {
this.vm$ = combineLatest([ this.vm$ = combineLatest([
toObservable(this.btwQuery.data), this.selectedDate$.pipe(
toObservable(this.tswQuery.data), switchMap((selectedDate) =>
toObservable(this.ktwQuery.data), this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
OrdinanceTypes.BTW,
selectedDate ?? new Date()
)
)
),
this.selectedDate$.pipe(
switchMap((selectedDate) =>
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
OrdinanceTypes.TSW,
selectedDate ?? new Date()
)
)
),
this.selectedDate$.pipe(
switchMap((selectedDate) =>
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
OrdinanceTypes.KTW,
selectedDate ?? new Date()
)
)
),
this.selectedMetric$, this.selectedMetric$,
this.selectedDate$, this.selectedDate$,
this.managerDashboardService.isLoadingMonthview$, this.managerDashboardService.isLoadingMonthview$,
this.selectedViewType$, this.selectedViewType$,
]).pipe( ]).pipe(
filter(
([btwMetrics, tswMetrics, ktwMetrics]) =>
!!btwMetrics && !!tswMetrics && !!ktwMetrics,
),
map( map(
([ ([
btwMetrics, btwMetrics,
@@ -91,17 +96,13 @@ export class MonthviewComponent {
combinedMetrics: mergeMetrics( combinedMetrics: mergeMetrics(
Object.values( Object.values(
groupBy( groupBy(
[ [...btwMetrics, ...tswMetrics, ...ktwMetrics],
...(btwMetrics as TimePerTourMetricDto[]), ({ interval: { start } }) => format(new Date(start), 'd.M.yy')
...(tswMetrics as TimePerTourMetricDto[]), )
...(ktwMetrics as TimePerTourMetricDto[]), )
],
({ interval: { start } }) => format(new Date(start), 'd.M.yy'),
),
),
),
}),
), ),
})
)
); );
} }
@@ -112,7 +113,7 @@ export class MonthviewComponent {
addMonthToSelectedDate(months: number): void { addMonthToSelectedDate(months: number): void {
const nextSelectedDate = addMonths( const nextSelectedDate = addMonths(
this.selectedDate.value ?? new Date(), this.selectedDate.value ?? new Date(),
months, months
); );
if (isSunday(nextSelectedDate)) { if (isSunday(nextSelectedDate)) {
this.selectedDate.setValue(addDays(nextSelectedDate, 1)); this.selectedDate.setValue(addDays(nextSelectedDate, 1));
@@ -127,7 +128,7 @@ export class MonthviewComponent {
setMonthAndYear( setMonthAndYear(
normalizedMonthAndYear: Date, normalizedMonthAndYear: Date,
datepicker: MatDatepicker<Date>, datepicker: MatDatepicker<Date>
) { ) {
this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1)); this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1));
datepicker.close(); datepicker.close();
@@ -1,13 +1,15 @@
@if (vm$ | async; as vm) { @if (vm$ | async; as vm) {
<div class="flex flex-col m-0 md:m-4"> <div
class="flex flex-col m-0 md:m-4"
>
<div class="flex flex-wrap md:flex-nowrap"> <div class="flex flex-wrap md:flex-nowrap">
<!-- KPI --> <!-- KPI -->
<dks-time-per-tour-kpi <dks-time-per-tour-kpi
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200" class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
[ngClass]="{ [ngClass]="{
border: vm.selectedOrdinanceType === OrdinanceTypes.BTW, 'border': vm.selectedOrdinanceType === OrdinanceTypes.BTW
}" }"
style="border-color: var(--mat-sys-primary)" style="border-color: var(--mat-sys-primary);"
[text]="OrdinanceTypes.BTW" [text]="OrdinanceTypes.BTW"
[kpis]="vm.btwKPI.summaries" [kpis]="vm.btwKPI.summaries"
(click)="setSelectedOrdinanceType(OrdinanceTypes.BTW)" (click)="setSelectedOrdinanceType(OrdinanceTypes.BTW)"
@@ -15,9 +17,9 @@
<dks-time-per-tour-kpi <dks-time-per-tour-kpi
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200" class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
[ngClass]="{ [ngClass]="{
border: vm.selectedOrdinanceType === OrdinanceTypes.TSW, 'border': vm.selectedOrdinanceType === OrdinanceTypes.TSW
}" }"
style="border-color: var(--mat-sys-primary)" style="border-color: var(--mat-sys-primary);"
[text]="OrdinanceTypes.TSW" [text]="OrdinanceTypes.TSW"
[kpis]="vm.tswKPI.summaries" [kpis]="vm.tswKPI.summaries"
(click)="setSelectedOrdinanceType(OrdinanceTypes.TSW)" (click)="setSelectedOrdinanceType(OrdinanceTypes.TSW)"
@@ -25,16 +27,18 @@
<dks-time-per-tour-kpi <dks-time-per-tour-kpi
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200" class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
[ngClass]="{ [ngClass]="{
border: vm.selectedOrdinanceType === OrdinanceTypes.KTW, 'border': vm.selectedOrdinanceType === OrdinanceTypes.KTW
}" }"
style="border-color: var(--mat-sys-primary)" style="border-color: var(--mat-sys-primary);"
[text]="OrdinanceTypes.KTW" [text]="OrdinanceTypes.KTW"
[kpis]="vm.ktwKPI.summaries" [kpis]="vm.ktwKPI.summaries"
(click)="setSelectedOrdinanceType(OrdinanceTypes.KTW)" (click)="setSelectedOrdinanceType(OrdinanceTypes.KTW)"
></dks-time-per-tour-kpi> ></dks-time-per-tour-kpi>
</div> </div>
@if (vm.selectedOrdinanceType) { @if (vm.selectedOrdinanceType) {
<div class="md:p-2 flex flex-wrap md:flex-nowrap"> <div
class="md:p-2 flex flex-wrap md:flex-nowrap"
>
<!-- Data Table --> <!-- Data Table -->
<mat-card appearance="outlined" class="w-full"> <mat-card appearance="outlined" class="w-full">
<mat-card-content> <mat-card-content>
@@ -1,75 +1,58 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { format, Interval } from 'date-fns'; import { format, Interval } from 'date-fns';
import { BehaviorSubject, combineLatest, filter, map } from 'rxjs'; import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { ManagerDashboardService } from '../manager-dashboard.service'; import { ManagerDashboardService } from '../manager-dashboard.service';
import { OrdinanceTypes } from '../types'; import { OrdinanceTypes } from '../types';
import { getOverviewQuery } from 'src/app/core/data-access/managers/manager.queries';
import { toObservable } from '@angular/core/rxjs-interop';
import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries';
import {
TimePerTourKPISummaryDto,
TimePerTourMetricDto,
} from '@avicenna/shared-dtos';
@Component({ @Component({
selector: 'dks-overview', selector: 'dks-overview',
templateUrl: './overview.component.html', templateUrl: './overview.component.html',
styleUrls: ['./overview.component.css'], styleUrls: ['./overview.component.css'],
standalone: false, standalone: false
}) })
export class OverviewComponent { export class OverviewComponent {
OrdinanceTypes = OrdinanceTypes; OrdinanceTypes = OrdinanceTypes;
selectedOrdinanceType$ = new BehaviorSubject<OrdinanceTypes | undefined>( selectedOrdinanceType$ = new BehaviorSubject<OrdinanceTypes | undefined>(
undefined, undefined
); );
vm$; vm$;
btwOverviewQuery = getOverviewQuery(OrdinanceTypes.BTW);
tswOverviewQuery = getOverviewQuery(OrdinanceTypes.TSW);
ktwOverviewQuery = getOverviewQuery(OrdinanceTypes.KTW);
constructor( constructor(
private readonly managerDashboardService: ManagerDashboardService
) { ) {
this.vm$ = combineLatest([ this.vm$ = combineLatest([
toObservable(this.btwOverviewQuery.data), this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
toObservable(this.tswOverviewQuery.data), OrdinanceTypes.BTW
toObservable(this.ktwOverviewQuery.data), ),
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
OrdinanceTypes.TSW
),
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
OrdinanceTypes.KTW
),
this.selectedOrdinanceType$, this.selectedOrdinanceType$,
]).pipe( ]).pipe(
filter(([btw, tsw, ktw]) => !!btw && !!tsw && !!ktw),
map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({ map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({
btwKPI: { btwKPI: {
summaries: summaries: btwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
btwKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [], cars: this.createCarTableData(
cars: btwKPIs.map(({ interval, cars }) => ({ interval, cars }))
this.createCarTableData(
(btwKPIs as TimePerTourKPISummaryDto[]).map(
({ interval, cars }) => ({ interval, cars }),
), ),
) ?? [],
}, },
tswKPI: { tswKPI: {
summaries: summaries: tswKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
tswKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [], cars: this.createCarTableData(
cars: tswKPIs.map(({ interval, cars }) => ({ interval, cars }))
this.createCarTableData(
(tswKPIs as TimePerTourKPISummaryDto[]).map(
({ interval, cars }) => ({ interval, cars }),
), ),
) ?? [],
}, },
ktwKPI: { ktwKPI: {
summaries: summaries: ktwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
ktwKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [], cars: this.createCarTableData(
cars: ktwKPIs.map(({ interval, cars }) => ({ interval, cars }))
this.createCarTableData(
(ktwKPIs as TimePerTourKPISummaryDto[]).map(
({ interval, cars }) => ({ interval, cars }),
), ),
) ?? [],
}, },
selectedOrdinanceType, selectedOrdinanceType,
})), }))
); );
} }
@@ -91,15 +74,15 @@ export class OverviewComponent {
meta: { availableTime: number; tourAmount: number }; meta: { availableTime: number; tourAmount: number };
}; };
}[]; }[];
}[], }[]
) { ) {
const columns = [ const columns = [
{ id: 'carName', title: 'Fahrzeug' }, { id: 'carName', title: 'Fahrzeug' },
...carsByInterval?.map(({ interval }, index) => ({ ...carsByInterval.map(({ interval }, index) => ({
id: `slot${index}`, id: `slot${index}`,
title: `${format(new Date(interval.start), 'HH:mm')} - ${format( title: `${format(new Date(interval.start), 'HH:mm')} - ${format(
new Date(interval.end), new Date(interval.end),
'HH:mm', 'HH:mm'
)}`, )}`,
})), })),
]; ];
@@ -123,7 +106,7 @@ export class OverviewComponent {
} }
return newGroup; return newGroup;
}, },
{} as Record<string, any>, {} as Record<string, any>
); );
return { columns, data: Object.values(groupedByCars) }; return { columns, data: Object.values(groupedByCars) };
} }
@@ -1,341 +0,0 @@
import { DatePipe, NgClass } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, Input, signal } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideAmbulance,
lucideChevronLeft,
lucideChevronRight,
lucideLoader2,
lucideUser,
} from '@ng-icons/lucide';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { injectQuery } from '@tanstack/angular-query-experimental';
import { addDays, addMinutes, format, parse, subDays } from 'date-fns';
import { lastValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
export interface Car {
name: string;
drivers: Driver[];
tours: Tour[];
}
export interface Driver {
name: string;
}
export interface Tour {
id: string;
operationId: string;
ordinanceType: string;
startDate: string;
optimizationInMinutes: number;
departedForPickup?: string;
arrivedAtPickup?: string;
departedPickup?: string;
arrivedAtTarget?: string;
completed?: string;
}
@Component({
selector: 'app-time-cell',
standalone: true,
imports: [DatePipe, NgClass],
template: `
<div
class="flex items-center justify-center rounded py-1 px-0.5 text-xs font-mono tabular-nums min-h-6"
[ngClass]="
date
? [bg, color, 'font-medium']
: 'bg-muted/30 text-muted-foreground/30'
"
>
@if (date) {
{{ date }}
} @else {
-
}
</div>
`,
})
export class TimeCellComponent {
@Input() date: Date | string | null | undefined;
@Input() color = 'text-foreground';
@Input() bg = 'bg-muted';
}
@Component({
selector: 'app-playground',
imports: [NgIcon, HlmIcon, HlmButtonImports, DatePipe, TimeCellComponent],
providers: [
provideIcons({
lucideChevronRight,
lucideChevronLeft,
lucideAmbulance,
lucideUser,
lucideLoader2,
}),
],
template: ` <div class="flex flex-col h-[calc(100vh-6rem)] gap-4 m-0 md:m-4">
<div
class="flex-none flex items-center justify-between p-4 bg-card border rounded-lg shadow-sm"
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 bg-muted/30 rounded-md p-1">
<button
hlmBtn
size="icon"
variant="ghost"
class="size-8"
(click)="prevDay()"
>
<ng-icon hlm size="sm" name="lucideChevronLeft" />
</button>
<span class="font-mono font-medium min-w-24 text-center">
{{ selectedDate() | date: 'dd.MM.yyyy' }}
</span>
<button
hlmBtn
size="icon"
variant="ghost"
class="size-8"
(click)="nextDay()"
>
<ng-icon hlm size="sm" name="lucideChevronRight" />
</button>
</div>
<span class="text-sm text-muted-foreground hidden md:inline-block">
Playground: Disposition & Timestamps
</span>
</div>
</div>
<div class="flex-1 min-h-0 relative">
@if (carsQuery.isLoading()) {
<div
class="flex h-full items-center justify-center text-muted-foreground animate-pulse"
>
<ng-icon hlmIcon name="lucideLoader2" class="mr-2 animate-spin" />
Lade Fahrzeuge ...
</div>
}
@if (carsQuery.data(); as cars) {
@if(cars.length === 0) {
Kein Optimierungspotenzial für den Einsatztag {{selectedDate() | date:"dd.MM.yyyy"}}
}
<div
class="absolute inset-0 flex flex-row gap-4 overflow-x-auto pb-4 px-1 snap-x snap-mandatory"
>
@for (car of cars; track car.name) {
<div
class="flex-none w-80 md:w-96 flex flex-col h-full bg-muted/10 border rounded-xl overflow-hidden snap-start bg-card shadow-sm"
>
<div class="flex-none p-3 border-b bg-muted/40 backdrop-blur-sm">
<div class="flex items-center gap-2 mb-1">
<div
class="size-8 rounded bg-primary/10 flex items-center justify-center text-primary"
>
<ng-icon hlmIcon name="lucideAmbulance" size="sm" />
</div>
<h3 class="font-bold text-lg leading-none">{{ car.name }}</h3>
</div>
<div class="flex flex-wrap gap-1 mt-2">
@for (driver of car.drivers; track driver.name) {
<span
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-background border text-muted-foreground"
>
<ng-icon
hlmIcon
name="lucideUser"
size="xs"
class="mr-1 opacity-70"
/>
{{ driver.name }}
</span>
} @empty {
<span class="text-xs text-destructive italic"
>Keine Besatzung</span
>
}
</div>
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-3">
@for (tour of car.tours; track tour.id) {
<div
class="bg-background border rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow group"
[class.bg-amber-100!]="hasOptimizationPotential(tour)"
>
<div
class="flex justify-between items-baseline border-b border-border/50 pb-2 mb-2"
>
<div class="flex flex-col">
<span
class="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider"
>Op-ID</span
>
<span class="font-mono text-xs text-muted-foreground"
>#{{ tour.operationId }}</span
>
</div>
<div class="flex flex-col items-end">
<span
class="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider"
>Start</span
>
<span class="font-bold font-mono text-sm text-primary">
{{ tour.startDate | date: 'HH:mm' }}
</span>
</div>
</div>
<div class="grid grid-cols-5 gap-1 text-center">
<div
class="col-span-5 flex justify-between px-1 mb-1 text-[9px] text-muted-foreground uppercase opacity-50"
>
<span>Anf</span> <span>Ort</span> <span>Trans</span>
<span>Ziel</span> <span>Frei</span>
</div>
<app-time-cell
[date]="tour.departedForPickup"
color="text-yellow-600"
bg="bg-yellow-500/10"
/>
<app-time-cell
[date]="tour.arrivedAtPickup"
color="text-blue-600"
bg="bg-blue-500/10"
/>
<app-time-cell
[date]="tour.departedPickup"
color="text-indigo-600"
bg="bg-indigo-500/10"
/>
<app-time-cell
[date]="tour.arrivedAtTarget"
color="text-purple-600"
bg="bg-purple-500/10"
/>
<app-time-cell
[date]="tour.completed"
color="text-green-600"
bg="bg-green-500/10"
/>
@if (hasOptimizationPotential(tour)) {
<app-time-cell
date="--:--"
color="text-gray-600"
bg="bg-gray-500/10"
/>
<app-time-cell
[date]="
calcOptimizedTime(
tour.arrivedAtPickup!,
tour.optimizationInMinutes
)
"
[color]="
hasOptimizationPotential(tour)
? 'text-red-600'
: 'text-gray-600'
"
[bg]="
hasOptimizationPotential(tour)
? 'bg-red-500/10'
: 'bg-gray-500/10'
"
/>
<app-time-cell
date="--:--"
color="text-gray-600"
bg="bg-gray-500/10"
/>
<app-time-cell
date="--:--"
color="text-gray-600"
bg="bg-gray-500/10"
/>
<app-time-cell
date="--:--"
color="text-gray-600"
bg="bg-gray-500/10"
/>
}
</div>
</div>
} @empty {
<div
class="h-32 flex flex-col items-center justify-center text-muted-foreground/50 border-2 border-dashed rounded-lg"
>
<span class="text-sm">Keine Touren</span>
</div>
}
</div>
</div>
}
</div>
}
</div>
</div>`,
})
export class PlaygroundComponent {
private readonly http = inject(HttpClient);
selectedDate = signal<Date>(new Date());
carsQuery = injectQuery(() => ({
queryKey: [
'playground',
'lists',
{ day: format(this.selectedDate(), 'yyyy-MM-dd') },
],
queryFn: async () =>
lastValueFrom(
this.http.get<Car[]>(
`${environment.api.baseUrl}/playground/operation-day?day=${format(this.selectedDate(), 'yyyy-MM-dd')}`,
),
),
}));
prevDay(): void {
this.selectedDate.update((val) => subDays(val, 1));
}
nextDay(): void {
this.selectedDate.update((val) => addDays(val, 1));
}
hasOptimizationPotential(tour: Tour): boolean {
return tour.optimizationInMinutes !== 0;
}
calcOptimizedTime(time: string, timeOffset: number): string {
if (timeOffset === 0 || !time) return '--:--';
const parsedTime = parse(time, 'HH:mm', new Date());
const updatedDate = addMinutes(parsedTime, timeOffset);
return format(updatedDate, 'HH:mm');
}
private isTimeInRange(time: string, start: string, end: string): boolean {
if (start <= end) {
return time >= start && time <= end;
} else {
return time >= start || time <= end;
}
}
}
@@ -43,7 +43,7 @@ export class TicketsService {
} }
}), }),
shareReplay(1), shareReplay(1),
startWith(undefined), startWith(undefined)
); );
updateTickets$ = new Subject<void>(); updateTickets$ = new Subject<void>();
@@ -55,7 +55,7 @@ export class TicketsService {
private readonly setDocumentInfoGql: SetDocumentInfoGQL, private readonly setDocumentInfoGql: SetDocumentInfoGQL,
private readonly ticketStateKpiGql: TicketStateKpiGQL, private readonly ticketStateKpiGql: TicketStateKpiGQL,
private readonly sendApprovalRequestGql: SendApprovalRequestGQL, private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
private readonly toast: HotToastService, private readonly toast: HotToastService
) { ) {
this.updateTickets$.next(); this.updateTickets$.next();
} }
@@ -63,21 +63,21 @@ export class TicketsService {
sendApprovalRequest(ticketId: string, email: string) { sendApprovalRequest(ticketId: string, email: string) {
return this.sendApprovalRequestGql return this.sendApprovalRequestGql
.mutate({ .mutate({
variables: {
email, email,
ticketId, ticketId,
},
}) })
.pipe( .pipe(
tap((mutationResult) => { tap((mutationResult) => {
if (mutationResult.error) { if (mutationResult.errors) {
this.toast.error( this.toast.error(
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal', 'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
); );
} else { } else {
this.toast.success('Die Genehmigungsanfrage wird versendet.'); this.toast.success(
'Die Genehmigungsanfrage wird versendet.'
);
} }
}), })
); );
} }
@@ -85,15 +85,13 @@ export class TicketsService {
state: TicketValidationState, state: TicketValidationState,
cursor: string, cursor: string,
filters: TicketFilterArgs, filters: TicketFilterArgs,
take = 20, take = 20
): Observable<GetTicketsQuery['tickets']> { ): Observable<GetTicketsQuery['tickets']> {
const ticketsQuery = this.getTicketsGql.watch({ const ticketsQuery = this.getTicketsGql.watch({
variables: {
state, state,
cursor, cursor,
filters, filters,
take, take,
},
}); });
const ticketsUpdate = combineLatest([ const ticketsUpdate = combineLatest([
@@ -101,7 +99,7 @@ export class TicketsService {
this.updateTickets$, this.updateTickets$,
]).pipe( ]).pipe(
switchMap(() => ticketsQuery.refetch()), switchMap(() => ticketsQuery.refetch()),
startWith(undefined), startWith(undefined)
); );
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe( return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
@@ -111,19 +109,17 @@ export class TicketsService {
}), }),
retry({ delay: 1000, count: 3 }), retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData, updatedData ? updatedData : firstData
), ),
map((response) => response.data?.tickets as GetTicketsQuery['tickets']), map((response) => response.data.tickets)
); );
} }
fetchTicketStateMeta( fetchTicketStateMeta(
state: TicketValidationState, state: TicketValidationState
): Observable<GetTicketStateMetaQuery['ticketStateMeta']> { ): Observable<GetTicketStateMetaQuery['ticketStateMeta']> {
const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({ const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({
variables: {
state, state,
},
}); });
const ticketsUpdate = combineLatest([ const ticketsUpdate = combineLatest([
@@ -131,7 +127,7 @@ export class TicketsService {
this.updateTickets$, this.updateTickets$,
]).pipe( ]).pipe(
switchMap(() => ticketStateMetaQuery.refetch()), switchMap(() => ticketStateMetaQuery.refetch()),
startWith(undefined), startWith(undefined)
); );
return combineLatest([ return combineLatest([
@@ -144,21 +140,17 @@ export class TicketsService {
}), }),
retry({ delay: 1000, count: 3 }), retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData, updatedData ? updatedData : firstData
),
map(
(response) =>
response.data
?.ticketStateMeta as GetTicketStateMetaQuery['ticketStateMeta'],
), ),
map((response) => response.data.ticketStateMeta)
); );
} }
fetchTicketStateKpi( fetchTicketStateKpi(
state: TicketValidationState, state: TicketValidationState
): Observable<TicketStateKpiQuery['ticketStateKpi']> { ): Observable<TicketStateKpiQuery['ticketStateKpi']> {
const ticketStateKpiQuery = this.ticketStateKpiGql.watch({ const ticketStateKpiQuery = this.ticketStateKpiGql.watch({
variables: { state }, state,
}); });
const ticketsUpdate = combineLatest([ const ticketsUpdate = combineLatest([
@@ -166,7 +158,7 @@ export class TicketsService {
this.updateTickets$, this.updateTickets$,
]).pipe( ]).pipe(
switchMap(() => ticketStateKpiQuery.refetch()), switchMap(() => ticketStateKpiQuery.refetch()),
startWith(undefined), startWith(undefined)
); );
return combineLatest([ return combineLatest([
@@ -179,45 +171,36 @@ export class TicketsService {
}), }),
retry({ delay: 1000, count: 3 }), retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) => map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData, updatedData ? updatedData : firstData
),
map(
(response) =>
response.data
?.ticketStateKpi as TicketStateKpiQuery['ticketStateKpi'],
), ),
map((response) => response.data.ticketStateKpi)
); );
} }
searchTickets( searchTickets(
query: string, query: string
): Observable<SearchTicketsQuery['searchTickets'] | undefined> { ): Observable<SearchTicketsQuery['searchTickets']> {
return this.searchTicketsGql return this.searchTicketsGql
.fetch({ variables: { query } }) .fetch({ query })
.pipe(map((response) => response.data?.searchTickets)); .pipe(map((response) => response.data.searchTickets));
} }
setDocumentInfo( setDocumentInfo(
ticketId: string, ticketId: string,
{ { approval, tdLocation }: Omit<SetDocumentInfoMutationVariables, 'ticketId'>
approval,
tdLocation,
}: Omit<SetDocumentInfoMutationVariables, 'ticketId'>,
) { ) {
if ((approval as any)?.['__typename']) if ((approval as any)?.['__typename'])
delete (approval as any)['__typename']; delete (approval as any)['__typename'];
return this.setDocumentInfoGql return this.setDocumentInfoGql
.mutate({ .mutate({
variables: {
ticketId, ticketId,
approval, approval,
tdLocation, tdLocation,
},
}) })
.pipe( .pipe(
map((res) => res.data?.setDocumentInfo), map((res) => res.data?.setDocumentInfo),
tap(() => this.updateTickets$.next()), tap(() => this.updateTickets$.next())
); );
} }
} }
@@ -15,54 +15,3 @@
<span>{{ ticket.tourCount }} Tour{{ ticket.tourCount > 1 ? "en" : "" }}</span> <span>{{ ticket.tourCount }} Tour{{ ticket.tourCount > 1 ? "en" : "" }}</span>
<span class="text-xs self-end">{{ getDistance(ticket.createdAt) }}</span> <span class="text-xs self-end">{{ getDistance(ticket.createdAt) }}</span>
} }
<!-- <div
class="group relative flex flex-col gap-1.5 rounded-lg border bg-card p-3 shadow-sm transition-all hover:bg-accent/5 hover:shadow-md cursor-pointer select-none"
[class.border-destructive]="ticket().isOverdue"
[class.bg-destructive\/5]="ticket().isOverdue"
[class.border-border]="!ticket().isOverdue"
>
<div class="flex items-start justify-between gap-3">
<span
class="text-sm font-semibold leading-tight text-foreground line-clamp-2"
[class.text-destructive]="ticket().isOverdue"
>
{{ ticket().patientName }}
</span>
</div>
<div class="flex items-center justify-between mt-1">
<div class="flex items-center gap-1.5 text-xs">
@if (ticket().nextStart) {
<div
class="flex items-center gap-1.5 transition-colors"
[class.text-destructive]="ticket().isOverdue"
[class.font-medium]="ticket().isOverdue"
[class.text-muted-foreground]="!ticket().isOverdue"
>
<ng-icon
hlmIcon
[name]="ticket().isOverdue ? 'lucideAlertCircle' : 'lucideCalendarClock'"
size="xs"
/>
<span>{{ ticket().nextStart | date:'dd.MM. HH:mm' }}</span>
</div>
} @else {
<span class="text-muted-foreground/50 italic text-[10px]">Bereits gefahren</span>
}
</div>
<div
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors"
[class.bg-destructive\/10]="ticket().isOverdue"
[class.text-destructive]="ticket().isOverdue"
[class.bg-secondary]="!ticket().isOverdue"
[class.text-secondary-foreground]="!ticket().isOverdue"
>
<ng-icon hlmIcon name="lucideCar" size="xs" />
<span>{{ ticket().tourCount }}</span>
</div>
</div>
</div> -->
@@ -20,8 +20,7 @@ import CategoryColors from './category-colors';
import { TicketListItemDto } from '@avicenna/shared-dtos'; import { TicketListItemDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { HlmIcon } from '@spartan-ng/helm/icon'; import { HlmIcon } from '@spartan-ng/helm/icon';
import { lucideCalendarClock, lucideCar, lucideCircleAlert } from '@ng-icons/lucide'; import { lucideCircleAlert } from '@ng-icons/lucide';
import { DatePipe } from '@angular/common';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -39,11 +38,9 @@ import { DatePipe } from '@angular/common';
`, `,
], ],
providers: [provideIcons({ providers: [provideIcons({
lucideCircleAlert, lucideCircleAlert
lucideCalendarClock,
lucideCar
})], })],
imports: [NgIcon, HlmIcon, DatePipe], imports: [NgIcon, HlmIcon],
}) })
export class TicketItemComponent implements OnInit { export class TicketItemComponent implements OnInit {
hasActivity = input(false); hasActivity = input(false);
@@ -2,20 +2,17 @@
class="shadow" class="shadow"
[state]="ticketState" [state]="ticketState"
></dks-ticket-lane-header> ></dks-ticket-lane-header>
<div class="overflow-auto p-2 space-y-2"> <div class="flex-1 overflow-auto p-2 space-y-2">
@if (ticketsQuery.isPending()) { @if (ticketsQuery.isPending()) {
<p>Lade Tickets...</p> <p>Lade Tickets...</p>
} } @if (ticketsQuery.data(); as result) { @for (ticket of result.data; track
@if (ticketsQuery.data(); as result) { ticket.id) {
@for (ticket of result.data; track ticket.id) { <dks-ticket-item [ticket]="ticket" (click)="openTicketView(ticket)" />
<dks-ticket-item [ticket]="ticket" (click)="openTicketView(ticket)" class="mb-2"/> } @if (result.data.length === 0) {
}
@if (result.data.length === 0) {
<div class="text-center p-4 text-muted-foreground"> <div class="text-center p-4 text-muted-foreground">
Keine Tickets in dieser Phase. Keine Tickets in dieser Phase.
</div> </div>
} } }
}
</div> </div>
<nav hlmPagination class="mt-auto border-t p-2"> <nav hlmPagination class="mt-auto border-t p-2">
@@ -29,9 +26,7 @@
<li hlmPaginationItem> <li hlmPaginationItem>
<span class="text-sm px-4"> <span class="text-sm px-4">
Seite {{ page() }} Seite {{ page() }} @if (ticketsQuery.data(); as res) { von
@if (ticketsQuery.data(); as res) {
von
{{ res.meta.totalPages }} {{ res.meta.totalPages }}
} }
</span> </span>
@@ -1,31 +1,39 @@
import { import {
ScrollingModule CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling'; } from '@angular/cdk/scrolling';
import { AsyncPipe } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
Input, Input,
ViewChild,
computed, computed,
inject, inject,
input, input,
output, output,
signal signal,
} from '@angular/core'; } from '@angular/core';
import { PaginatedResult, TicketListItemDto } from '@avicenna/shared-dtos'; import { DialogService, DialogCloseDirective } from '@ngneat/dialog';
import { DialogService } from '@ngneat/dialog';
import { QueryClient } from '@tanstack/angular-query-experimental';
import { BehaviorSubject, tap } from 'rxjs'; import { BehaviorSubject, tap } from 'rxjs';
import { HlmPaginationImports } from 'src/app/core/base-components/pagination/src'; import { HlmPaginationImports } from 'src/app/core/base-components/pagination/src';
import { ticketKeys } from 'src/app/core/tickets/ticket.keys'; import { HlmScrollArea } from 'src/app/core/base-components/scroll-area/src';
import { useTicketLane } from 'src/app/core/tickets/tickets.queries'; import { useTicketLane } from 'src/app/core/tickets/tickets.queries';
import { import {
Ticket, Ticket,
TicketValidationState TicketFilterArgs,
TicketValidationState,
Tour,
} from '../../../core/data-access/graphql/generated/generated'; } from '../../../core/data-access/graphql/generated/generated';
import { ActivityService } from '../../../core/services/activity.service'; import { ActivityService } from '../../../core/services/activity.service';
import { TicketItemComponent } from '../ticket-item/ticket-item.component'; import { TicketItemComponent } from '../ticket-item/ticket-item.component';
import { TicketViewComponent } from '../ticket-view/ticket-view.component'; import { TicketViewComponent } from '../ticket-view/ticket-view.component';
import { TicketsService } from '../tickets.service';
import { TicketLaneHeaderComponent } from './ticket-lane-header.component'; import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
import { PaginatedResult, TicketListItemDto } from '@avicenna/shared-dtos';
import { QueryClient } from '@tanstack/angular-query-experimental';
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -39,15 +47,18 @@ import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
:host { :host {
box-shadow: var(--mat-sys-level1); box-shadow: var(--mat-sys-level1);
background-color: var(--mat-sys-surface-container-low); background-color: var(--mat-sys-surface-container-low);
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr auto;
} }
`, `,
], ],
imports: [ imports: [
TicketLaneHeaderComponent, TicketLaneHeaderComponent,
TicketItemComponent, TicketItemComponent,
AsyncPipe,
ScrollingModule, ScrollingModule,
HlmScrollArea,
HlmPaginationImports, HlmPaginationImports,
DialogCloseDirective,
], ],
}) })
export class TicketLaneComponent { export class TicketLaneComponent {
@@ -60,7 +71,7 @@ export class TicketLaneComponent {
ticketsQuery = useTicketLane(this.stage, this.page); ticketsQuery = useTicketLane(this.stage, this.page);
hasNextPage = computed(() => { hasNextPage = computed(() => {
const data: PaginatedResult<TicketListItemDto> | undefined = this.ticketsQuery.data(); const data: PaginatedResult<TicketListItemDto> = this.ticketsQuery.data();
if (!data) return false; if (!data) return false;
return this.page() < data.meta.totalPages; return this.page() < data.meta.totalPages;
}); });
@@ -26,14 +26,14 @@ export class TicketStateInfoCardComponent {
constructor() { constructor() {
effect(() => { effect(() => {
this.isLoading.set(true); this.isLoading.set(true);
// this.service this.service
// .fetchTicketStateKpi(this.ticketState()) .fetchTicketStateKpi(this.ticketState())
// .pipe( .pipe(
// tap((res) => this.metrics.set(res.additional)), tap((res) => this.metrics.set(res.additional)),
// tap((res) => this.kpi.set(res)), tap((res) => this.kpi.set(res)),
// tap(() => this.isLoading.set(false)) tap(() => this.isLoading.set(false))
// ) )
// .subscribe(); .subscribe();
}); });
} }
@@ -6,7 +6,6 @@ import {
inject, inject,
input, input,
output, output,
signal,
} from '@angular/core'; } from '@angular/core';
import { TicketDetailDto } from '@avicenna/shared-dtos'; import { TicketDetailDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -24,8 +23,6 @@ import {
lucideMapPin, lucideMapPin,
lucideRepeat, lucideRepeat,
lucideShieldCheck, lucideShieldCheck,
lucideSquare,
lucideSquareCheck,
lucideStethoscope, lucideStethoscope,
lucideTriangleAlert, lucideTriangleAlert,
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
@@ -44,10 +41,6 @@ import { environment } from 'src/environments/environment';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { TicketApprovalComponent } from 'src/app/core/tickets/components/ticket-approval-component/ticket-approval.component'; import { TicketApprovalComponent } from 'src/app/core/tickets/components/ticket-approval-component/ticket-approval.component';
import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components/ticket-validation-errors/ticket-validation-errors.component'; import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components/ticket-validation-errors/ticket-validation-errors.component';
import { TicketActionsService } from 'src/app/core/tickets/ticket-actions.service';
import { HotToastService } from '@ngxpert/hot-toast';
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
import { QueryClient } from '@tanstack/angular-query-experimental';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -176,9 +169,9 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
<section <section
hlmCard hlmCard
class="p-4 flex flex-col justify-between" class="p-4 flex flex-col justify-between"
[class.border-l-4]="!ticket().transportDocument.isDocumentPresent" [class.border-l-4]="!ticket().transportDocument.isUploaded"
[class.border-l-destructive]=" [class.border-l-destructive]="
!ticket().transportDocument.isDocumentPresent !ticket().transportDocument.isUploaded
" "
> >
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
@@ -198,23 +191,27 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
<p <p
class="text-xs" class="text-xs"
[class.text-destructive]=" [class.text-destructive]="
!ticket().transportDocument.isDocumentPresent !ticket().transportDocument.isUploaded
" "
[class.text-muted-foreground]=" [class.text-muted-foreground]="
ticket().transportDocument.isDocumentPresent ticket().transportDocument.isUploaded
" "
> >
{{ ticket().transportDocument.label }} @if (ticket().transportDocument.isUploaded) {
Bereits hochgeladen
} @else {
Dokument erforderlich
}
</p> </p>
</div> </div>
</div> </div>
@if (ticket().transportDocument.digitalDownloadLink) { @if (ticket().transportDocument.isUploaded) {
<a <a
hlmBtn hlmBtn
size="icon" size="icon"
variant="ghost" variant="ghost"
[href]="ticket().transportDocument.digitalDownloadLink" [href]="ticket().transportDocument.link"
target="_blank" target="_blank"
class="text-green-600 hover:bg-green-50" class="text-green-600 hover:bg-green-50"
> >
@@ -223,26 +220,14 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
} }
</div> </div>
@if (!ticket().transportDocument.isUploaded) {
<div class="mt-2 pt-2 border-t border-border/50"> <div class="mt-2 pt-2 border-t border-border/50">
@if (ticket().transportDocument.actions.receivePhysical.isPossible) {
<button
hlmBtn
variant="outline"
class="w-full gap-2 text-green-700 hover:text-green-800 hover:bg-green-50 border-green-200 mb-4"
(click)="receivePhysical()"
[disabled]="isSubmitting()"
>
<ng-icon hlmIcon name="lucideSquareCheck" size="xs" />
{{ ticket().transportDocument.actions.receivePhysical.label }}
</button>
}
@if (ticket().transportDocument.actions.upload.isPossible) {
<app-transport-document-upload <app-transport-document-upload
[ticketId]="ticket().id" [ticketId]="ticket().id"
(uploaded)="uploadedAction.emit()" (uploaded)="uploadedAction.emit()"
/> />
}
</div> </div>
}
</section> </section>
<app-ticket-approval <app-ticket-approval
[ticketId]="ticket().id" [ticketId]="ticket().id"
@@ -328,7 +313,7 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
lucideShieldCheck, lucideShieldCheck,
lucideCalendarClock, lucideCalendarClock,
lucideBiohazard, lucideBiohazard,
lucideSquareCheck,
lucideArrowRight, lucideArrowRight,
lucideCalendar, lucideCalendar,
lucideMapPin, lucideMapPin,
@@ -356,13 +341,9 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
], ],
}) })
export class TicketOverviewComponent { export class TicketOverviewComponent {
private toast = inject(HotToastService); http = inject(HttpClient);
private queryClient = inject(QueryClient);
private actions = inject(TicketActionsService);
ticket = input.required<TicketDetailDto>(); ticket = input.required<TicketDetailDto>();
uploadedAction = output<void>({ alias: 'uploaded' }); uploadedAction = output<void>({ alias: 'uploaded' });
isSubmitting = signal(false);
ApprovalState = ApprovalState; ApprovalState = ApprovalState;
stageVariant = computed(() => { stageVariant = computed(() => {
@@ -376,24 +357,18 @@ export class TicketOverviewComponent {
} }
}); });
async receivePhysical() { sendApprovalRequest() {
this.isSubmitting.set(true); console.log(`approval request not implemented`);
try {
await this.actions.receivePhysicalTransportDocument(this.ticket().id);
this.toast.success('Eingang des Transportscheins erfasst');
this.refreshData();
} catch (error) {
this.toast.error('Fehler beim Sendern der Anfrage');
console.error(error);
} finally {
this.isSubmitting.set(false);
}
} }
private refreshData(): void { overrideApprovalRequirement() {
this.uploadedAction.emit(); console.log(`request not implemented`);
this.queryClient.invalidateQueries({
queryKey: ticketKeys.lists(), const url = `${environment.api.baseUrl}/tickets/${
}); this.ticket().id
}/approval/override`;
lastValueFrom(this.http.post(url, {})).then(() =>
this.uploadedAction.emit(),
);
} }
} }
@@ -1,5 +1,5 @@
@if (ticketQuery.data(); as ticket) { @if (ticketQuery.data(); as ticket) {
<h3 class="py-2 px-3 mb-0!"> <h3 class="py-2 px-3 !mb-0">
{{ ticket.patient.name}} {{ ticket.patient.name}}
</h3> </h3>
} }
@@ -11,7 +11,7 @@
<button hlmTabsTrigger="history">Verlauf</button> <button hlmTabsTrigger="history">Verlauf</button>
</hlm-tabs-list> --> </hlm-tabs-list> -->
<div hlmTabsContent="overview" class="overflow-y-auto max-h-[calc(85vh-8rem)] pr-1"> <div hlmTabsContent="overview">
@if (ticketQuery.data(); as ticket) { @if (ticketQuery.data(); as ticket) {
<dks-ticket-overview [ticket]="ticket" (uploaded)="refreshTicket()"/> <dks-ticket-overview [ticket]="ticket" (uploaded)="refreshTicket()"/>
} }
@@ -4,12 +4,18 @@ import { MatIconModule } from '@angular/material/icon';
import { DialogService } from '@ngneat/dialog'; import { DialogService } from '@ngneat/dialog';
import { TicketValidationState } from '../../core/data-access/graphql/generated/generated'; import { TicketValidationState } from '../../core/data-access/graphql/generated/generated';
import { TicketLaneComponent } from './ticket-lane/ticket-lane.component'; import { TicketLaneComponent } from './ticket-lane/ticket-lane.component';
import { TicketStateInfoCardComponent } from './ticket-state-info-card/ticket-state-info-card.component';
import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-search-dialog.component'; import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-search-dialog.component';
import { BrnAlertDialogImports } from '@spartan-ng/brain/alert-dialog';
import { HlmAlertDialogImports } from '@spartan-ng/helm/alert-dialog';
import { HlmButton } from '@spartan-ng/helm/button';
import { BrnDialogImports } from '@spartan-ng/brain/dialog';
import { HlmDialogImports } from '@spartan-ng/helm/dialog';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-tickets-page', selector: 'dks-tickets-page',
template: `<section class="h-full flex flex-col p-4 md:p-6 gap-4 md:gap-6"> template: `<section class="h-full p-6 grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="col-span-1 md:col-span-4 flex justify-end items-center"> <div class="col-span-1 md:col-span-4 flex justify-end items-center">
<button mat-icon-button (click)="showSearchDialog()"> <button mat-icon-button (click)="showSearchDialog()">
<mat-icon>search</mat-icon> <mat-icon>search</mat-icon>
@@ -27,45 +33,26 @@ import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-se
<app-ticket-state-info-card <app-ticket-state-info-card
[ticketState]="TicketState.DocumentsMissing" [ticketState]="TicketState.DocumentsMissing"
></app-ticket-state-info-card> --> ></app-ticket-state-info-card> -->
<div
class="
flex-1
min-h-0
flex
flex-row
overflow-x-auto
snap-x
snap-mandatory
gap-4
pb-4
md:grid
md:grid-cols-4
md:overflow-visible
md:pb-0
"
>
<dks-ticket-lane <dks-ticket-lane
[stage]="'DATA_INCOMPLETE'" [stage]="'DATA_INCOMPLETE'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto" class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.Created" [ticketState]="TicketState.Created"
></dks-ticket-lane> ></dks-ticket-lane>
<dks-ticket-lane <dks-ticket-lane
[stage]="'ASSESSMENT'" [stage]="'ASSESSMENT'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto" class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.UserInteractionRequired" [ticketState]="TicketState.UserInteractionRequired"
></dks-ticket-lane> ></dks-ticket-lane>
<dks-ticket-lane <dks-ticket-lane
[stage]="'DOCS_PENDING'" [stage]="'DOCS_PENDING'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto" class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.TdUncertain" [ticketState]="TicketState.TdUncertain"
></dks-ticket-lane> ></dks-ticket-lane>
<dks-ticket-lane <dks-ticket-lane
[stage]="'APPROVAL'" [stage]="'APPROVAL'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto" class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.DocumentsMissing" [ticketState]="TicketState.DocumentsMissing"
></dks-ticket-lane> ></dks-ticket-lane>
<div class="w-1 flex-none md:hidden"></div>
</div>
</section>`, </section>`,
styles: [ styles: [
` `
@@ -78,7 +65,13 @@ import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-se
} }
`, `,
], ],
imports: [TicketLaneComponent, MatButtonModule, MatIconModule], imports: [
TicketStateInfoCardComponent,
TicketLaneComponent,
MatButtonModule,
MatIconModule,
HlmButton,
],
}) })
export class TicketsPageComponent { export class TicketsPageComponent {
private dialog = inject(DialogService); private dialog = inject(DialogService);
@@ -1,11 +1,6 @@
import { AsyncPipe, DatePipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
ChangeDetectionStrategy, import { FormControl, ReactiveFormsModule } from '@angular/forms';
Component,
inject,
signal,
} from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { DialogService } from '@ngneat/dialog'; import { DialogService } from '@ngneat/dialog';
@@ -23,14 +18,6 @@ import {
import { TicketViewComponent } from '../ticket-view/ticket-view.component'; import { TicketViewComponent } from '../ticket-view/ticket-view.component';
import { TicketsService } from '../tickets.service'; import { TicketsService } from '../tickets.service';
import { format, isAfter } from 'date-fns'; import { format, isAfter } from 'date-fns';
import { useTicketSearch } from 'src/app/core/tickets/tickets.queries';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { HlmIcon } from '@spartan-ng/helm/icon';
import {
lucideCircleAlert,
lucideCalendarClock,
lucideCar,
} from '@ng-icons/lucide';
@Component({ @Component({
template: `<div class="flex flex-col items-center justify-start p-4"> template: `<div class="flex flex-col items-center justify-start p-4">
@@ -38,71 +25,12 @@ import {
<hr /> <hr />
<mat-form-field class="w-full"> <mat-form-field class="w-full">
<mat-label>Einsatznummer oder Patient</mat-label> <mat-label>Einsatznummer oder Patient</mat-label>
<input class="w-full" matInput [(ngModel)]="search" name="search" /> <input class="w-full" matInput [formControl]="searchCtrl" name="search" />
</mat-form-field> </mat-form-field>
<hr /> <hr />
<div class="w-full"> <div class="w-full">
@if (searchQuery.isLoading()) { @for (ticket of tickets$ | async; track ticket) {
<p>Suche Tickets ...</p>
}
@if (searchQuery.data(); as result) {
@for (ticket of result; track ticket.id) {
<div <div
class="group relative flex flex-col gap-1.5 rounded-lg border bg-card p-3 shadow-sm transition-all hover:bg-accent/5 hover:shadow-md cursor-pointer select-none mb-2"
[class.border-destructive]="ticket.isOverdue"
[class.bg-destructive/5]="ticket.isOverdue"
[class.border-border]="!ticket.isOverdue"
(click)="showTicketView(ticket)"
>
<div class="flex items-start justify-between gap-3">
<span
class="text-sm font-semibold leading-tight text-foreground line-clamp-2"
[class.text-destructive]="ticket.isOverdue"
>
{{ ticket.patientName }}
</span>
</div>
<div class="flex items-center justify-between mt-1">
<div class="flex items-center gap-1.5 text-xs">
@if (ticket.nextStart) {
<div
class="flex items-center gap-1.5 transition-colors"
[class.text-destructive]="ticket.isOverdue"
[class.font-medium]="ticket.isOverdue"
[class.text-muted-foreground]="!ticket.isOverdue"
>
<ng-icon
hlmIcon
[name]="
ticket.isOverdue
? 'lucideAlertCircle'
: 'lucideCalendarClock'
"
size="xs"
/>
<span>{{ ticket.nextStart | date: 'dd.MM. HH:mm' }}</span>
</div>
} @else {
<span class="text-muted-foreground/50 italic text-[10px]"
>Bereits gefahren</span
>
}
</div>
<div
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors"
[class.bg-destructive/10]="ticket.isOverdue"
[class.text-destructive]="ticket.isOverdue"
[class.bg-secondary]="!ticket.isOverdue"
[class.text-secondary-foreground]="!ticket.isOverdue"
>
<ng-icon hlmIcon name="lucideCar" size="xs" />
<span>{{ ticket.tourCount }}</span>
</div>
</div>
</div>
<!-- <div
class="flex flex-col justify-between items-center py-2 border-b border-gray-200 cursor-pointer" class="flex flex-col justify-between items-center py-2 border-b border-gray-200 cursor-pointer"
(click)="showTicketView(ticket)" (click)="showTicketView(ticket)"
> >
@@ -115,6 +43,7 @@ import {
</span> </span>
} }
</span> </span>
<!-- <span class="text-sm text-gray-600">{{ getNextStart(ticket.tours) }}</span> -->
</div> </div>
<div class="w-full flex justify-between"> <div class="w-full flex justify-between">
<span> <span>
@@ -127,32 +56,14 @@ import {
}} }}
</span> </span>
</div> </div>
</div> --> </div>
}
} }
</div> </div>
</div>`, </div>`,
imports: [ imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, AsyncPipe],
MatFormFieldModule,
MatInputModule,
FormsModule,
NgIcon,
HlmIcon,
DatePipe,
],
providers: [
provideIcons({
lucideCircleAlert,
lucideCalendarClock,
lucideCar,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class TicketsSearchDialogComponent { export class TicketsSearchDialogComponent {
search = signal<string>('');
searchQuery = useTicketSearch(this.search);
private ticketsService = inject(TicketsService); private ticketsService = inject(TicketsService);
private dialog = inject(DialogService); private dialog = inject(DialogService);
@@ -161,13 +72,13 @@ export class TicketsSearchDialogComponent {
debounceTime(200), debounceTime(200),
distinctUntilChanged(), distinctUntilChanged(),
filter((val) => !!val && val?.length > 2), filter((val) => !!val && val?.length > 2),
switchMap((query) => this.ticketsService.searchTickets(query as string)), switchMap((query) => this.ticketsService.searchTickets(query as string))
); );
showTicketView(ticket: unknown): void { showTicketView(ticket: unknown): void {
const dialogRef = this.dialog.open(TicketViewComponent, { const dialogRef = this.dialog.open(TicketViewComponent, {
data: (ticket as Ticket)?.id as string, data: (ticket as Ticket)?.id as string,
width: '60vw', width: '40vw',
minHeight: '400px', minHeight: '400px',
}); });
@@ -178,21 +89,19 @@ export class TicketsSearchDialogComponent {
getToursLength(ticket: unknown) { getToursLength(ticket: unknown) {
return (ticket as Ticket & { tours: Tour[] }).tours.filter( return (ticket as Ticket & { tours: Tour[] }).tours.filter(
({ check }) => check === 0, ({ check }) => check === 0
).length; ).length;
} }
getNextStart(tours: Partial<Tour>[]) { getNextStart(tours: Partial<Tour>[]) {
const sortedStartDates = [ const sortedStartDates = [...tours.map(({startDate}) => new Date(startDate))].sort((a, b) => a.getTime() - b.getTime())
...tours.map(({ startDate }) => new Date(startDate)), for(let i = 0; i < sortedStartDates.length; i++) {
].sort((a, b) => a.getTime() - b.getTime()); const currentDate = sortedStartDates.at(i)
for (let i = 0; i < sortedStartDates.length; i++) { if(currentDate && isAfter(currentDate, new Date())) {
const currentDate = sortedStartDates.at(i); return format(currentDate, 'HH:mm | dd.MM.yy')
if (currentDate && isAfter(currentDate, new Date())) {
return format(currentDate, 'HH:mm | dd.MM.yy');
} }
} }
const latestDate = sortedStartDates.at(-1); const latestDate = sortedStartDates.at(-1);
return latestDate ? format(latestDate, 'HH:mm | dd.MM.yy') : 'No Tours'; return latestDate ? format(latestDate, 'HH:mm | dd.MM.yy') : 'No Tours'
} }
} }
@@ -1,17 +1,33 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HotToastService } from '@ngxpert/hot-toast'; import { HotToastService } from '@ngxpert/hot-toast';
import { import {
catchError,
combineLatest,
map, map,
Observable, Observable,
retry,
shareReplay, shareReplay,
startWith, startWith,
Subject, Subject,
switchMap,
tap, tap,
throwError,
timer, timer,
} from 'rxjs'; } from 'rxjs';
import { import {
GetTicketsGQL,
GetTicketsQuery,
GetTicketStateMetaGQL,
GetTicketStateMetaQuery,
SearchTicketsGQL, SearchTicketsGQL,
SearchTicketsQuery, SearchTicketsQuery,
SendApprovalRequestGQL,
SetDocumentInfoGQL,
SetDocumentInfoMutationVariables,
TicketFilterArgs,
TicketStateKpiGQL,
TicketStateKpiQuery,
TicketValidationState,
} from '../../core/data-access/graphql/generated/generated'; } from '../../core/data-access/graphql/generated/generated';
@Injectable({ @Injectable({
@@ -27,25 +43,164 @@ export class TicketsService {
} }
}), }),
shareReplay(1), shareReplay(1),
startWith(undefined), startWith(undefined)
); );
updateTickets$ = new Subject<void>(); updateTickets$ = new Subject<void>();
constructor( constructor(
private readonly getTicketsGql: GetTicketsGQL,
private readonly getTicketStateMetaGql: GetTicketStateMetaGQL,
private readonly searchTicketsGql: SearchTicketsGQL, private readonly searchTicketsGql: SearchTicketsGQL,
private readonly toast: HotToastService, 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,
filters: TicketFilterArgs,
take = 20
): Observable<GetTicketsQuery['tickets']> {
const ticketsQuery = this.getTicketsGql.watch({
state,
cursor,
filters,
take,
});
const ticketsUpdate = combineLatest([
this.ticketsUpdated$,
this.updateTickets$,
]).pipe(
switchMap(() => ticketsQuery.refetch()),
startWith(undefined)
);
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
catchError((error) => {
console.error(error);
return throwError(() => error);
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
),
map((response) => response.data.tickets)
);
}
fetchTicketStateMeta(
state: TicketValidationState
): Observable<GetTicketStateMetaQuery['ticketStateMeta']> {
const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({
state,
});
const ticketsUpdate = combineLatest([
this.ticketsUpdated$,
this.updateTickets$,
]).pipe(
switchMap(() => ticketStateMetaQuery.refetch()),
startWith(undefined)
);
return combineLatest([
ticketsUpdate,
ticketStateMetaQuery.valueChanges,
]).pipe(
catchError((error) => {
console.error(error);
return throwError(() => error);
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
),
map((response) => response.data.ticketStateMeta)
);
}
fetchTicketStateKpi(
state: TicketValidationState
): Observable<TicketStateKpiQuery['ticketStateKpi']> {
const ticketStateKpiQuery = this.ticketStateKpiGql.watch({
state,
});
const ticketsUpdate = combineLatest([
this.ticketsUpdated$,
this.updateTickets$,
]).pipe(
switchMap(() => ticketStateKpiQuery.refetch()),
startWith(undefined)
);
return combineLatest([
ticketsUpdate,
ticketStateKpiQuery.valueChanges,
]).pipe(
catchError((error) => {
console.error(error);
return throwError(() => error);
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
),
map((response) => response.data.ticketStateKpi)
);
}
searchTickets( searchTickets(
query: string, query: string
): Observable<SearchTicketsQuery['searchTickets'] | undefined> { ): Observable<SearchTicketsQuery['searchTickets']> {
return this.searchTicketsGql return this.searchTicketsGql
.fetch({ .fetch({ query })
variables: { .pipe(map((response) => response.data.searchTickets));
query, }
},
setDocumentInfo(
ticketId: string,
{ approval, tdLocation }: Omit<SetDocumentInfoMutationVariables, 'ticketId'>
) {
if ((approval as any)?.['__typename'])
delete (approval as any)['__typename'];
return this.setDocumentInfoGql
.mutate({
ticketId,
approval,
tdLocation,
}) })
.pipe(map((response) => response.data?.searchTickets)); .pipe(
map((res) => res.data?.setDocumentInfo),
tap(() => this.updateTickets$.next())
);
} }
} }
+8 -4
View File
@@ -5,7 +5,6 @@
"strict": true, "strict": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
"useDefineForClassFields": false,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -17,8 +16,16 @@
"importHelpers": true, "importHelpers": true,
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"lib": [
"ES2022",
"dom",
"esnext.asynciterable"
],
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@angular/*": [
"../../node_modules/@angular/*"
],
"@tanstack/*": [ "@tanstack/*": [
"../../node_modules/@tanstack/*" "../../node_modules/@tanstack/*"
], ],
@@ -81,9 +88,6 @@
], ],
"@spartan-ng/helm/textarea": [ "@spartan-ng/helm/textarea": [
"./src/app/core/base-components/textarea/src/index.ts" "./src/app/core/base-components/textarea/src/index.ts"
],
"@spartan-ng/helm/skeleton": [
"./src/app/core/base-components/skeleton/src/index.ts"
] ]
} }
}, },
+1 -2
View File
@@ -6,7 +6,7 @@ COPY package*.json .
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/ COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
COPY data-hub/data-connector/package*.json ./data-hub/data-connector/ COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
RUN npm ci --verbose RUN npm ci
COPY packages/shared-dtos ./packages/shared-dtos/ COPY packages/shared-dtos ./packages/shared-dtos/
COPY data-hub/data-connector ./data-hub/data-connector/ COPY data-hub/data-connector ./data-hub/data-connector/
@@ -33,7 +33,6 @@ FROM node:20.15.1-alpine
WORKDIR /app WORKDIR /app
COPY --from=builder /app/builder/node_modules ./node_modules COPY --from=builder /app/builder/node_modules ./node_modules
COPY --from=builder /app/builder/packages ./packages
COPY --from=builder /app/builder/data-hub/data-connector/prisma ./prisma COPY --from=builder /app/builder/data-hub/data-connector/prisma ./prisma
COPY --from=builder /app/builder/data-hub/data-connector/dist ./dist COPY --from=builder /app/builder/data-hub/data-connector/dist ./dist
COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh . COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh .
+1 -3
View File
@@ -24,8 +24,7 @@
"seed": "ts-node prisma/seed/seed.ts" "seed": "ts-node prisma/seed/seed.ts"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^5.0.0", "@apollo/server": "^4.12.0",
"@as-integrations/express5": "^1.1.2",
"@avicenna/shared-dtos": "^1.0.0", "@avicenna/shared-dtos": "^1.0.0",
"@faker-js/faker": "^9.6.0", "@faker-js/faker": "^9.6.0",
"@hyperdx/node-logger": "^0.4.0", "@hyperdx/node-logger": "^0.4.0",
@@ -41,7 +40,6 @@
"@nestjs/graphql": "^13.0.4", "@nestjs/graphql": "^13.0.4",
"@nestjs/platform-express": "^11.0.15", "@nestjs/platform-express": "^11.0.15",
"@nestjs/schedule": "^5.0.1", "@nestjs/schedule": "^5.0.1",
"@nestjs/swagger": "^11.2.5",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.60.0", "@opentelemetry/auto-instrumentations-node": "^0.60.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0", "@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
@@ -2,6 +2,9 @@
Warnings: Warnings:
- A unique constraint covering the columns `[tourId,ticketId]` on the table `TourFile` will be added. If there are existing duplicate values, this will fail. - A unique constraint covering the columns `[tourId,ticketId]` on the table `TourFile` will be added. If there are existing duplicate values, this will fail.
- Added the required column `approvalRequirement` to the `Ticket` table without a default value. This is not possible if the table is not empty.
- Added the required column `approvalStatus` to the `Ticket` table without a default value. This is not possible if the table is not empty.
- Added the required column `stage` to the `Ticket` table without a default value. This is not possible if the table is not empty.
*/ */
-- DropIndex -- DropIndex
@@ -22,14 +25,14 @@ ADD COLUMN "approvalIsOverriden" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3), ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3),
ADD COLUMN "approvalPreInquiryMessageId" TEXT, ADD COLUMN "approvalPreInquiryMessageId" TEXT,
ADD COLUMN "approvalRecipient" TEXT, ADD COLUMN "approvalRecipient" TEXT,
ADD COLUMN "approvalReminderCount" INTEGER DEFAULT 0, ADD COLUMN "approvalReminderCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "approvalRequestedAt" TIMESTAMP(3), ADD COLUMN "approvalRequestedAt" TIMESTAMP(3),
ADD COLUMN "approvalRequirement" TEXT DEFAULT 'UNKNOWN', ADD COLUMN "approvalRequirement" TEXT NOT NULL,
ADD COLUMN "approvalStatus" TEXT DEFAULT 'NOT_STARTED', ADD COLUMN "approvalStatus" TEXT NOT NULL,
ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false, ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false, ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "patientId" UUID, ADD COLUMN "patientId" UUID,
ADD COLUMN "stage" TEXT DEFAULT 'DRAFT', ADD COLUMN "stage" TEXT NOT NULL,
ADD COLUMN "updatedAt" TIMESTAMP(3), ADD COLUMN "updatedAt" TIMESTAMP(3),
ALTER COLUMN "id" DROP DEFAULT, ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "currentState" DROP NOT NULL; ALTER COLUMN "currentState" DROP NOT NULL;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "lastStageBeforeClosed" TEXT;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "optimizationInMinutes" INTEGER NOT NULL DEFAULT 0;
@@ -422,7 +422,6 @@ model Tour {
createdAt DateTime? createdAt DateTime?
updatedAt DateTime? updatedAt DateTime?
deletedAt DateTime? deletedAt DateTime?
optimizationInMinutes Int @default(0)
Ticket Ticket? @relation(fields: [ticketId], references: [id]) Ticket Ticket? @relation(fields: [ticketId], references: [id])
ticketId String? @db.Uuid ticketId String? @db.Uuid
} }
@@ -489,13 +488,13 @@ enum ApprovalState {
model Ticket { model Ticket {
id String @id @db.Uuid id String @id @db.Uuid
patientId String? @db.Uuid patientId String? @db.Uuid
stage String? @default("DRAFT") stage String
approvalRequirement String? @default("UNKNOWN") approvalRequirement String
approvalStatus String? @default("NOT_STARTED") approvalStatus String
approvalRequestedAt DateTime? approvalRequestedAt DateTime?
approvalLastInteractionAt DateTime? approvalLastInteractionAt DateTime?
approvalReminderCount Int? @default(0) approvalReminderCount Int @default(0)
approvalRecipient String? approvalRecipient String?
approvalPreInquiryMessageId String? approvalPreInquiryMessageId String?
approvalFullRequestMessageId String? approvalFullRequestMessageId String?
@@ -503,7 +502,6 @@ model Ticket {
hasPhysicalTransportDocument Boolean @default(false) hasPhysicalTransportDocument Boolean @default(false)
hasDigitalTransportDocument Boolean @default(false) hasDigitalTransportDocument Boolean @default(false)
lastStageBeforeClosed String?
updatedAt DateTime? @updatedAt updatedAt DateTime? @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -25,7 +25,6 @@ import { FeatCalculationModule } from './modules/feat-calculation/feat-calculati
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module'; import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module'; import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module'; import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
import { AppPlaygroundModule } from './modules/app-playground/app-playground.module';
@Module({ @Module({
imports: [ imports: [
@@ -60,7 +59,7 @@ import { AppPlaygroundModule } from './modules/app-playground/app-playground.mod
EventEmitterModule.forRoot(), EventEmitterModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({ GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver, driver: ApolloDriver,
graphiql: true, playground: true,
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'), autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
sortSchema: true, sortSchema: true,
subscriptions: { subscriptions: {
@@ -78,7 +77,6 @@ import { AppPlaygroundModule } from './modules/app-playground/app-playground.mod
ApplicationControlCenterModule, ApplicationControlCenterModule,
ApplicationTicketSystemModule, ApplicationTicketSystemModule,
AppHomeModule, AppHomeModule,
AppPlaygroundModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, Logger], providers: [AppService, Logger],
@@ -24,6 +24,13 @@ export interface PrismaServiceOptions {
* See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management * See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management
*/ */
explicitConnect?: boolean; explicitConnect?: boolean;
/**
* Apply Prisma middlewares to perform actions before or after db queries.
*
* See: https://www.prisma.io/docs/concepts/components/prisma-client/middleware
*/
middlewares?: Array<Prisma.Middleware>;
} }
export interface PrismaOptionsFactory { export interface PrismaOptionsFactory {
@@ -31,6 +31,12 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
], ],
}); });
if (this.prismaServiceOptions.middlewares) {
this.prismaServiceOptions.middlewares.forEach((middleware) =>
this.$use(middleware),
);
}
/* /*
Sadly typescript does not recognize the Prisma Client configuration passed in the super call. Sadly typescript does not recognize the Prisma Client configuration passed in the super call.
As Prisma defines the events generically, based on the configuration, the event type, typescript inferred, is wrong. As Prisma defines the events generically, based on the configuration, the event type, typescript inferred, is wrong.
@@ -191,7 +191,7 @@ type PlannedTime {
type Query { type Query {
accountingStateMeta(state: AccountingState!): TourStateMeta! accountingStateMeta(state: AccountingState!): TourStateMeta!
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int): PaginatedTour! accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly! anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
anomaliesProto: [Anomaly!]! anomaliesProto: [Anomaly!]!
anomaly(id: String!): Anomaly! anomaly(id: String!): Anomaly!
@@ -303,19 +303,18 @@ export type TariffGroup = {
export type Ticket = { export type Ticket = {
id: string; id: string;
patientId: string | null; patientId: string | null;
stage: Generated<string | null>; stage: string;
approvalRequirement: Generated<string | null>; approvalRequirement: string;
approvalStatus: Generated<string | null>; approvalStatus: string;
approvalRequestedAt: Timestamp | null; approvalRequestedAt: Timestamp | null;
approvalLastInteractionAt: Timestamp | null; approvalLastInteractionAt: Timestamp | null;
approvalReminderCount: Generated<number | null>; approvalReminderCount: Generated<number>;
approvalRecipient: string | null; approvalRecipient: string | null;
approvalPreInquiryMessageId: string | null; approvalPreInquiryMessageId: string | null;
approvalFullRequestMessageId: string | null; approvalFullRequestMessageId: string | null;
approvalIsOverriden: Generated<boolean>; approvalIsOverriden: Generated<boolean>;
hasPhysicalTransportDocument: Generated<boolean>; hasPhysicalTransportDocument: Generated<boolean>;
hasDigitalTransportDocument: Generated<boolean>; hasDigitalTransportDocument: Generated<boolean>;
lastStageBeforeClosed: string | null;
updatedAt: Timestamp | null; updatedAt: Timestamp | null;
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
currentState: TicketValidationState | null; currentState: TicketValidationState | null;
@@ -387,7 +386,6 @@ export type Tour = {
createdAt: Timestamp | null; createdAt: Timestamp | null;
updatedAt: Timestamp | null; updatedAt: Timestamp | null;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
optimizationInMinutes: Generated<number>;
ticketId: string | null; ticketId: string | null;
}; };
export type TourFile = { export type TourFile = {
@@ -37,7 +37,7 @@ export class MailService {
return await this.mailerService.sendMail({ return await this.mailerService.sendMail({
to: recipient, to: recipient,
bcc: 'info@avicenna.hamburg', // bcc: 'info@avicenna.hamburg',
subject: `Antrag auf Kostenübernahme`, subject: `Antrag auf Kostenübernahme`,
template: 'genehmigungsanfrage', template: 'genehmigungsanfrage',
headers: this.getThreadingHeaders(replyToId), headers: this.getThreadingHeaders(replyToId),
@@ -62,7 +62,7 @@ export class MailService {
return await this.mailerService.sendMail({ return await this.mailerService.sendMail({
to: recipient, to: recipient,
bcc: 'info@avicenna.hamburg', // bcc: 'info@avicenna.hamburg',
headers: this.getThreadingHeaders(replyToId), headers: this.getThreadingHeaders(replyToId),
subject: `Antrag auf Kostenübernahme Transport am ${params.transportDate} für ${params.patientName}`, subject: `Antrag auf Kostenübernahme Transport am ${params.transportDate} für ${params.patientName}`,
template: 'genehmigung-vorab-anfrage', template: 'genehmigung-vorab-anfrage',
@@ -79,7 +79,6 @@ export class MailService {
return await this.mailerService.sendMail({ return await this.mailerService.sendMail({
to: recipient, to: recipient,
bcc: 'info@avicenna.hamburg',
subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading
template: 'genehmigung-reminder', template: 'genehmigung-reminder',
headers: this.getThreadingHeaders(replyToId), headers: this.getThreadingHeaders(replyToId),
@@ -1,7 +1,14 @@
import { ArgsType, Field, InputType } from '@nestjs/graphql'; import {
ArgsType,
Field,
Float,
InputType,
Int,
ObjectType,
} from '@nestjs/graphql';
import { PaginationArgs } from 'src/core/base/pagination.input'; import { PaginationArgs } from 'src/core/base/pagination.input';
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
import { AccountingState } from './accounting-state'; import { AccountingState } from './accounting-state';
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
@InputType() @InputType()
export class AccountingFilterArgs implements TourAccountingFilterDto { export class AccountingFilterArgs implements TourAccountingFilterDto {
@@ -20,3 +27,15 @@ export class AccountingTourArgs extends PaginationArgs {
@Field(() => AccountingFilterArgs, { nullable: true }) @Field(() => AccountingFilterArgs, { nullable: true })
filters!: AccountingFilterArgs; filters!: AccountingFilterArgs;
} }
@ObjectType('TourStateMeta')
export class TourStateMetaObjectType {
@Field(() => Int)
count!: number;
@Field(() => Float, { nullable: true })
revenueSum?: number;
@Field(() => Float, { nullable: true })
positiveRevenueDeviation?: number;
@Field(() => Float, { nullable: true })
negativeRevenueDeviation?: number;
}
@@ -1,64 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { AccountingState } from './accounting-state';
import {
AccountingListDto,
PaginatedResult,
TicketStage,
} from '@avicenna/shared-dtos';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { Tour } from '@prisma/client';
@Injectable()
export class AccountingQueryService {
constructor(private readonly tourService: TourService) {}
async toursByState(
state: AccountingState,
page: number,
limit: number = 10,
): Promise<PaginatedResult<AccountingListDto>> {
const offset = (page - 1) * limit;
const { tours, total } = await this.tourService.findByAccountingState(
state,
limit,
offset,
);
return {
data: tours.map((tour) => this.toAccountingListDto(tour)),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
private toAccountingListDto(
tour: Tour & { stage: string },
): AccountingListDto {
return {
tourId: tour.id,
operationId: tour.operationId,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
revenue: tour.revenue,
isTicketOpen: [
TicketStage.DRAFT,
TicketStage.DATA_INCOMPLETE,
TicketStage.ASSESSMENT_PENDING,
TicketStage.APPROVAL_IN_PROGRESS,
TicketStage.DOCUMENTS_PENDING,
].includes(tour.stage as TicketStage),
hasRevenueDeviation:
Math.abs(tour.revenue - tour.revenueDispoLive) > 0.01,
hasOptimizationPotential: tour.optimizationInMinutes !== 0,
};
}
}
@@ -1,22 +1,12 @@
import { import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
Args,
Int,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { TicketObjectType } from '../app-ticket-system/ticket.object-type'; import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
import { import {
AccountingFilterArgs AccountingTourArgs,
TourStateMetaObjectType,
} from './accounting-arg.types'; } from './accounting-arg.types';
import { AccountingState } from './accounting-state'; import { AccountingState } from './accounting-state';
import { AccountingValidationService } from './accounting-validation.service'; import { AccountingValidationService } from './accounting-validation.service';
import { import { PaginatedTourObjectType, TourCostBreakdownObjectType } from './accounting.object-type';
PaginatedTourObjectType,
TourCostBreakdownObjectType,
TourStateMetaObjectType,
} from './accounting.object-type';
import { TourObjectType } from './tour.object-type'; import { TourObjectType } from './tour.object-type';
@Resolver(() => TourObjectType) @Resolver(() => TourObjectType)
@@ -25,13 +15,9 @@ export class AccountingValidationResolver {
@Query(() => PaginatedTourObjectType) @Query(() => PaginatedTourObjectType)
accountingTours( accountingTours(
@Args('state', { type: () => AccountingState }) state: AccountingState, @Args() { state, filters, cursor, take }: AccountingTourArgs,
@Args('filters', { type: () => AccountingFilterArgs, nullable: true })
filters: AccountingFilterArgs,
@Args('cursor', { nullable: true }) cursor: string,
@Args('take', { type: () => Int, nullable: true }) take: number = 10,
) { ) {
return this.service.paginateToursByState(state, take, cursor); return this.service.paginateToursByState(state, filters, take, cursor);
} }
@Query(() => TourStateMetaObjectType) @Query(() => TourStateMetaObjectType)
@@ -44,7 +30,7 @@ export class AccountingValidationResolver {
@Query(() => TourCostBreakdownObjectType) @Query(() => TourCostBreakdownObjectType)
tourCostBreakdown(@Args('operationId') operationId: string) { tourCostBreakdown(@Args('operationId') operationId: string) {
return this.service.getTourCostBreakdown(operationId); return this.service.getTourCostBreakdown(operationId)
} }
@ResolveField(() => TicketObjectType, { nullable: true }) @ResolveField(() => TicketObjectType, { nullable: true })
@@ -1,18 +1,15 @@
import {
PaginatedResult,
PaginatedTourDto,
TourDto,
TourStateMetaDto,
} from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
decodeOffsetCursor, decodeOffsetCursor,
encodeCursor, encodeCursor,
} from 'src/core/base/pagination.util'; } from 'src/core/base/pagination.util';
import { TourStateMetaObjectType } from './accounting-arg.types';
import { AccountingState } from './accounting-state';
import { PaginatedTourObjectType } from './accounting.object-type';
import { AccountingService } from './accounting.service';
import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service'; import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service';
import { TourService } from '../feat-business-objects/entities/tour/tour.service'; import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { AccountingState } from './accounting-state'; import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
import { AccountingService } from './accounting.service';
@Injectable() @Injectable()
export class AccountingValidationService { export class AccountingValidationService {
@@ -22,39 +19,16 @@ export class AccountingValidationService {
private readonly tourService: TourService, private readonly tourService: TourService,
) {} ) {}
async toursByState(
state: AccountingState,
page: number,
limit: number = 10,
): Promise<PaginatedResult<TourDto>> {
const offset = (page - 1) * limit;
const { tours, total } = await this.tourService.findByAccountingState(
state,
limit,
offset,
);
return {
data: tours,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async paginateToursByState( async paginateToursByState(
state: AccountingState, state: AccountingState,
filter: TourAccountingFilterDto,
take: number = 10, take: number = 10,
cursor?: string, cursor?: string,
): Promise<PaginatedTourDto> { ): Promise<PaginatedTourObjectType> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0; const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const { tours, total } = await this.tourService.findByAccountingState( const { tours, total } = await this.tourService.findByAccountingState(
state, state,
filter,
take, take,
skip, skip,
); );
@@ -73,7 +47,7 @@ export class AccountingValidationService {
async getAccountingStateMeta( async getAccountingStateMeta(
accountingState: AccountingState, accountingState: AccountingState,
): Promise<TourStateMetaDto> { ): Promise<TourStateMetaObjectType> {
return this.tourService.calculateAccountingStateMeta(accountingState); return this.tourService.calculateAccountingStateMeta(accountingState);
} }
@@ -1,96 +0,0 @@
import {
OptimizationCarDto,
PaginatedTourDto,
TourCostBreakdownDto,
TourDto,
TourStateMetaDto,
} from '@avicenna/shared-dtos';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { AccountingQueryService } from './accounting-query.service';
import { AccountingState } from './accounting-state';
import { AccountingValidationService } from './accounting-validation.service';
import { RevenueOptimizationService } from './revenue-optimization.service';
@ApiTags('Accounting')
@Controller('api/accounting')
export class AccountingController {
constructor(
private readonly service: AccountingValidationService,
private readonly queryService: AccountingQueryService,
private readonly optimizationService: RevenueOptimizationService,
private readonly tourService: TourService,
) {}
@Get('cars/:name')
async getCarForOptimization(
@Param('name') name: string,
@Query('operation-day') operationDay: string,
): Promise<OptimizationCarDto> {
const date = new Date(operationDay);
const tours = await this.tourService.getOptimizationToursForCar(name, date);
return {
name,
tours: tours.map((tour) => ({
id: tour.id,
operationId: tour.operationId,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate.toISOString(),
optimizationInMinutes: tour.optimizationInMinutes ?? 0,
departedForPickup: tour.startBegin
? resolveDateFromTime(tour.startDate, tour.startBegin).toISOString()
: null,
arrivedAtPickup: tour.startEnd
? resolveDateFromTime(tour.startDate, tour.startEnd).toISOString()
: null,
departedPickup: tour.target
? resolveDateFromTime(tour.startDate, tour.target).toISOString()
: null,
arrivedAtTarget: tour.targetBegin
? resolveDateFromTime(tour.startDate, tour.targetBegin).toISOString()
: null,
completed: tour.empty
? resolveDateFromTime(tour.startDate, tour.empty).toISOString()
: null,
})),
};
}
@Get('tours')
@ApiOkResponse({ type: PaginatedTourDto })
async getAccountingTours(
@Query('state') state: AccountingState,
@Query('page') page: number,
@Query('take') take: number = 10,
) {
return this.queryService.toursByState(state, page, take);
}
@Get('tours/:id')
@ApiOkResponse({ type: TourDto })
getTourDetail(@Param('id') tourId: string): Promise<TourDto> {
return this.tourService.findById(tourId);
}
@Get('state-meta')
@ApiOkResponse({ type: TourStateMetaDto })
async getAccountingStateMeta(@Query('state') state: AccountingState) {
return this.service.getAccountingStateMeta(state);
}
@Get('cost-breakdown/:operationId')
@ApiOkResponse({ type: TourCostBreakdownDto })
async getTourCostBreakdown(@Param('operationId') operationId: string) {
return this.service.getTourCostBreakdown(operationId);
}
@Get('optimizations')
async getOptimizations(@Query('day') day: Date) {
return this.optimizationService.findOptimizationsForOperationDay(day);
}
}
@@ -1,53 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { eachDayOfInterval, startOfDay, subWeeks } from 'date-fns';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { LoadingDoneEvent } from '../feat-etl/feat-importers/events/loading-done.event';
import {
RevenueOptimizationDto,
RevenueOptimizationService,
} from './revenue-optimization.service';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { ETLEventTypes } from '../feat-etl/feat-importers/events/event-types.enum';
@Injectable()
export class AccountingListener {
private readonly logger = new Logger(AccountingListener.name);
constructor(
private readonly service: RevenueOptimizationService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly tourService: TourService,
) {}
@OnEvent(ETLEventTypes.LoadingDone)
async handleRevenueOptimizationCheck() {
this.logger.log(`looking for revenue optimizations`);
const batchSize = 10;
const days = eachDayOfInterval({
start: subWeeks(startOfDay(new Date()), 4),
end: startOfDay(new Date()),
});
const optimizationsInPeriod: RevenueOptimizationDto[] = [];
for (let i = 0; i < days.length; i += batchSize) {
const batch = days.slice(i, i + batchSize);
const optimizations = await Promise.all(
batch.map((day) => this.service.findOptimizationsForOperationDay(day)),
);
optimizationsInPeriod.push(...optimizations.flat(2));
}
await Promise.all(
optimizationsInPeriod.map((optimization) =>
this.tourService.updateTour(optimization.tourId, {
optimizationInMinutes: optimization.timeModificationInMinutes,
}),
),
);
return optimizationsInPeriod;
}
}
@@ -1,14 +1,8 @@
import { import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
Field,
Float,
Int,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { CostTypeVariant } from '@prisma/client';
import { Paginated } from 'src/core/base/pagination.input';
import { AccountingState } from './accounting-state'; import { AccountingState } from './accounting-state';
import { Paginated } from 'src/core/base/pagination.input';
import { TourObjectType } from './tour.object-type'; import { TourObjectType } from './tour.object-type';
import { CostTypeVariant } from '@prisma/client';
registerEnumType(AccountingState, { registerEnumType(AccountingState, {
name: 'AccountingState', name: 'AccountingState',
@@ -19,7 +13,7 @@ export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
@ObjectType('ServicePosition') @ObjectType('ServicePosition')
export class ServicePositionObjectType { export class ServicePositionObjectType {
@Field(() => String) @Field()
variant: CostTypeVariant; variant: CostTypeVariant;
@Field() @Field()
description: string; description: string;
@@ -37,15 +31,3 @@ export class TourCostBreakdownObjectType {
@Field(() => [ServicePositionObjectType]) @Field(() => [ServicePositionObjectType])
servicePositions: ServicePositionObjectType[]; servicePositions: ServicePositionObjectType[];
} }
@ObjectType('TourStateMeta')
export class TourStateMetaObjectType {
@Field(() => Int)
count!: number;
@Field(() => Float, { nullable: true })
revenueSum?: number;
@Field(() => Float, { nullable: true })
positiveRevenueDeviation?: number;
@Field(() => Float, { nullable: true })
negativeRevenueDeviation?: number;
}
@@ -4,22 +4,14 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module'; import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { AccountingValidationResolver } from './accounting-validation.resolver'; import { AccountingValidationResolver } from './accounting-validation.resolver';
import { AccountingValidationService } from './accounting-validation.service'; import { AccountingValidationService } from './accounting-validation.service';
import { AccountingController } from './accounting.controller';
import { AccountingListener } from './accounting.listener';
import { AccountingService } from './accounting.service'; import { AccountingService } from './accounting.service';
import { RevenueOptimizationService } from './revenue-optimization.service';
import { AccountingQueryService } from './accounting-query.service';
@Module({ @Module({
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule], imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
controllers: [AccountingController],
providers: [ providers: [
AccountingValidationResolver, AccountingValidationResolver,
AccountingValidationService, AccountingValidationService,
AccountingService, AccountingService,
AccountingListener,
RevenueOptimizationService,
AccountingQueryService
], ],
exports: [], exports: [],
}) })
@@ -1,208 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CostTypeVariant, Tour } from '@prisma/client';
import {
differenceInMinutes,
isAfter,
isBefore,
isWithinInterval,
} from 'date-fns';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { generateIntervalForTimeRange } from 'src/utils';
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service';
export interface RevenueOptimizationDto {
tourId: string;
timeModificationInMinutes: number;
costDifference: number;
}
@Injectable()
export class RevenueOptimizationService {
private readonly logger = new Logger(RevenueOptimizationService.name);
private healthInsurancesWithNoTenant = [];
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly tariffService: TariffService,
) {}
async findOptimizationsForOperationDay(
day: Date = new Date('2026-01-15'),
): Promise<RevenueOptimizationDto[]> {
const tours = await this.loadToursForOperationDay(day);
const groupedByCar = tours.reduce(
(acc, tour) => {
(acc[tour.carName] ??= []).push(tour);
return acc;
},
{} as Record<string, typeof tours>,
);
const result = [];
for (const groupName of Object.keys(groupedByCar)) {
const optimizations = await this.findOptimizationPotential(
groupedByCar[groupName],
);
if (optimizations.length > 0) {
result.push(optimizations);
}
}
return result;
}
async findOptimizationPotential(
tours: Tour[],
): Promise<RevenueOptimizationDto[]> {
const result: RevenueOptimizationDto[] = [];
for (let i = 0; i < tours.length; i++) {
const currentTour = tours[i];
const bonusPeriods = await this.getBonusPeriods(currentTour);
// TODO: Make sure startEnd is the correct timestamp
const tourInvoiceTime = resolveDateFromTime(
currentTour.startDate,
currentTour.startEnd,
);
const bonusPeriod = bonusPeriods
.filter(({ interval }) => isWithinInterval(tourInvoiceTime, interval))
.at(0);
const possiblePeriod = bonusPeriods
.filter(
({ interval }) =>
(Math.abs(differenceInMinutes(tourInvoiceTime, interval.start)) <=
15 ||
Math.abs(differenceInMinutes(tourInvoiceTime, interval.end)) <
15) &&
!isWithinInterval(tourInvoiceTime, interval),
)
.at(0);
if (
(!bonusPeriod && possiblePeriod) ||
bonusPeriod?.costAmount < possiblePeriod?.costAmount
) {
let timeModification = 0;
if (isBefore(tourInvoiceTime, possiblePeriod.interval.start)) {
timeModification = differenceInMinutes(
possiblePeriod.interval.start,
tourInvoiceTime,
);
} else if (isAfter(tourInvoiceTime, possiblePeriod.interval.end)) {
timeModification = differenceInMinutes(
possiblePeriod.interval.end,
tourInvoiceTime,
);
}
let costDifference = 0;
if (bonusPeriod) {
costDifference = possiblePeriod.costAmount - bonusPeriod.costAmount;
} else {
costDifference = possiblePeriod.costAmount;
}
result.push({
tourId: currentTour.id,
timeModificationInMinutes: timeModification,
costDifference,
});
}
}
return result;
}
private async getBonusPeriods(tour: Tour) {
if (!tour.ordinanceType) {
this.logger.warn(`Tour ${tour.operationId} has no ordinanceType`, {
operationId: tour.operationId,
healthInsurance: tour.healthInsurance,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
});
return [];
}
const tariff = await this.findTariff(
tour.healthInsurance,
tour.ordinanceType.split(' ').at(1),
tour.startDate,
);
if (!tariff) {
this.logger.warn(`no Tariff found for Tour ${tour.operationId}`, {
operationId: tour.operationId,
healthInsurance: tour.healthInsurance,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
});
return [];
}
const bonusPeriods = tariff.costTypes
.filter(
({ variant }) =>
variant === CostTypeVariant.NACHTZUSCHLAG ||
variant === CostTypeVariant.GRUNDPREIS_ZEIT,
)
.map(({ timeRange, costAmount, description }) => ({
description,
interval: generateIntervalForTimeRange(
timeRange,
'Europe/Berlin',
tour.startDate,
),
costAmount,
}));
return bonusPeriods;
}
private async loadToursForOperationDay(day: Date) {
const operationDayInterval = generateIntervalForTimeRange(
'05:00-04:59',
'Europe/Berlin',
day,
);
const rows = await this.db
.selectFrom('Tour')
.selectAll()
.where('Tour.startDate', '>=', new Date(operationDayInterval.start))
.where('Tour.startDate', '<=', new Date(operationDayInterval.end))
.where('Tour.healthInsurance', 'is not', null)
.execute();
return rows;
}
private async findTariff(
healthInsurance: string,
ordinanceType: string,
tourDate: Date,
) {
if (!healthInsurance || healthInsurance === '') {
return;
}
const tenant = await this.tariffService.findTenant(
healthInsurance,
ordinanceType,
);
if (!tenant) {
this.healthInsurancesWithNoTenant.push({
healthInsurance,
ordinanceType,
});
return;
}
return this.tariffService.findByTenantId(
tenant.id,
ordinanceType,
tourDate,
);
}
}
@@ -3,7 +3,6 @@ import { Tour } from '@prisma/client';
@ObjectType('Tour') @ObjectType('Tour')
export class TourObjectType implements Tour { export class TourObjectType implements Tour {
optimizationInMinutes: number;
insuranceNo: string; insuranceNo: string;
overRTW: boolean; overRTW: boolean;
companyName: string; companyName: string;
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module'; import { PrismaModule } from 'src/core/database/prisma.module';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module'; import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module'; import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { ManagerController } from './manager.controller';
import { ManagerResolver } from './manager.resolver'; import { ManagerResolver } from './manager.resolver';
import { TimePerTourKpiService } from './time-per-tour-kpi.service'; import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service'; import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
@@ -10,7 +9,6 @@ import { TourAmountForecastService } from './tour-amount-forecast.service';
@Module({ @Module({
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule], imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
controllers: [ManagerController],
providers: [ providers: [
TourAmountForecastService, TourAmountForecastService,
TimePerTourKpiService, TimePerTourKpiService,
@@ -1,59 +0,0 @@
import {
TimePerTourKPISummaryDto,
TimePerTourMetricDto,
} from '@avicenna/shared-dtos';
import {
BadRequestException,
Controller,
Get,
Logger,
Query,
} from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
@Controller('api/manager')
export class ManagerController {
private readonly logger = new Logger(ManagerController.name);
constructor(
private readonly timePerTourKpiService: TimePerTourKpiService,
private readonly timePerTourMetricService: TimePerTourMetricsService,
) {}
@Get('overview')
@ApiOkResponse({ type: [TimePerTourKPISummaryDto] })
async overview(
@Query('ordinanceType') ordinanceType: string,
): Promise<TimePerTourKPISummaryDto[]> {
if (!ordinanceType) {
throw new BadRequestException(`no ordinanceType provided`);
}
return this.timePerTourKpiService.toursPerTimeKPI(ordinanceType);
}
@Get('dayview')
@ApiOkResponse({ type: [TimePerTourMetricDto] })
async dayview(
@Query('day') day: Date,
@Query('ordinanceType') ordinanceType: string,
): Promise<TimePerTourMetricDto[]> {
if (!day || !ordinanceType) {
throw new BadRequestException(`no day or ordinanceType provided`);
}
return this.timePerTourMetricService.forDay(day, ordinanceType);
}
@Get('monthview')
@ApiOkResponse({ type: [TimePerTourMetricDto] })
async monthview(
@Query('day') day: Date,
@Query('ordinanceType') ordinanceType: string,
): Promise<TimePerTourMetricDto[]> {
if (!day || !ordinanceType) {
throw new BadRequestException(`no day or ordinanceType provided`);
}
return this.timePerTourMetricService.forMonth(day, ordinanceType);
}
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { addMilliseconds, Interval } from 'date-fns'; import { addMilliseconds } from 'date-fns';
import { PubSub } from 'graphql-subscriptions'; import { PubSub } from 'graphql-subscriptions';
import { generateIntervalForTimeRange } from 'src/utils'; import { generateIntervalForTimeRange } from 'src/utils';
import { CalculationService } from '../feat-calculation/calculation.service'; import { CalculationService } from '../feat-calculation/calculation.service';
@@ -115,10 +115,7 @@ export class TimePerTourKpiService {
tourAmount, tourAmount,
); );
return { return {
interval: { interval,
start: new Date(interval.start),
end: new Date(interval.end)
},
kpi: { kpi: {
value: baseValue, value: baseValue,
meta: { meta: {
@@ -1,4 +1,3 @@
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
addDays, addDays,
@@ -16,10 +15,11 @@ import {
startOfDay, startOfDay,
startOfMonth, startOfMonth,
} from 'date-fns'; } from 'date-fns';
import { TourAmountForecastService } from './tour-amount-forecast.service';
import { TimePerTourMetricObjectType } from './manager.object-type';
import { CalculationService } from '../feat-calculation/calculation.service';
import { generateIntervalForTimeRange, groupBy } from 'src/utils'; import { generateIntervalForTimeRange, groupBy } from 'src/utils';
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service'; import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
import { CalculationService } from '../feat-calculation/calculation.service';
import { TourAmountForecastService } from './tour-amount-forecast.service';
@Injectable() @Injectable()
export class TimePerTourMetricsService { export class TimePerTourMetricsService {
@@ -84,7 +84,7 @@ export class TimePerTourMetricsService {
intervalClamp: Interval, intervalClamp: Interval,
subIntervals: Interval[], subIntervals: Interval[],
ordinanceType: string, ordinanceType: string,
): Promise<TimePerTourMetricDto[]> { ): Promise<TimePerTourMetricObjectType[]> {
const carEntities = await this.carEntityService.getCarsInInterval( const carEntities = await this.carEntityService.getCarsInInterval(
intervalClamp, intervalClamp,
ordinanceType, ordinanceType,
@@ -180,10 +180,7 @@ export class TimePerTourMetricsService {
} }
return { return {
interval: { interval,
start: new Date(interval.start),
end: new Date(interval.end),
},
tourAmount: Math.round(tourAmount * 100) / 100, tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100, availableTime: Math.round(availableTime * 100) / 100,
breakEven: breakEven:
@@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { PlaygroundController } from './playground.controller';
import { PlaygroundService } from './playground.service';
@Module({
imports: [FeatBusinessObjectsModule],
controllers: [PlaygroundController],
providers: [PlaygroundService],
})
export class AppPlaygroundModule {}
@@ -1,12 +0,0 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PlaygroundService } from './playground.service';
@Controller('api/playground')
export class PlaygroundController {
constructor(private readonly service: PlaygroundService) {}
@Get('operation-day')
async getOperationDay(@Query('day') day: Date) {
return this.service.getCarsByOperationDay(new Date(day));
}
}
@@ -1,57 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
import { CarEntity } from '../feat-business-objects/car-entity/car.entity';
@Injectable()
export class PlaygroundService {
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly carService: CarEntityService,
) {}
async getCarsByOperationDay(day: Date) {
const ordinanceTypes = ['KTW', 'TSW', 'BTW'];
const cars = await Promise.all(
ordinanceTypes.map((ot) =>
this.carService.getCarsForOperationDay(day, ot),
),
);
return cars
.flat(2)
.filter(({ carName }) => !!carName)
.map((car) => this.carMapper(car))
.filter(
({ tours }) =>
tours.filter(
({ optimizationInMinutes }) => optimizationInMinutes !== 0,
).length > 0,
);
}
private carMapper(car: CarEntity) {
return {
name: car.carName,
drivers: car['plannedTimes'].map((pt) => ({
name: `${pt.firstname} ${pt.surname}`,
})),
tours: car['tours'].map((tour) => ({
id: tour.id,
operationId: tour.operationId,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
optimizationInMinutes: tour.optimizationInMinutes,
departedForPickup: tour.startBegin,
arrivedAtPickup: tour.startEnd,
departedPickup: tour.target,
arrivedAtTarget: tour.targetBegin,
completed: tour.empty,
})),
};
}
}
@@ -3,7 +3,6 @@ import {
ApprovalRequirement, ApprovalRequirement,
TicketApprovalViewDto, TicketApprovalViewDto,
TicketDetailDto, TicketDetailDto,
TicketTransportDocumentView,
} from '@avicenna/shared-dtos'; } from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
@@ -12,6 +11,7 @@ import { Ticket } from 'src/modules/feat-tickets/domain/ticket.entity';
@Injectable() @Injectable()
export class TicketReadMapper { export class TicketReadMapper {
toDetailDto(ticket: Ticket): TicketDetailDto { toDetailDto(ticket: Ticket): TicketDetailDto {
const transportDocument = ticket.transportDocument;
const tour = ticket.tours const tour = ticket.tours
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime()) .sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
.at(0); .at(0);
@@ -45,7 +45,10 @@ export class TicketReadMapper {
approval: this.toApprovalView(ticket), approval: this.toApprovalView(ticket),
transportDocument: this.toTransportDocumentView(ticket), transportDocument: {
isUploaded: !!transportDocument,
link: transportDocument?.storagePath,
},
tours: ticket.tours tours: ticket.tours
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime()) .sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
@@ -89,15 +92,6 @@ export class TicketReadMapper {
'Sind Sie sicher, dass sie die Genehmigungsanforderung des Tickets überschreiben wollen?', 'Sind Sie sicher, dass sie die Genehmigungsanforderung des Tickets überschreiben wollen?',
}, },
registerExternalRequest: {
isPossible:
approval.status !== ApprovalRequestStatus.FULL_REQUEST_SENT &&
approval.status !== ApprovalRequestStatus.EXTERNAL_REQUEST &&
approval.status !== ApprovalRequestStatus.GRANTED &&
approval.requirement === ApprovalRequirement.REQUIRED,
label: 'Bereits außerhalb angefragt',
},
sendRequest: this.canSendApprovalRequest(ticket), sendRequest: this.canSendApprovalRequest(ticket),
sendReminder: this.canSendApprovalReminder(ticket), sendReminder: this.canSendApprovalReminder(ticket),
@@ -111,28 +105,6 @@ export class TicketReadMapper {
}; };
} }
toTransportDocumentView(ticket: Ticket): TicketTransportDocumentView {
const td = ticket.transportDocument;
const isPresent =
ticket.hasDigitalTransportDocument || ticket.hasPhysicalTransportDocument;
return {
isDocumentPresent: isPresent,
label: isPresent ? 'Transportschein liegt vor' : 'Dokument erforderlich',
digitalDownloadLink: td?.storagePath,
actions: {
upload: {
isPossible: !ticket.hasDigitalTransportDocument,
},
receivePhysical: {
isPossible: !ticket.hasPhysicalTransportDocument,
label: 'Transportschein liegt vor (Erfassen)',
},
},
};
}
private translateApprovalStatus(status: ApprovalRequestStatus): string { private translateApprovalStatus(status: ApprovalRequestStatus): string {
switch (status) { switch (status) {
case ApprovalRequestStatus.NOT_STARTED: case ApprovalRequestStatus.NOT_STARTED:
@@ -141,8 +113,6 @@ export class TicketReadMapper {
return 'Vorab-Anfrage versendet'; return 'Vorab-Anfrage versendet';
case ApprovalRequestStatus.FULL_REQUEST_SENT: case ApprovalRequestStatus.FULL_REQUEST_SENT:
return 'Anfrage versendet'; return 'Anfrage versendet';
case ApprovalRequestStatus.EXTERNAL_REQUEST:
return 'Anfrage von Extern versendet';
case ApprovalRequestStatus.GRANTED: case ApprovalRequestStatus.GRANTED:
return 'Genehmigung erteilt'; return 'Genehmigung erteilt';
} }
@@ -1,11 +1,7 @@
import { import { TicketListItemDto, PaginatedResult } from '@avicenna/shared-dtos';
PaginatedResult,
TicketListItemDto,
TicketStage,
} from '@avicenna/shared-dtos';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { Kysely, sql } from 'kysely'; import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module'; import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types'; import { DB } from 'src/core/database/types';
@@ -17,94 +13,6 @@ export class TicketQueryService {
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>, @Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {} ) {}
async searchTicket(searchTerm: string): Promise<TicketListItemDto[]> {
const searchTerms = searchTerm
.split(' ')
.filter(Boolean)
.map((str) => `%${str}%`);
const rows = await this.db
.selectFrom('Ticket')
.distinctOn('Ticket.id')
.where('stage', 'not in', [TicketStage.ARCHIVED, TicketStage.CLOSED])
.leftJoin('Patient', (join) =>
join.onRef(sql`"Patient"."id"::uuid`, '=', 'Ticket.patientId'),
)
.innerJoin('Tour', 'Ticket.id', 'Tour.ticketId')
.where((eb) =>
eb.and(
searchTerms.map((term) =>
eb.or([
eb('Patient.name', 'ilike', term),
eb('Patient.surname', 'ilike', term),
eb('Tour.patientName', 'ilike', term),
eb('Tour.patientSurname', 'ilike', term),
eb('Tour.operationId', 'ilike', term),
]),
),
),
)
.select((eb) => [
'Ticket.id',
'Ticket.stage',
'Ticket.approvalRequirement',
'Ticket.approvalStatus',
'Ticket.approvalRequestedAt',
'Ticket.createdAt',
'Ticket.updatedAt',
sql<string>`concat("Tour"."patientName", ' ', "Tour"."patientSurname")`.as(
'tourPatientName',
),
sql<string>`concat("Patient".name, ' ', "Patient".surname)`.as(
'patientName',
),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select(eb.fn.countAll<number>().as('count'))
.as('tourCount'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('ordinanceType')
.limit(1)
.as('ordinanceType'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('Tour.startDate')
.where('Tour.startDate', '>=', sql<Date>`now()`)
.orderBy('Tour.startDate', 'asc')
.limit(1)
.as('nextStartDate'),
])
.limit(5)
.execute();
return rows.map(
(row): TicketListItemDto => ({
id: row.id,
ordinanceType: row.ordinanceType,
patientName:
`${row.patientName}`.length > `${row.tourPatientName}`.length
? `${row.patientName}`
: row.tourPatientName,
stage: row.stage,
nextStart: row.nextStartDate,
tourCount: row.tourCount,
approvalRequirement: row.approvalRequirement,
approvalStatus: row.approvalStatus,
isOverdue: row.approvalRequestedAt
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
: false,
errorCount: 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}),
);
}
async getTicketsForLane( async getTicketsForLane(
stage: string, stage: string,
page: number, page: number,
@@ -126,7 +34,6 @@ export class TicketQueryService {
.select((eb) => [ .select((eb) => [
'Ticket.id', 'Ticket.id',
'Ticket.stage', 'Ticket.stage',
'Ticket.approvalRequirement',
'Ticket.approvalStatus', 'Ticket.approvalStatus',
'Ticket.approvalRequestedAt', 'Ticket.approvalRequestedAt',
'Ticket.createdAt', 'Ticket.createdAt',
@@ -154,14 +61,6 @@ export class TicketQueryService {
.select('ordinanceType') .select('ordinanceType')
.limit(1) .limit(1)
.as('ordinanceType'), .as('ordinanceType'),
eb
.selectFrom('Tour')
.select('Tour.startDate')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.where('Tour.startDate', '>=', sql<Date>`now()`)
.orderBy('Tour.startDate', 'asc')
.limit(1)
.as('nextStartDate'),
]) ])
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
@@ -173,13 +72,9 @@ export class TicketQueryService {
ordinanceType: row.ordinanceType, ordinanceType: row.ordinanceType,
patientName: `${row.patientName} ${row.patientSurname}`, patientName: `${row.patientName} ${row.patientSurname}`,
stage: row.stage, stage: row.stage,
nextStart: row.nextStartDate,
tourCount: row.tourCount, tourCount: row.tourCount,
approvalRequirement: row.approvalRequirement,
approvalStatus: row.approvalStatus, approvalStatus: row.approvalStatus,
isOverdue: row.approvalRequestedAt isOverdue: row.approvalRequestedAt ? differenceInDays(new Date(), row.approvalRequestedAt) >= 3 : false,
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
: false,
errorCount: 0, errorCount: 0,
createdAt: row.createdAt, createdAt: row.createdAt,
@@ -29,7 +29,6 @@ export class DocumentInfoObjectType {
@ObjectType('Ticket') @ObjectType('Ticket')
export class TicketObjectType implements Ticket { export class TicketObjectType implements Ticket {
lastStageBeforeClosed: string;
approvalPreInquiryMessageId: string; approvalPreInquiryMessageId: string;
approvalFullRequestMessageId: string; approvalFullRequestMessageId: string;
approvalRecipient: string; approvalRecipient: string;
@@ -1,10 +1,3 @@
import {
GetBoardQueryDto,
PaginatedResult,
TicketApprovalViewDto,
TicketDetailDto,
TicketListItemDto,
} from '@avicenna/shared-dtos';
import { import {
BadRequestException, BadRequestException,
Body, Body,
@@ -22,20 +15,25 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { MarkAsApprovedUseCase } from '../feat-tickets/application/mark-as-approved.usecase';
import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.usecase';
import { ReceivePhysicalTransportDocumentUseCase } from '../feat-tickets/application/receive-physical-transport-document.usecase';
import { RequestTicketApprovalUseCase } from '../feat-tickets/application/request-ticket-approval.usecase';
import { SendApprovalReminderUseCase } from '../feat-tickets/application/send-approval-reminder.usecase';
import { UploadTransportDocumentUseCase } from '../feat-tickets/application/upload-transport-document.usecase'; import { UploadTransportDocumentUseCase } from '../feat-tickets/application/upload-transport-document.usecase';
import { TicketId } from '../feat-tickets/domain/ticket.entity'; import { TicketQueryService } from './queries/ticket-query.service';
import {
PaginatedResult,
TicketListItemDto,
GetBoardQueryDto,
TicketDetailDto,
TicketApprovalViewDto,
} from '@avicenna/shared-dtos';
import { import {
TICKET_REPOSITORY, TICKET_REPOSITORY,
TicketRepository, TicketRepository,
} from '../feat-tickets/domain/ticket.repository.interface'; } from '../feat-tickets/domain/ticket.repository.interface';
import { TicketId } from '../feat-tickets/domain/ticket.entity';
import { TicketReadMapper } from './mappers/ticket-read.mapper'; import { TicketReadMapper } from './mappers/ticket-read.mapper';
import { TicketQueryService } from './queries/ticket-query.service'; import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.usecase';
import { RegisterExternalApprovalRequestUseCase } from '../feat-tickets/application/register-external-approval-request.usecase'; import { RequestTicketApprovalUseCase } from '../feat-tickets/application/request-ticket-approval.usecase';
import { SendApprovalReminderUseCase } from '../feat-tickets/application/send-approval-reminder.usecase';
import { MarkAsApprovedUseCase } from '../feat-tickets/application/mark-as-approved.usecase';
@Controller('api/tickets') @Controller('api/tickets')
export class TicketsController { export class TicketsController {
@@ -50,22 +48,9 @@ export class TicketsController {
private readonly overrideUseCase: OverrideApprovalRequirementUseCase, private readonly overrideUseCase: OverrideApprovalRequirementUseCase,
private readonly requestApprovalUseCase: RequestTicketApprovalUseCase, private readonly requestApprovalUseCase: RequestTicketApprovalUseCase,
private readonly sendReminderUseCase: SendApprovalReminderUseCase, private readonly sendReminderUseCase: SendApprovalReminderUseCase,
private readonly approveUseCase: MarkAsApprovedUseCase, private readonly approveUseCase: MarkAsApprovedUseCase
private readonly receiveUseCase: ReceivePhysicalTransportDocumentUseCase,
private readonly registerUseCase: RegisterExternalApprovalRequestUseCase,
) {} ) {}
@Get('search')
async searchTickets(
@Query('q') searchTerm: string,
): Promise<TicketListItemDto[]> {
if (!searchTerm) {
throw new BadRequestException("no 'q' provided");
}
return this.queryService.searchTicket(searchTerm);
}
@Get() @Get()
async getBoard( async getBoard(
@Query() query: GetBoardQueryDto, @Query() query: GetBoardQueryDto,
@@ -110,30 +95,17 @@ export class TicketsController {
@Post(':id/approval/request') @Post(':id/approval/request')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async requestApproval( async requestApproval(@Param('id') id: string, @Body('recipient') recipient: string) {
@Param('id') id: string,
@Body('recipient') recipient: string,
) {
if (!id) { if (!id) {
throw new BadRequestException(`no id provided`); throw new BadRequestException(`no id provided`);
} }
if (!recipient) { if (!recipient) {
throw new BadRequestException(`no recipient provided`); throw new BadRequestException(`no recipient provided`)
} }
await this.requestApprovalUseCase.execute(id, recipient); await this.requestApprovalUseCase.execute(id, recipient);
} }
@Post(':id/approval/register-request')
@HttpCode(HttpStatus.NO_CONTENT)
async registerExternalApprovalRequest(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.registerUseCase.execute(id);
}
@Post(':id/approval/remind') @Post(':id/approval/remind')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async sendReminder(@Param('id') id: string) { async sendReminder(@Param('id') id: string) {
@@ -164,16 +136,6 @@ export class TicketsController {
await this.overrideUseCase.execute(id); await this.overrideUseCase.execute(id);
} }
@Post(':id/transport-document/receive')
@HttpCode(HttpStatus.NO_CONTENT)
async receiveTD(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.receiveUseCase.execute(id);
}
@Post(':ticketId/files') @Post(':ticketId/files')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED) @HttpCode(HttpStatus.ACCEPTED)
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Prisma, TicketValidationState, Tour } from '@prisma/client'; import { Prisma, TicketValidationState, Tour } from '@prisma/client';
import { endOfDay, Interval, startOfDay } from 'date-fns'; import { endOfDay, Interval, startOfDay } from 'date-fns';
import { ExpressionBuilder, ExpressionWrapper, Kysely, sql, SqlBool } from 'kysely'; import { ExpressionBuilder, ExpressionWrapper, Kysely, SqlBool } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module'; import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service'; import { PrismaService } from 'src/core/database/prisma.service';
import { DB } from 'src/core/database/types'; import { DB } from 'src/core/database/types';
@@ -26,8 +26,8 @@ function onlyRealToursKysely(eb: TourEb) {
eb('deletedAt', 'is', null), eb('deletedAt', 'is', null),
eb.or([ eb.or([
eb('Tour.patientId', 'is', null), eb('patientId', 'is', null),
eb('Tour.patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]), eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
]), ]),
]); ]);
} }
@@ -77,26 +77,6 @@ export class TourRepository {
.orderBy('Tour.startDate asc') .orderBy('Tour.startDate asc')
.execute(); .execute();
} }
// tour.repository.ts
async findForCarInInterval(
carName: string,
interval: Interval,
): Promise<Tour[]> {
return await this.db
.selectFrom('Tour')
.selectAll('Tour')
.where((eb) =>
eb.and([
eb('Tour.carName', 'ilike', `%${carName}%`),
eb('Tour.startDate', '>=', new Date(interval.start)),
eb('Tour.startDate', '<', new Date(interval.end)),
onlyRealToursKysely(eb),
]),
)
.orderBy('Tour.startDate', 'asc')
.execute();
}
async findWithoutTicket(): Promise<Tour[]> { async findWithoutTicket(): Promise<Tour[]> {
return this.db return this.db
@@ -327,39 +307,26 @@ export class TourRepository {
// --------------------- Accounting --------------------- // // --------------------- Accounting --------------------- //
async findByAccountingState( async findByAccountingState(
state: AccountingState, state: AccountingState,
filter: TourAccountingFilterDto,
take?: number, take?: number,
skip?: number, skip?: number,
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> { ): Promise<{ tours: Tour[]; total: number }> {
return await this.db.transaction().execute(async (trx) => { return await this.db.transaction().execute(async (trx) => {
const tours = (await trx const tours = await trx
.selectFrom('Tour') .selectFrom('Tour')
// .selectAll('Tour') .selectAll()
.select([
'Tour.id',
'Tour.operationId',
'Tour.startDate',
'Tour.revenue',
'Tour.revenueDispoLive',
'Tour.optimizationInMinutes',
'Tour.ordinanceType',
])
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.select(['Ticket.stage', 'Ticket.patientId as ticketPatientId'])
.where((eb) => .where((eb) =>
eb.and([ eb.and([
this.getFilterForAccountingState(eb, state), this.getFilterForAccountingState(eb, state),
onlyRealToursKysely(eb), onlyRealToursKysely(eb),
]), ]),
) )
.orderBy(sql`abs("Tour"."optimizationInMinutes")`, 'desc')
.orderBy('Tour.revenueDeviation desc')
.offset(skip) .offset(skip)
.limit(take) .limit(take)
.execute()) as unknown as (Tour & { stage: string })[]; .execute();
const total = await trx const total = await trx
.selectFrom('Tour') .selectFrom('Tour')
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.where((eb) => .where((eb) =>
eb.and([ eb.and([
this.getFilterForAccountingState(eb, state), this.getFilterForAccountingState(eb, state),
@@ -436,11 +403,33 @@ export class TourRepository {
return eb.and([ return eb.and([
eb('Tour.check', '=', 0), eb('Tour.check', '=', 0),
eb('Tour.revenueDeviation', '<>', 0), eb('Tour.revenueDeviation', '<>', 0),
eb.exists(
eb
.selectFrom('Ticket')
.select('Ticket.id')
.whereRef('Ticket.id', '=', 'Tour.ticketId')
.where(
'Ticket.currentState',
'<>',
TicketValidationState.ARCHIVED,
),
),
]); ]);
case AccountingState.Billable: case AccountingState.Billable:
return eb.and([ return eb.and([
eb('Tour.check', '=', 0), eb('Tour.check', '=', 0),
eb('Tour.revenueDeviation', '=', 0), eb('Tour.revenueDeviation', '=', 0),
eb.exists(
eb
.selectFrom('Ticket')
.select('Ticket.id')
.whereRef('Ticket.id', '=', 'Tour.ticketId')
.where(
'Ticket.currentState',
'=',
TicketValidationState.ARCHIVED,
),
),
]); ]);
case AccountingState.PreparedForBilling: case AccountingState.PreparedForBilling:
return eb.and([eb('Tour.check', '=', 1)]); return eb.and([eb('Tour.check', '=', 1)]);

Some files were not shown because too many files have changed in this diff Show More