Compare commits

..

1 Commits

Author SHA1 Message Date
Marcel Arndt ce676f20a4 commit working state
Too many changes to split them up. This commit contains roughly two months worth of work.

This commit introduces the first aggregate and rewrites the ticket system to follow DDD patterns.
It adds Spartan NG as the new component library. TanStack Query to replace GraphQL. Oh and Prisma is
almost over as well.
2026-02-11 16:50:09 +01:00
168 changed files with 41732 additions and 29007 deletions
+1
View File
@@ -7,3 +7,4 @@
.DS_Store
node_modules/
dist/
+5 -5
View File
@@ -2,11 +2,6 @@ FROM node:20.19.1-slim AS 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 npm_config_arch=x64
ENV npm_config_platform=linux
@@ -17,6 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& 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
COPY packages/shared-dtos ./packages/shared-dtos/
+32 -6
View File
@@ -18,7 +18,7 @@
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/dashboard",
"index": "src/index.html",
@@ -37,7 +37,7 @@
],
"styles": [
"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/styles.scss"
],
@@ -49,7 +49,7 @@
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "2MB"
"maximumError": "2.5MB"
},
{
"type": "anyComponentStyle",
@@ -80,7 +80,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "dashboard:build:production"
@@ -92,10 +92,10 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
@@ -119,5 +119,31 @@
}
}
}
},
"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": "."
}
}
}
+26 -27
View File
@@ -11,27 +11,26 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.6",
"@angular/cdk": "^19.2.9",
"@angular/common": "^19.2.6",
"@angular/compiler": "^19.2.6",
"@angular/core": "^19.2.6",
"@angular/forms": "^19.2.6",
"@angular/material": "^19.2.9",
"@angular/material-date-fns-adapter": "^19.2.9",
"@angular/platform-browser": "^19.2.6",
"@angular/platform-browser-dynamic": "^19.2.6",
"@angular/router": "^19.2.6",
"@apollo/client": "^3.13.8",
"@avicenna/shared-dtos": "^1.0.0",
"@angular/animations": "^21.1.0",
"@angular/cdk": "^21.1.0",
"@angular/common": "^21.1.0",
"@angular/compiler": "^21.1.0",
"@angular/core": "^21.1.0",
"@angular/forms": "^21.1.0",
"@angular/material": "^21.1.0",
"@angular/material-date-fns-adapter": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/platform-browser-dynamic": "^21.1.0",
"@angular/router": "^21.1.0",
"@apollo/client": "^4.1.0",
"@azure/msal-angular": "^3.0.22",
"@azure/msal-browser": "^3.19.1",
"@hyperdx/browser": "^0.22.0",
"@ng-icons/core": "^31.4.0",
"@ng-icons/lucide": "^31.4.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0",
"@ngneat/dialog": "^5.1.1",
"@ngneat/overview": "^6.0.0",
"@ngxpert/hot-toast": "^3.0.0",
"@ngneat/overview": "^7.0.0",
"@ngxpert/hot-toast": "^6.0.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-web": "^0.45.1",
"@opentelemetry/context-zone": "^1.30.1",
@@ -43,14 +42,14 @@
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-trace-web": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@spartan-ng/brain": "^0.0.1-alpha.561",
"@spartan-ng/brain": "0.0.1-alpha.605",
"@tanstack/angular-query-experimental": "^5.90.20",
"apollo-angular": "^10.0.3",
"apollo-angular": "^13.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"echarts": "^5.5.1",
"graphql": "^16.11.0",
"graphql": "^16.12.0",
"graphql-ws": "^5.16.0",
"ngx-scrollbar": "^19.1.1",
"rxjs": "~7.8.0",
@@ -59,16 +58,16 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.7",
"@angular/cli": "^19.2.7",
"@angular/compiler-cli": "^19.2.6",
"@angular/localize": "^19.2.6",
"@angular/build": "^21.1.0",
"@angular/cli": "^21.1.0",
"@angular/compiler-cli": "^21.1.0",
"@angular/localize": "^21.1.0",
"@graphql-codegen/cli": "^5.0.5",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-apollo-angular": "^4.0.0",
"@graphql-codegen/typescript-operations": "^4.6.0",
"@material/material-color-utilities": "^0.3.0",
"@spartan-ng/cli": "^0.0.1-alpha.561",
"@spartan-ng/cli": "0.0.1-alpha.605",
"@tailwindcss/postcss": "^4.1.4",
"@types/jasmine": "~5.1.0",
"autoprefixer": "^10.4.19",
@@ -78,10 +77,10 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.39",
"postcss": "^8.5.3",
"prettier": "^3.8.0",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.4.0",
"typescript": "~5.5.2"
"typescript": "~5.9.3"
}
}
@@ -18,7 +18,6 @@ import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
import { GlobalErrorHandler } from './core/telemetry/global-error-handler';
import { graphqlProvider } from './graphql.provider';
const queryClient = new QueryClient({
@@ -43,10 +42,6 @@ window.__TANSTACK_QUERY_CLIENT__ = queryClient;
export const appConfig: ApplicationConfig = {
providers: [
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideAnimationsAsync(),
@@ -61,6 +61,18 @@ export const routes: Routes = [
title: 'Ticketsystem',
},
},
{
path: 'playground',
loadComponent: () =>
import('./pages/playground/playground.component').then(
(m) => m.PlaygroundComponent
),
canActivate: [MsalGuard, roleGuard],
data: {
title: 'Playground',
roles: ['manager', 'admin'],
},
},
{
path: '',
pathMatch: 'full',
@@ -10,5 +10,5 @@ import type { ClassValue } from 'clsx';
})
export class HlmCardFooter {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-6', this.userClass()));
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-4', this.userClass()));
}
@@ -12,7 +12,7 @@ export class HlmCardHeader {
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
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-6',
'@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',
this.userClass(),
),
);
@@ -3,7 +3,7 @@ import { hlm } from '@spartan-ng/helm/utils';
import { type VariantProps, cva } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', {
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm', {
variants: {},
defaultVariants: {},
});
@@ -3,7 +3,6 @@ import { Injectable, type TemplateRef, inject } from '@angular/core';
import {
type BrnDialogOptions,
BrnDialogService,
DEFAULT_BRN_DIALOG_OPTIONS,
cssClassesToArray,
} from '@spartan-ng/brain/dialog';
import { HlmDialogContent } from './hlm-dialog-content';
@@ -22,8 +21,6 @@ export class HlmDialogService {
public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) {
const mergedOptions = {
...DEFAULT_BRN_DIALOG_OPTIONS,
...(options ?? {}),
backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
context: {
@@ -0,0 +1,5 @@
import { HlmSkeleton } from './lib/hlm-skeleton';
export * from './lib/hlm-skeleton';
export const HlmSkeletonImports = [HlmSkeleton] as const;
@@ -0,0 +1,14 @@
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,26 +1,33 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { BrnTabs } from '@spartan-ng/brain/tabs';
import { hlm } from '@spartan-ng/helm/utils';
import type { ClassValue } from 'clsx';
@Component({
selector: 'hlm-tabs',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnTabs,
inputs: ['orientation', 'direction', 'activationMode', 'brnTabs: tab'],
outputs: ['tabActivated'],
},
],
host: {
'[class]': '_computedClass()',
},
template: '<ng-content/>',
selector: 'hlm-tabs',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnTabs,
inputs: ['orientation', 'activationMode', 'brnTabs: tab'],
outputs: ['tabActivated'],
},
],
host: {
'[class]': '_computedClass()',
},
template: '<ng-content/>',
})
export class HlmTabs {
public readonly tab = input.required<string>();
public readonly tab = input.required<string>();
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() => hlm('flex flex-col gap-2', this.userClass()));
public readonly userClass = input<ClassValue>('', { alias: 'class' });
protected readonly _computedClass = computed(() =>
hlm('flex flex-col gap-2', this.userClass()),
);
}
@@ -1,6 +1,257 @@
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 { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
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;
}
@@ -0,0 +1,19 @@
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,
};
@@ -0,0 +1,133 @@
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,
};
});
}
@@ -0,0 +1,23 @@
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,
};
@@ -0,0 +1,74 @@
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,
}),
);
},
}));
}
@@ -1,11 +0,0 @@
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,6 +15,7 @@ import {
lucideBell,
lucideCircleAlert,
lucideCircleCheck,
lucideInbox,
lucideSend,
lucideSquareCheck,
lucideStamp,
@@ -50,7 +51,8 @@ import { ticketKeys } from '../../ticket.keys';
lucideSend,
lucideBell,
lucideSquareCheck,
lucideCircleAlert
lucideCircleAlert,
lucideInbox,
}),
],
template: `<section
@@ -58,8 +60,7 @@ import { ticketKeys } from '../../ticket.keys';
class="flex flex-col overflow-hidden border-l-4 py-4"
[class.border-l-muted]="view().requirement === 'NOT_REQUIRED'"
[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'"
>
@@ -68,8 +69,7 @@ import { ticketKeys } from '../../ticket.keys';
class="h-9 w-9 flex items-center justify-center rounded-full shrink-0"
[class.bg-muted]="view().requirement === 'NOT_REQUIRED'"
[class.bg-yellow-100]="
view().status !== 'GRANTED' &&
view().requirement === 'REQUIRED'
view().status !== 'GRANTED' && view().requirement === 'REQUIRED'
"
[class.bg-green-100]="view().status === 'GRANTED'"
>
@@ -116,12 +116,15 @@ import { ticketKeys } from '../../ticket.keys';
}
</div>
<p class="text-xs text-muted-foreground mt-1">
<!-- FIXME: move to backend -->
@if (view().requirement === 'NOT_REQUIRED') {
Für diese Fahrt ist keine Genehmigung notwendig.
} @else if (view().status === 'NOT_STARTED') {
Noch keine Anfrage gestellt.
} @else if (view().status === 'GRANTED') {
Genehmigung erteilt
} @else if (view().status === 'EXTERNAL_REQUEST') {
Wartet auf Rückmeldung des Kunden
} @else {
Wartet auf Rückmeldung der Kasse.
}
@@ -206,6 +209,19 @@ 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) {
<button
hlmBtn
@@ -330,6 +346,13 @@ export class TicketApprovalComponent {
);
}
async registerRequest(): Promise<void> {
this.executeAction(
() => this.actionsService.registerExternalRequest(this.ticketId()),
'Externe Anfrage registriert',
);
}
async sendReminder(): Promise<void> {
this.executeAction(
() => this.actionsService.sendReminder(this.ticketId()),
@@ -14,7 +14,7 @@ import { HlmIcon } from '@spartan-ng/helm/icon';
@Component({
selector: 'app-ticket-validation-errors',
imports: [HlmCardImports, HlmButtonImports, NgIcon, HlmIcon, RouterLink],
imports: [HlmCardImports, HlmButtonImports, NgIcon, HlmIcon],
providers: [
provideIcons({
lucideTriangleAlert,
@@ -1,56 +1,74 @@
<div class="w-full">
@if (!isUploading()) {
<label
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
class="flex flex-col items-center justify-center pt-5 pb-6 text-center"
<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"
>
<ng-icon
hlmIcon
name="lucideCloudUpload"
size="lg"
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">Klicken zum Hochladen</p>
<p class="text-xs text-muted-foreground mt-1">PDF oder Bild (Max 5MB)</p>
</div>
<input
type="file"
class="hidden"
(change)="onFileSelected($event)"
accept=".pdf,image/*"
/>
</label>
} @else {
<div
class="h-32 flex flex-col items-center justify-center border-2 border-solid border-muted rounded-lg bg-background p-4"
>
<div class="flex items-center gap-2 mb-3 w-full justify-center">
<ng-icon
hlmIcon
name="lucideFile"
size="sm"
class="text-primary animate-pulse"
/>
<span class="text-sm font-medium truncate max-w-[200px]">{{
fileName()
}}</span>
</div>
<div class="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300 ease-out"
[style.width.%]="uploadProgress()"
></div>
</div>
class="flex flex-col items-center justify-center pt-5 pb-6 text-center"
>
<ng-icon
hlmIcon
name="lucideCloudUpload"
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"
/>
<div class="flex justify-between w-full mt-2 text-xs text-muted-foreground">
<span>Lade hoch...</span>
<span>{{ uploadProgress() }}%</span>
<p class="text-sm text-foreground font-medium">
@if (isDragging()) {
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>
<input
type="file"
class="hidden"
(change)="onFileSelected($event)"
accept=".pdf,image/*"
/>
</label>
} @else {
<div
class="h-32 flex flex-col items-center justify-center border-2 border-solid border-muted rounded-lg bg-background p-4"
>
<div class="flex items-center gap-2 mb-3 w-full justify-center">
<ng-icon
hlmIcon
name="lucideFile"
size="sm"
class="text-primary animate-pulse"
/>
<span class="text-sm font-medium truncate max-w-50">{{
fileName()
}}</span>
</div>
<div class="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300 ease-out"
[style.width.%]="uploadProgress()"
></div>
</div>
<div
class="flex justify-between w-full mt-2 text-xs text-muted-foreground"
>
<span>Lade hoch...</span>
<span>{{ uploadProgress() }}%</span>
</div>
</div>
</div>
}
</div>
@@ -45,6 +45,32 @@ export class TransportDocumentUploadComponent {
uploadProgress = signal(0);
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 {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
@@ -10,24 +10,42 @@ export class TicketActionsService {
private http = inject(HttpClient);
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> {
return lastValueFrom(
this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/override`,
{}
)
{},
),
);
}
async requestApproval(
ticketId: string,
recipientEmail: string
recipientEmail: string,
): Promise<void> {
return lastValueFrom(
this.http.post<void>(
`${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`,
{},
),
);
}
@@ -35,8 +53,8 @@ export class TicketActionsService {
return lastValueFrom(
this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/remind`,
{}
)
{},
),
);
}
@@ -44,8 +62,8 @@ export class TicketActionsService {
return lastValueFrom(
this.http.post<void>(
`${this.baseUrl}/tickets/${ticketId}/approval/approve`,
{}
)
{},
),
);
}
}
@@ -3,7 +3,10 @@ export const ticketKeys = {
// Listen (Kanban Lanes)
lists: () => [...ticketKeys.all, 'list'] as const,
byLane: (lane: string, page: number) => [...ticketKeys.lists(), { lane, page }] as const,
byLane: (lane: string, page: number) =>
[...ticketKeys.lists(), { lane, page }] as const,
search: (searchTerm: string) =>
[...ticketKeys.lists(), { searchTerm }] as const,
// Details
details: () => [...ticketKeys.all, 'detail'] as const,
@@ -1,7 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { ticketKeys } from './ticket.keys';
import {
PaginatedResult,
TicketDetailDto,
@@ -11,7 +9,9 @@ import {
injectQuery,
keepPreviousData,
} from '@tanstack/angular-query-experimental';
import { lastValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
import { ticketKeys } from './ticket.keys';
const BASE_URL = environment.api.baseUrl;
const API_URL = '/tickets';
@@ -24,14 +24,29 @@ export function useTicketLane(lane: () => string, page: () => number) {
queryFn: async () => {
return lastValueFrom(
http.get<PaginatedResult<TicketListItemDto>>(
`${BASE_URL}${API_URL}?stage=${lane()}&page=${page()}`
)
`${BASE_URL}${API_URL}?stage=${lane()}&page=${page()}`,
),
);
},
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) {
const http = inject(HttpClient);
@@ -40,7 +55,7 @@ export function useTicketDetail(ticketId: () => string) {
enabled: !!ticketId(),
queryFn: async () => {
return lastValueFrom(
http.get<TicketDetailDto>(`${BASE_URL}${API_URL}/${ticketId()}`)
http.get<TicketDetailDto>(`${BASE_URL}${API_URL}/${ticketId()}`),
);
},
}));
@@ -1,15 +1,20 @@
import { ApplicationConfig, inject } from '@angular/core';
import { ApolloClientOptions, InMemoryCache, split } from '@apollo/client/core';
import {
ApolloClientOptions,
ApolloLink,
InMemoryCache,
split,
} from '@apollo/client/core';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { getMainDefinition } from '@apollo/client/utilities';
import { Apollo, APOLLO_OPTIONS } from 'apollo-angular';
import { provideApollo } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http';
import { OperationDefinitionNode } from 'graphql';
import { createClient } from 'graphql-ws';
import { environment } from '../environments/environment';
const uri = environment.graphQl.endpoint; // <-- add the URL of the GraphQL server here
export function apolloOptionsFactory(): ApolloClientOptions<any> {
export function apolloOptionsFactory(): ApolloClientOptions {
const httpLink = inject(HttpLink);
const http = httpLink.create({ uri });
@@ -17,18 +22,18 @@ export function apolloOptionsFactory(): ApolloClientOptions<any> {
const ws = new GraphQLWsLink(
createClient({
url: environment.graphQl.ws,
})
}),
);
const link = split(
({ query }) => {
const { kind, operation } = getMainDefinition(
query
query,
) as OperationDefinitionNode;
return kind === 'OperationDefinition' && operation === 'subscription';
},
ws,
http
http,
);
return {
link,
@@ -37,9 +42,35 @@ export function apolloOptionsFactory(): ApolloClientOptions<any> {
}
export const graphqlProvider: ApplicationConfig['providers'] = [
Apollo,
{
provide: APOLLO_OPTIONS,
useFactory: apolloOptionsFactory,
},
provideApollo(() => {
const httpLink = inject(HttpLink);
const http = httpLink.create({ uri });
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 { CurrencyPipe, NgClass } from '@angular/common';
import { CurrencyPipe, DatePipe, NgClass } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
@@ -11,97 +11,121 @@ import {
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { AccountingListDto } from '@avicenna/shared-dtos';
import { HotToastService } from '@ngxpert/hot-toast';
import {
AccountingState,
Ticket,
TicketValidationState,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
import { AccountingState } from '../../../core/data-access/graphql/generated/generated';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { lucideCalendar } from '@ng-icons/lucide';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-item',
template: `
<div class="flex justify-between">
<div class="flex items-center">
<button
mat-icon-button
(click)="notifyCopy($event)"
[cdkCopyToClipboard]="tour.operationId"
<div
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-start justify-between p-3 pb-1">
<div class="flex flex-col">
<span
class="text-[10px] uppercase text-muted-foreground font-medium tracking-wider"
>
Ref
</span>
<span
class="font-mono text-xs text-foreground/80 font-semibold group-hover:text-primary transition-colors"
>
#{{ 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"
>
<mat-icon class="text-sm">content_copy</mat-icon>
</button>
<a
class="dispolive-btn"
target="_blank"
[href]="'https://avicenna.dispolive.de/fahrbericht/' + tour.id"
>
{{ tour.operationId }}
</a>
{{ tour.ordinanceType }}
</span>
</div>
<span>{{ tour.ordinanceType }}</span>
</div>
<div>
@if (accountingState !== AccountingState.Billed) {
<span
[title]="!tour.healthInsurance ? 'Keine Krankenkasse hinterlegt' : ''"
[ngClass]="{ 'bg-yellow-200': !tour.healthInsurance }"
<div
class="flex items-end justify-between border-t border-border/50 bg-muted/5 p-3"
>
{{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }}
</span>
} @else {
{{
tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
}}
}
<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>
`,
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: [
MatButtonModule,
MatIconModule,
MatBadgeModule,
ClipboardModule,
NgClass,
CurrencyPipe,
DatePipe,
NgIcon,
HlmIcon,
],
providers: [
provideIcons({
lucideCalendar,
}),
],
})
export class AccountingItemComponent implements AfterViewInit {
export class AccountingItemComponent {
toast = inject(HotToastService);
@Input() tour!: Tour & { ticket: Ticket };
@Input() tour!: AccountingListDto;
@Input() accountingState!: AccountingState;
@HostBinding('style.border-color')
borderColor = '#ffffff';
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) {
event.stopPropagation();
this.toast.success('Einsatznummer kopiert!', {
@@ -1,28 +1,69 @@
<dks-accounting-lane-header
class="shadow"
[state]="accountingState"
(filterChange)="filterChanged($event)"
class="border-b border-border/50 bg-card z-10"
[state]="accountingState()"
></dks-accounting-lane-header>
@if (tours$ | async; as tours) {
<cdk-virtual-scroll-viewport
itemSize="86"
(scrolledIndexChange)="fetchNextPage()"
<div class="overflow-y-auto p-3 space-y-3 scroll-smooth">
@if (toursQuery.isPending()) {
<div
class="flex h-20 items-center justify-center text-muted-foreground text-sm animate-pulse"
>
<dks-accounting-item
*cdkVirtualFor="let tour of tours; trackBy: trackByFn"
class="animate-fadeIn"
style="box-shadow: var(--mat-sys-level2);"
[accountingState]="accountingState"
[tour]="tour"
(click)="openTourView(tour)"
Lade Touren...
</div>
}
@if (toursQuery.data(); as result) {
@for (tour of result.data; track tour.tourId) {
<dks-accounting-item
class="block animate-in fade-in zoom-in-95 duration-200"
[accountingState]="accountingState()"
[tour]="tour"
(click)="openTourView(tour)"
>
</dks-accounting-item>
</cdk-virtual-scroll-viewport>
<!-- <ng-container *ngIf="accountingStateMeta$ | async as accountingStateMeta">
<dks-accounting-lane-footer
[tourStateMeta]="accountingStateMeta"
[withTourCount]="true"
[withRevenueSum]="accountingState !== AccountingState.Billed"
></dks-accounting-lane-footer>
</ng-container> -->
}
</dks-accounting-item>
}
@if (result.data.length === 0) {
<div
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"
>
Keine Touren in dieser Lane.
</div>
}
}
</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,36 +1,23 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
ScrollingModule
} from '@angular/cdk/scrolling';
import { AsyncPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
Input,
ViewChild,
computed,
inject,
input,
signal
} from '@angular/core';
import { AccountingListDto, PaginatedResult } from '@avicenna/shared-dtos';
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 {
BehaviorSubject,
Observable,
combineLatest,
debounceTime,
filter,
map,
mergeMap,
scan,
switchMap,
tap,
} from 'rxjs';
import {
AccountingFilterArgs,
AccountingState,
Ticket,
Tour,
AccountingState
} from '../../../core/data-access/graphql/generated/generated';
import { AccountingTourViewComponent } from '../accounting-tour-view/accounting-tour-view.component';
import { DashboardAccountingService } from '../dashboard-accounting.service';
import { AccountingItemComponent } from './accounting-item.component';
import { AccountingLaneHeaderComponent } from './accounting-lane-header.component';
@@ -39,99 +26,49 @@ import { AccountingLaneHeaderComponent } from './accounting-lane-header.componen
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './accounting-lane.component.html',
host: {
class: 'rounded h-full w-full grid',
class:
'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: [
AccountingLaneHeaderComponent,
AccountingItemComponent,
AsyncPipe,
ScrollingModule
],
ScrollingModule,
HlmPaginationImports,
],
})
export class AccountingLaneComponent {
private dialog = inject(DialogService);
private accountingService = inject(DashboardAccountingService);
private dialog = inject(DialogService)
accountingState = input.required<AccountingState>();
private accountingState$ = new BehaviorSubject<AccountingState | undefined>(
undefined
);
private accountingFilter$ = new BehaviorSubject<AccountingFilterArgs>({});
page = signal(1);
private readonly take = signal<number>(10);
@Input() set accountingState(accountingState: AccountingState) {
this.accountingState$.next(accountingState);
}
get accountingState() {
return this.accountingState$.getValue() as AccountingState;
toursQuery = useTours(this.accountingState, this.page, this.take);
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);
}
}
@ViewChild(CdkVirtualScrollViewport)
viewport!: CdkVirtualScrollViewport;
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()
)
)
);
nextPage(): void {
if (this.hasNextPage()) {
this.page.update((p) => p + 1);
}
}
openTourView(tour: Tour & { ticket: Ticket }): void {
openTourView(tour: AccountingListDto): void {
const dialogRef = this.dialog.open(AccountingTourViewComponent, {
data: tour,
data: tour.tourId,
width: '60vw',
minHeight: '400px',
});
@@ -139,59 +76,4 @@ export class AccountingLaneComponent {
.pipe(tap((result) => result && this.openTourView(result)))
.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,105 +1,291 @@
import { CurrencyPipe, NgClass, NgStyle } from '@angular/common';
import { Component, effect, input, signal } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { CurrencyPipe, DecimalPipe, NgClass } from '@angular/common';
import {
Component,
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 {
GetTourCostBreakdownGQL,
GetTourCostBreakdownQuery,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
@Component({
selector: 'app-accounting-tour-cost-breakdown',
imports: [MatDividerModule, CurrencyPipe, NgClass],
imports: [
CurrencyPipe,
DecimalPipe,
NgClass,
HlmCardImports,
NgIcon,
HlmIcon,
],
providers: [
provideIcons({ lucideReceipt, lucideAlertTriangle, lucideCheckCircle2 }),
],
template: `
@if (costBreakdown() !== undefined) {
<div class="px-4 py-2 flex justify-between">
<div class="flex flex-col text-gray-600 text-sm">
<span class="font-medium">Tarif</span>
<span>
{{ costBreakdown()?.tariffDescription }}
</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
class="w-full justify-self-start"
style="background-color: var(--mat-sys-secondary-container)"
>
Beschreibung
</div>
<!-- <div
class="w-full justify-self-center"
style="background-color: var(--mat-sys-secondary-container)"
>
Typ
</div> -->
<div
class="w-full justify-self-end"
style="background-color: var(--mat-sys-secondary-container)"
>
Wert
</div>
<div
class="w-full justify-self-end"
style="background-color: var(--mat-sys-secondary-container)"
>
Summe
</div>
@for (servicePosition of costBreakdown()?.servicePositions; track $index;
let o = $odd) {
<div class="w-full" [ngClass]="{ 'bg-gray-100': o }">
{{ servicePosition.description }}
</div>
<!-- <div class="justify-self-center w-full break-all" [ngClass]="{ 'bg-gray-100': o }">
{{ servicePosition.variant }}
</div> -->
<div class="justify-self-end w-full" [ngClass]="{ 'bg-gray-100': o }">
{{
servicePosition.value / 100
| currency : 'EUR' : 'symbol' : '.2' : 'de'
}}
</div>
<div class="justify-self-end w-full" [ngClass]="{ 'bg-gray-100': o }">
{{
sumServicePositionToIndex(costBreakdown()?.servicePositions, $index) /
100 | currency : 'EUR' : 'symbol' : '.2' : 'de'
}}
<section
hlmCard
class="m-4 border-border shadow-sm bg-card text-card-foreground overflow-hidden"
>
<div hlmCardHeader class="border-b bg-muted/20 px-6 py-4">
<div hlmCardTitle>
<span
class="text-xs font-medium text-muted-foreground uppercase tracking-wider block mb-1"
>
Angewandter Tarif
</span>
<div
class="font-semibold text-foreground text-sm flex items-center gap-2"
>
<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
</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 class="border-t border-border p-4 bg-gray-50/30">
@if (!hasDeviation()) {
<div
class="flex items-center justify-between p-2 rounded-lg border border-green-200 bg-green-50 text-green-800"
>
<div class="flex items-center gap-3">
<div
class="bg-green-100 h-8 w-8 flex justify-center items-center rounded-full"
>
<ng-icon
hlmIcon
name="lucideCheckCircle2"
class="text-green-600"
size="sm"
/>
</div>
<div class="flex flex-col">
<span class="text-sm font-bold">Umsatz abgeglichen</span>
<span class="text-xs opacity-80"
>Berechnung deckt sich mit Dispo-System</span
>
</div>
</div>
<span class="text-lg font-bold font-mono px-2">
{{
totalCalculated() | currency: 'EUR' : 'symbol' : '.2' : 'de'
}}
</span>
</div>
} @else {
<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()
| 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>
} @else {
<div
class="p-8 text-center text-muted-foreground text-sm animate-pulse"
>
Berechne Positionen...
</div>
}
</div>
</section>
`,
styles: `
.grid {
grid-template-columns: 3fr 1fr 1fr;
}
`,
styles: `.grid {grid-template-columns: 3fr 1fr 1fr;}`,
})
export class AccountingTourCostBreakdownComponent {
tour = input.required<Tour>();
private gql = inject(GetTourCostBreakdownGQL);
tour = input.required<TourDto>();
costBreakdown = signal<
GetTourCostBreakdownQuery['tourCostBreakdown'] | undefined
>(undefined);
constructor(private readonly tourCostBreakdownGql: GetTourCostBreakdownGQL) {
effect(() => {
tourCostBreakdownGql
.fetch({ operationId: this.tour().operationId })
.pipe(tap((res) => this.costBreakdown.set(res.data.tourCostBreakdown)))
constructor() {
// Data Fetching Effect
effect((onCleanup) => {
const operationId = this.tour().operationId;
if (!operationId) return;
const sub = this.gql
.fetch({ variables: { operationId } })
.pipe(tap((res) => this.costBreakdown.set(res.data?.tourCostBreakdown)))
.subscribe();
onCleanup(() => sub.unsubscribe());
});
}
sumServicePositionToIndex(
servicePositions: any[] | undefined,
idx: number
): number {
return (servicePositions ?? [])
.slice(0, idx + 1)
.reduce((sum, { value }) => (sum += value), 0);
}
// --- Computed Logic für das Template ---
/** * Berechnet die Zeilen inklusive Running Total vorab,
* damit wir keine Funktionen im Template aufrufen müssen.
*/
calculatedRows = computed(() => {
const positions = this.costBreakdown()?.servicePositions || [];
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);
});
}
@@ -0,0 +1,377 @@
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,87 +1,307 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatTabsModule } from '@angular/material/tabs';
import { Tour } from '../../../core/data-access/graphql/generated/generated';
import { DatePipe, DecimalPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { TourDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideAmbulance,
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({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-overview',
template: `
<div class="px-4 py-2 flex justify-between">
<div class="flex flex-col text-gray-600 text-sm">
<span class="font-medium">Startzeit</span>
<span>
{{ tour.startDate | date : 'dd.MM.yyyy' }}
{{ tour.startDate | date : 'HH:mm' }} Uhr
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-overview',
template: `
<div class="p-4 min-h-125 space-y-6">
<div class="flex justify-between items-start">
<div>
<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="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>
}
</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"
<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"
>
<span class="font-semibold pb-1">Fahrzeug</span>
{{ tour.carName }}
</div>
}
@if (tour.driverName) {
<div
class="flex flex-col pb-1 md:pb-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"
>
<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
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>
Erstellt: {{ tour().createdAt | date: 'dd.MM.yyyy HH:mm' }}
</span>
</div>
}
</section>
</div>
</div>
</div>
`,
styles: [],
imports: [MatTabsModule, MatIconModule, MatDividerModule, DatePipe]
`,
styles: [],
imports: [DatePipe, DecimalPipe, HlmCardImports, NgIcon, HlmIcon],
providers: [
provideIcons({
lucideAmbulance,
lucideUser,
lucideUsers,
lucideClock,
lucideCalendar,
lucideCheckCircle2,
lucideMapPin,
}),
],
})
export class AccountingTourOverviewComponent {
@Input() tour!: Tour;
tour = input.required<TourDto>();
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,14 +1,14 @@
import { DatePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/graphql/generated/generated';
import { TourDto } from '@avicenna/shared-dtos';
import { TicketValidationState } from '../../../core/data-access/graphql/generated/generated';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-ticket',
template: `
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-ticket',
template: `
<div class="grid grid-cols-4">
<mat-list class="col-span-2" role="list">
<div mat-subheader class="text-sm">Ticket Informationen</div>
@@ -16,34 +16,19 @@ import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/g
Status:
@switch (tour.ticket.currentState) {
@case (TicketValidationState.Created) {
<span
class="font-bold"
>Touren angenommen
</span>
<span class="font-bold">Touren angenommen </span>
}
@case (TicketValidationState.UserInteractionRequired) {
<span
class="font-bold"
>Genehmigung / Transportschein
</span>
<span class="font-bold">Genehmigung / Transportschein </span>
}
@case (TicketValidationState.TdUncertain) {
<span
class="font-bold"
>Transportschein unklar
</span>
<span class="font-bold">Transportschein unklar </span>
}
@case (TicketValidationState.DocumentsMissing) {
<span
class="font-bold"
>Dokumente fehlen
</span>
<span class="font-bold">Dokumente fehlen </span>
}
@case (TicketValidationState.Archived) {
<span
class="font-bold"
>Archiviert
</span>
<span class="font-bold">Archiviert </span>
}
}
</mat-list-item>
@@ -61,17 +46,11 @@ import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/g
</mat-list-item>
</mat-list>
@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>
@for (error of tour.ticket.errors; track error) {
<mat-list-item
lines="2"
role="listitem"
>
<mat-icon matListItemIcon class="!text-red-600">error</mat-icon>
<mat-list-item lines="2" role="listitem">
<mat-icon matListItemIcon class="text-red-600!">error</mat-icon>
<span matListItemTitle>{{ error.split(':').at(0) }}</span>
<span [title]="error.split(':').at(1)">{{
error.split(':').at(1)
@@ -79,11 +58,10 @@ import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/g
</mat-list-item>
}
@for (note of tour.ticket.notes; track note) {
<mat-list-item
lines="2"
role="listitem"
<mat-list-item lines="2" role="listitem">
<mat-icon matListItemIcon class="text-yellow-400!"
>warning</mat-icon
>
<mat-icon matListItemIcon class="!text-yellow-400">warning</mat-icon>
<span matListItemTitle>{{ note.split(':').at(0) }}</span>
<span [title]="note.split(':').at(1)">{{
note.split(':').at(1)
@@ -93,16 +71,12 @@ import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/g
</mat-list>
}
</div>
`,
styles: [],
imports: [
MatIconModule,
MatListModule,
MatDividerModule
]
`,
styles: [],
imports: [MatIconModule, MatListModule, MatDividerModule],
})
export class AccountingTourTicketComponent {
@Input() tour!: Tour & { ticket: Ticket };
@Input() tour!: TourDto;
TicketValidationState = TicketValidationState;
}
@@ -1,51 +1,65 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTabsModule } from '@angular/material/tabs';
import { TourDto } from '@avicenna/shared-dtos';
import { DialogRef } from '@ngneat/dialog';
import {
Ticket,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
import { AccountingTourCostBreakdownComponent } from './accounting-tour-cost-breakdown.component';
import { AccountingTourOverviewComponent } from './accounting-tour-overview.component';
import { AccountingTourTicketComponent } from './accounting-tour-ticket.component';
import { AccountingTourCostBreakdownComponent } from './accounting-tour-cost-breakdown.component';
import { useTourDetail } from 'src/app/core/data-access/accounting/accounting.queries';
import { AccountingTourOptimizationComponent } from "./accounting-tour-optimization.component";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-accounting-tour-view',
template: `<h3 class="py-2 px-3 !mb-0">
{{ ref.data.ordinanceType }} -
{{ ref.data.patientName }}
{{ ref.data.patientSurname }}
template: ` @if (tourQuery.data(); as tour) {
<h3 class="py-2 px-3 mb-0!">
{{ tour.ordinanceType }} -
{{ tour.patientName }}
{{ tour.patientSurname }}
</h3>
<hr />
<mat-tab-group>
<mat-tab label="Umsatzberechnung">
<app-accounting-tour-cost-breakdown
[tour]="ref.data"
></app-accounting-tour-cost-breakdown>
</mat-tab>
<mat-tab-group class="overflow-auto">
<mat-tab label="Übersicht">
<dks-accounting-tour-overview
[tour]="ref.data"
[tour]="tour"
></dks-accounting-tour-overview>
</mat-tab>
<mat-tab label="Ticket">
<mat-tab label="Umsatzberechnung">
<app-accounting-tour-cost-breakdown
[tour]="tour"
></app-accounting-tour-cost-breakdown>
</mat-tab>
@if(tourQuery.data()?.optimizationInMinutes != 0) {
<mat-tab label="Abrechnungsoptimierung">
<app-accounting-tour-optimization
[tour]="tour"
></app-accounting-tour-optimization>
</mat-tab>
}
<!-- <mat-tab label="Ticket">
<dks-accounting-tour-ticket
[tour]="ref.data"
></dks-accounting-tour-ticket>
</mat-tab>
</mat-tab-group> `,
</mat-tab> -->
</mat-tab-group>
}`,
styles: [],
imports: [
MatTabsModule,
MatIconModule,
AccountingTourOverviewComponent,
AccountingTourTicketComponent,
AccountingTourCostBreakdownComponent
],
AccountingTourCostBreakdownComponent,
AccountingTourOptimizationComponent
],
})
export class AccountingTourViewComponent {
ref =
inject<DialogRef<Tour & { ticket: Ticket }, undefined | any>>(DialogRef);
ref = inject<DialogRef<string, undefined | any>>(DialogRef);
tourId = signal(this.ref.data);
tourQuery = useTourDetail(this.tourId);
}
@@ -0,0 +1,122 @@
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,11 +22,10 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
class="self-center"
[accountingState]="AccountingState.PreparedForBilling"
></app-accounting-info-card>
<div>
<dks-accounting-lane
[accountingState]="AccountingState.Pending"
></dks-accounting-lane>
</div>
<dks-accounting-lane
[accountingState]="AccountingState.Pending"
></dks-accounting-lane>
<dks-accounting-lane
[accountingState]="AccountingState.Billable"
></dks-accounting-lane>
@@ -41,8 +40,8 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
height: calc(100% - 64px);
}
section {
grid-template-rows: 2rem auto 1fr;
/* grid-template-rows: 2rem 1fr; */
/* grid-template-rows: 2rem auto 1fr; */
grid-template-rows: 2rem 1fr;
}
`,
],
@@ -34,7 +34,7 @@ export class DashboardAccountingService {
}
}),
shareReplay(1),
startWith(undefined)
startWith(undefined),
);
updateTours$ = new Subject<void>();
@@ -42,7 +42,7 @@ export class DashboardAccountingService {
constructor(
private readonly getAccountingToursGql: GetAccountingToursGQL,
private readonly getAccountingStateMetaGql: GetAccountingStateMetaGQL,
private readonly toast: HotToastService
private readonly toast: HotToastService,
) {
this.updateTours$.next();
}
@@ -51,13 +51,10 @@ export class DashboardAccountingService {
state: AccountingState,
cursor: string,
filters: AccountingFilterArgs,
take = 20
take = 20,
): Observable<GetAccountingToursQuery['accountingTours']> {
const ticketsQuery = this.getAccountingToursGql.watch({
state,
cursor,
filters,
take,
variables: { state, cursor, filters, take },
});
const ticketsUpdate = combineLatest([
@@ -65,7 +62,7 @@ export class DashboardAccountingService {
this.updateTours$,
]).pipe(
switchMap(() => ticketsQuery.refetch()),
startWith(undefined)
startWith(undefined),
);
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
@@ -75,17 +72,21 @@ export class DashboardAccountingService {
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
updatedData ? updatedData : firstData,
),
map(
(response) =>
response.data
?.accountingTours as GetAccountingToursQuery['accountingTours'],
),
map((response) => response.data.accountingTours)
);
}
fetchAccountingStateMeta(
state: AccountingState
state: AccountingState,
): Observable<GetAccountingStateMetaQuery['accountingStateMeta']> {
const ticketStateMetaQuery = this.getAccountingStateMetaGql.watch({
state,
variables: { state },
});
const toursUpdate = combineLatest([
@@ -93,7 +94,7 @@ export class DashboardAccountingService {
this.updateTours$,
]).pipe(
switchMap(() => ticketStateMetaQuery.refetch()),
startWith(undefined)
startWith(undefined),
);
return combineLatest([toursUpdate, ticketStateMetaQuery.valueChanges]).pipe(
@@ -103,9 +104,13 @@ export class DashboardAccountingService {
}),
retry({ delay: 1000, count: 3 }),
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 { catchError, delay, EMPTY, map, Observable, take, tap } from 'rxjs';
import {
AcceptAnomalyGQL,
GetAnomaliesGQL,
GetGroupedAnomaliesGQL,
SetAnomalyToSleepGQL,
AcceptAnomalyGQL,
GetUpcomingToursGQL,
PaginatedAnomaly,
PaginatedAnomalyGroup,
SetAnomalyToSleepGQL,
} from '../../core/data-access/graphql/generated/generated';
@Injectable()
@@ -23,98 +23,86 @@ export class AnomaliesService {
private readonly setAnomalyToSleepGql: SetAnomalyToSleepGQL,
private readonly acceptAnomalyGql: AcceptAnomalyGQL,
private readonly upcomingToursGql: GetUpcomingToursGQL,
private readonly toast: HotToastService
private readonly toast: HotToastService,
) {
this.upcomingToursQuery = this.upcomingToursGql.watch(
{},
{ pollInterval: 60 * 1000 }
);
this.upcomingToursQuery = this.upcomingToursGql.watch({
pollInterval: 60 * 1000,
});
this.upcomingTours$ = this.upcomingToursQuery.valueChanges;
}
getAnomaliesByPage(
cursor: string,
take: number = 10
take: number = 10,
): Observable<PaginatedAnomaly> {
return this.getAnomaliesGql
.fetch(
{ cursor, take },
{
fetchPolicy: 'no-cache',
}
)
.fetch({ variables: { cursor, take }, fetchPolicy: 'no-cache' })
.pipe(
map((queryResult) => queryResult.data.anomalies),
map((queryResult) => queryResult.data?.anomalies as PaginatedAnomaly),
catchError((err) => {
console.error(err);
return EMPTY;
})
}),
);
}
getGroupedAnomaliesByPage(
cursor: string,
take: number = 10
take: number = 10,
): Observable<PaginatedAnomalyGroup> {
return this.getGroupedAnomaliesGql
.fetch(
{ cursor, take },
{
fetchPolicy: 'no-cache',
}
)
.fetch({ variables: { cursor, take }, fetchPolicy: 'no-cache' })
.pipe(
map(
(queryResult) =>
queryResult.data
.groupedAnomalies as unknown as PaginatedAnomalyGroup
?.groupedAnomalies as unknown as PaginatedAnomalyGroup,
),
catchError((err) => {
console.error(err);
return EMPTY;
})
}),
);
}
setAnomalyToSleep(anomalyId: string): void {
this.setAnomalyToSleepGql
.mutate({ id: anomalyId })
.mutate({ variables: { id: anomalyId } })
.pipe(
take(1),
tap(() => this.anomaliesRefresh$.emit()),
tap((mutationResult) => {
if (mutationResult.errors) {
if (mutationResult.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 {
this.toast.success(
'Die Anomalie wurde für eine Stunde stummgeschaltet!'
'Die Anomalie wurde für eine Stunde stummgeschaltet!',
);
}
})
}),
)
.subscribe();
}
acceptAnomaly(anomalyId: string): void {
this.acceptAnomalyGql
.mutate({ id: anomalyId })
.mutate({ variables: { id: anomalyId } })
.pipe(
take(1),
delay(10),
tap(() => this.anomaliesRefresh$.emit()),
tap((mutationResult) => {
if (mutationResult.errors) {
if (mutationResult.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 {
this.toast.success('Die Anomalie wurde akzeptiert!');
}
})
}),
)
.subscribe();
}
@@ -43,31 +43,25 @@ export class AnomalyViewComponent {
constructor() {
effect(() => {
this.anomalyByIdGql
.fetch({ id: this.anomalyId() })
.fetch({ variables: { id: this.anomalyId()} })
.pipe(
takeUntilDestroyed(this.destroyRef),
tap((res) => this.anomaly.set(res.data.anomaly))
tap((res) => this.anomaly.set(res.data?.anomaly))
)
.subscribe();
this.associatedEntitiesGql
.fetch({ id: this.anomalyId() })
.fetch({ variables:{id: this.anomalyId()} })
.pipe(
takeUntilDestroyed(this.destroyRef),
map((res) => res.data.associatedEntities),
map((res) => res.data?.associatedEntities),
tap(
({
tour,
attendanceRegistration,
plannedTime,
workTime,
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);
(props) => {
if (props?.tour) this.tours.set(props?.tour as Tour[]);
if (props?.attendanceRegistration)
this.attendanceRegistrations.set(props?.attendanceRegistration);
if (props?.plannedTime) this.plannedTimes.set(props?.plannedTime);
if (props?.workTime) this.workTimes.set(props?.workTime);
if (props?.employee) this.employees.set(props?.employee);
}
)
)
@@ -12,11 +12,11 @@ import {
} from '../../../../core/data-access/graphql/generated/generated';
@Component({
selector: 'dks-tours-per-time-kpi',
templateUrl: './tours-per-time-kpi.component.html',
styleUrls: ['./tours-per-time-kpi.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
selector: 'dks-tours-per-time-kpi',
templateUrl: './tours-per-time-kpi.component.html',
styleUrls: ['./tours-per-time-kpi.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ToursPerTimeKPIComponent implements OnInit {
@Input() ordinanceType!: string;
@@ -27,13 +27,16 @@ export class ToursPerTimeKPIComponent implements OnInit {
currentTimeSlot$!: Observable<number>;
constructor(
private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL
private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL,
) {}
ngOnInit(): void {
this.toursPerTimeKPI$ = this.getToursPerTimeKPIGql
.watch({ ordinanceType: this.ordinanceType }, { pollInterval: 60000 })
.valueChanges.pipe(map((result) => result.data.toursPerTimeKPI));
.watch({
variables: { ordinanceType: this.ordinanceType },
pollInterval: 60000,
})
.valueChanges.pipe(map((result) => result.data?.toursPerTimeKPI as GetTourAmountPerTimeKpiQuery['toursPerTimeKPI']));
this.currentTimeSlot$ = combineLatest([
interval(60000).pipe(startWith(0)),
this.toursPerTimeKPI$,
@@ -43,9 +46,9 @@ export class ToursPerTimeKPIComponent implements OnInit {
this.isInInterval({
start: kpi.interval.start,
end: kpi.interval.end,
})
)
)
}),
),
),
);
}
@@ -21,9 +21,9 @@ export class UpcomingToursComponent {
private readonly diaolog: DialogService
) {
this.upcomingTours$ = this.anomaliesService.upcomingTours$.pipe(
map((result) => result.data.upcomingTours),
map((result) => result.data?.upcomingTours),
map((tours) =>
tours
(tours ?? [])
.map((tour) => ({
...tour,
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="ALL"></app-manager-info-card> -->
</div>
<div
<!-- <div
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>
@@ -167,7 +167,7 @@ import { LinkCardComponent } from './link-card/link-card.component';
<app-ticket-state-info-card
[ticketState]="TicketState.DocumentsMissing"
></app-ticket-state-info-card>
</div>
</div> -->
</div>
`,
styles: ``,
@@ -210,7 +210,7 @@ export class HomeComponent implements OnInit {
effect(() => {
this.homeDashboardGql
.fetch()
.pipe(tap((res) => this.kpis.set(res.data.homeDashboardKpis)))
.pipe(tap((res) => this.kpis.set(res.data?.homeDashboardKpis)))
.subscribe();
});
}
@@ -1,22 +1,22 @@
<!-- <ng-container
*skeleton="isLoading(); repeat: 5; className: 'mr-2'; height: '50px'"
></ng-container> -->
@if (kpi()?.main?.label) {
<app-info-card
[chartValue]="kpi()?.main?.value!"
[chartLabel]="kpi()?.main?.label!"
[title]="ordinanceType()"
>
<app-info-card-content
[value]="kpi()?.secondary?.value | number : '.0-1' : 'de'"
[label]="kpi()?.secondary?.label"
></app-info-card-content>
@for (metric of kpi()?.additional; track $index) {
<app-info-card-footer
[value]="metric.value | number : '.0-2' : 'de'"
[label]="metric.label"
>
</app-info-card-footer>
}
</app-info-card>
@if (kpiQuery.isPending()) {
<hlm-skeleton class="h-75 w-92.5"></hlm-skeleton>
}
@if (kpiQuery.data(); as result) {
<app-info-card
[chartValue]="result.main.value"
[chartLabel]="result.main.label"
[title]="ordinanceType()"
>
<app-info-card-content
[value]="result.secondary.value | number: '.0-1' : 'de'"
[label]="result.secondary.label"
></app-info-card-content>
@for (metric of result.additional; track $index) {
<app-info-card-footer
[value]="metric.value | number: '.0-2' : 'de'"
[label]="metric.label"
>
</app-info-card-footer>
}
</app-info-card>
}
@@ -1,38 +1,50 @@
import { DecimalPipe } from '@angular/common';
import { Component, effect, inject, input, signal } from '@angular/core';
import { tap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Component, inject, input } from '@angular/core';
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 {
KpiInfo,
Metric,
} from '../../../../core/data-access/graphql/generated/generated';
import { ManagerDashboardService } from '../../manager-dashboard.service';
@Component({
selector: 'app-manager-info-card',
imports: [InfoCardModule, DecimalPipe],
imports: [InfoCardModule, DecimalPipe, HlmSkeleton],
templateUrl: './manager-info-card.component.html',
styleUrl: './manager-info-card.component.scss',
})
export class ManagerInfoCardComponent {
private service = inject(ManagerDashboardService);
private readonly http = inject(HttpClient);
isLoading = signal<boolean>(false);
ordinanceType = input.required<string>();
kpi = signal<KpiInfo | undefined>(undefined);
metrics = signal<Metric[]>([]);
kpiQuery = injectQuery(() => ({
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() {
effect(() => {
this.isLoading.set(true);
this.service
.fetchManagerKpi(this.ordinanceType())
.pipe(
tap((res) => this.metrics.set(res.additional)),
tap((res) => this.kpi.set(res)),
tap(() => this.isLoading.set(false))
)
.subscribe();
});
// effect(() => {
// this.isLoading.set(true);
// this.service
// .fetchManagerKpi(this.ordinanceType())
// .pipe(
// tap((res) => this.metrics.set(res.additional)),
// tap((res) => this.kpi.set(res)),
// tap(() => this.isLoading.set(false)),
// )
// .subscribe();
// });
}
}
@@ -12,23 +12,25 @@ import { TimePerTourMetricComponent } from '../time-per-tour-metric/time-per-tou
import { TimePerTourSummaryComponent } from '../time-per-tour-summary/time-per-tour-summary.component';
@Component({
selector: 'dks-metric-summary',
imports: [
CommonModule,
AverageRevenueMetricComponent,
AverageRevenueSummaryComponent,
BreakevenMetricComponent,
BreakevenSummaryComponent,
TimePerTourMetricComponent,
TimePerTourSummaryComponent,
MatSlideToggleModule,
],
templateUrl: './metric-summary.component.html',
styleUrls: ['./metric-summary.component.css']
selector: 'dks-metric-summary',
imports: [
CommonModule,
AverageRevenueMetricComponent,
AverageRevenueSummaryComponent,
BreakevenMetricComponent,
BreakevenSummaryComponent,
TimePerTourMetricComponent,
TimePerTourSummaryComponent,
MatSlideToggleModule,
],
templateUrl: './metric-summary.component.html',
styleUrls: ['./metric-summary.component.css'],
})
export class MetricSummaryComponent {
@Input() set metrics(metrics: TimePerTourMetric[]) {
this._metrics$.next(metrics);
@Input() set metrics(metrics: TimePerTourMetric[] | undefined) {
if (metrics) {
this._metrics$.next(metrics);
}
}
@Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour;
@Input() metricTimeScale!: MetricTimeScale;
@@ -41,7 +43,7 @@ export class MetricSummaryComponent {
vm$ = this._metrics$.pipe(
map((metrics) => ({
metrics,
}))
})),
);
getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string {
@@ -51,7 +53,7 @@ export class MetricSummaryComponent {
return 'd.M.yy';
} else {
throw new Error(
'No label format for time scale defined, TimeScale: ' + timeScale
'No label format for time scale defined, TimeScale: ' + timeScale,
);
}
}
@@ -9,13 +9,7 @@ import { summarizeMetrics } from '../../util/summarize-metrics';
@Component({
selector: 'dks-view-type-selector',
imports: [
NgClass,
AsyncPipe,
CurrencyPipe,
MatIconModule,
MatCardModule
],
imports: [NgClass, AsyncPipe, CurrencyPipe, MatIconModule, MatCardModule],
templateUrl: './view-type-selector.component.html',
styleUrls: ['./view-type-selector.component.css'],
})
@@ -23,14 +17,16 @@ export class ViewTypeSelectorComponent {
@Input() viewType!: DashboardViewType;
@Input() currentMetric!: DiagrammType;
@Input() selected = false;
@Input() set metrics(metrics: TimePerTourMetric[]) {
this.metrics$.next(metrics);
@Input() set metrics(metrics: TimePerTourMetric[] | undefined) {
if (metrics) {
this.metrics$.next(metrics);
}
}
@Output() viewTypeSelected = new EventEmitter<void>();
private metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
metricSummary$ = this.metrics$.pipe(
map((metrics) => summarizeMetrics(metrics))
map((metrics) => summarizeMetrics(metrics)),
);
DiagrammType = DiagrammType;
@@ -8,7 +8,7 @@
</button>
<div class="flex items-center">
<span class="pr-2">
{{ vm.selectedDate | date : "dd.MM.yyyy" }}
{{ vm.selectedDate | date: "dd.MM.yyyy" }}
</span>
<input
[formControl]="selectedDate"
@@ -1,14 +1,18 @@
import { Component } from '@angular/core';
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { MatChipListboxChange } from '@angular/material/chips';
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { addDays, addHours, format, isSunday } from 'date-fns';
import {
BehaviorSubject,
combineLatest,
filter,
map,
Observable,
startWith,
switchMap,
} from 'rxjs';
import { getDayviewQuery } from 'src/app/core/data-access/managers/manager.queries';
import { groupBy } from '../../../core/util/group-by';
import { ManagerDashboardService } from '../manager-dashboard.service';
import {
@@ -20,10 +24,10 @@ import {
import { mergeMetrics } from '../util/merge-metric';
@Component({
selector: 'dks-dayview',
templateUrl: './dayview.component.html',
styleUrls: ['./dayview.component.css'],
standalone: false
selector: 'dks-dayview',
templateUrl: './dayview.component.html',
styleUrls: ['./dayview.component.css'],
standalone: false,
})
export class DayviewComponent {
OrdinanceTypes = OrdinanceTypes;
@@ -33,49 +37,39 @@ export class DayviewComponent {
metricTimeScale = MetricTimeScale.Dayview;
selectedDate = new FormControl<Date>(new Date());
selectedDate$ = this.selectedDate.valueChanges.pipe(
startWith(this.selectedDate.value),
map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date))
);
startWith(this.selectedDate.value as Date),
map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date)),
) as Observable<Date>;
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
DashboardViewType.Gesamt
DashboardViewType.Gesamt,
);
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(
private readonly managerDashboardService: ManagerDashboardService
private readonly managerDashboardService: ManagerDashboardService,
) {
this.vm$ = combineLatest([
this.selectedDate$.pipe(
switchMap((selectedDate) =>
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()
)
)
),
toObservable(this.btwQuery.data),
toObservable(this.tswQuery.data),
toObservable(this.ktwQuery.data),
this.selectedMetric$,
this.selectedDate$,
this.managerDashboardService.isLoadingDayview$,
this.selectedViewType$,
]).pipe(
filter(
([btwMetrics, tswMetrics, ktwMetrics]) =>
!!btwMetrics && !!tswMetrics && !!ktwMetrics,
),
map(
([
btwMetrics,
@@ -96,13 +90,17 @@ export class DayviewComponent {
combinedMetrics: mergeMetrics(
Object.values(
groupBy(
[...btwMetrics, ...tswMetrics, ...ktwMetrics],
({ interval: { start } }) => format(new Date(start), 'HH:mm')
)
)
[
...(btwMetrics as TimePerTourMetricDto[]),
...(tswMetrics as TimePerTourMetricDto[]),
...(ktwMetrics as TimePerTourMetricDto[]),
],
({ interval: { start } }) => format(new Date(start), 'HH:mm'),
),
),
),
})
)
}),
),
);
}
@@ -113,7 +111,7 @@ export class DayviewComponent {
addDayToSelectedDate(days: number): void {
const nextSelectedDate = addDays(
this.selectedDate.value ?? new Date(),
days
days,
);
this.selectedDate.setValue(nextSelectedDate);
}
@@ -57,7 +57,9 @@ export class ManagerDashboardService {
ordinanceType: string
): Observable<ManagerKpiQuery['managerKpi']> {
const managerKpiQuery = this.managerKpiGql.watch({
ordinanceType,
variables: {
ordinanceType,
}
});
const update = this.timePerTourUpdate$.pipe(
@@ -74,44 +76,48 @@ export class ManagerDashboardService {
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
),
map((response) => response.data.managerKpi)
map((response) => response.data?.managerKpi as ManagerKpiQuery['managerKpi'])
);
}
getTimePerTourKPIByOrdinanceTypes(
ordinanceType: string
): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> {
const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({
ordinanceType,
});
// getTimePerTourKPIByOrdinanceTypes(
// ordinanceType: string
// ): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> {
// const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({
// variables: {
// ordinanceType,
// }
// });
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
switchMap(() => timePerTourKPIQuery.refetch()),
startWith(undefined)
);
return combineLatest([
timePerTourKPIUpdate,
timePerTourKPIQuery.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.toursPerTimeKPI)
);
}
// const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
// switchMap(() => timePerTourKPIQuery.refetch()),
// startWith(undefined)
// );
// return combineLatest([
// timePerTourKPIUpdate,
// timePerTourKPIQuery.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?.toursPerTimeKPI as GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI'])
// );
// }
getTimePerTourMetricByOrdinanceTypeForDay(
ordinanceType: string,
day: Date
): Observable<GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']> {
const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({
ordinanceType,
day,
variables: {
ordinanceType,
day,
}
});
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
@@ -134,7 +140,7 @@ export class ManagerDashboardService {
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
),
map((response) => response.data.timePerTourMetricForDay)
map((response) => response.data?.timePerTourMetricForDay as GetTimePerTourMetricForDayQuery['timePerTourMetricForDay'])
);
}
@@ -146,10 +152,12 @@ export class ManagerDashboardService {
> {
const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch(
{
ordinanceType,
day,
variables: {
ordinanceType,
day,
},
errorPolicy: 'all'
},
{ errorPolicy: 'all' }
);
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
@@ -173,10 +181,10 @@ export class ManagerDashboardService {
updatedData ? updatedData : firstData
),
map((response) => {
if (response.error || response.errors) {
console.error(response.error, response.errors);
if (response.error || response.error) {
console.error(response.error, response.error);
}
return response.data.timePerTourMetricForMonth;
return response.data?.timePerTourMetricForMonth as GetTimePerTourMetricForMonthQuery['timePerTourMetricForMonth'];
})
);
}
@@ -1,15 +1,19 @@
import { Component } from '@angular/core';
import { Component, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { MatChipListboxChange } from '@angular/material/chips';
import { MatDatepicker } from '@angular/material/datepicker';
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns';
import {
BehaviorSubject,
combineLatest,
filter,
map,
Observable,
startWith,
switchMap,
} from 'rxjs';
import { getMonthviewQuery } from 'src/app/core/data-access/managers/manager.queries';
import { groupBy } from '../../../core/util/group-by';
import { ManagerDashboardService } from '../manager-dashboard.service';
import {
@@ -34,48 +38,39 @@ export class MonthviewComponent {
metricTimeScale = MetricTimeScale.Monthview;
selectedDate = new FormControl<Date>(addDays(startOfMonth(new Date()), 1));
selectedDate$ = this.selectedDate.valueChanges.pipe(
startWith(this.selectedDate.value)
);
startWith(this.selectedDate.value as Date),
map((data) => data),
) as Observable<Date>;
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
DashboardViewType.Gesamt
DashboardViewType.Gesamt,
);
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(
private readonly managerDashboardService: ManagerDashboardService
private readonly managerDashboardService: ManagerDashboardService,
) {
this.vm$ = combineLatest([
this.selectedDate$.pipe(
switchMap((selectedDate) =>
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()
)
)
),
toObservable(this.btwQuery.data),
toObservable(this.tswQuery.data),
toObservable(this.ktwQuery.data),
this.selectedMetric$,
this.selectedDate$,
this.managerDashboardService.isLoadingMonthview$,
this.selectedViewType$,
]).pipe(
filter(
([btwMetrics, tswMetrics, ktwMetrics]) =>
!!btwMetrics && !!tswMetrics && !!ktwMetrics,
),
map(
([
btwMetrics,
@@ -96,13 +91,17 @@ export class MonthviewComponent {
combinedMetrics: mergeMetrics(
Object.values(
groupBy(
[...btwMetrics, ...tswMetrics, ...ktwMetrics],
({ interval: { start } }) => format(new Date(start), 'd.M.yy')
)
)
[
...(btwMetrics as TimePerTourMetricDto[]),
...(tswMetrics as TimePerTourMetricDto[]),
...(ktwMetrics as TimePerTourMetricDto[]),
],
({ interval: { start } }) => format(new Date(start), 'd.M.yy'),
),
),
),
})
)
}),
),
);
}
@@ -113,7 +112,7 @@ export class MonthviewComponent {
addMonthToSelectedDate(months: number): void {
const nextSelectedDate = addMonths(
this.selectedDate.value ?? new Date(),
months
months,
);
if (isSunday(nextSelectedDate)) {
this.selectedDate.setValue(addDays(nextSelectedDate, 1));
@@ -128,7 +127,7 @@ export class MonthviewComponent {
setMonthAndYear(
normalizedMonthAndYear: Date,
datepicker: MatDatepicker<Date>
datepicker: MatDatepicker<Date>,
) {
this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1));
datepicker.close();
@@ -1,44 +1,40 @@
@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">
<!-- 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"
[ngClass]="{
'border': vm.selectedOrdinanceType === OrdinanceTypes.BTW
}"
style="border-color: var(--mat-sys-primary);"
[ngClass]="{
border: vm.selectedOrdinanceType === OrdinanceTypes.BTW,
}"
style="border-color: var(--mat-sys-primary)"
[text]="OrdinanceTypes.BTW"
[kpis]="vm.btwKPI.summaries"
(click)="setSelectedOrdinanceType(OrdinanceTypes.BTW)"
></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"
[ngClass]="{
'border': vm.selectedOrdinanceType === OrdinanceTypes.TSW
}"
style="border-color: var(--mat-sys-primary);"
[ngClass]="{
border: vm.selectedOrdinanceType === OrdinanceTypes.TSW,
}"
style="border-color: var(--mat-sys-primary)"
[text]="OrdinanceTypes.TSW"
[kpis]="vm.tswKPI.summaries"
(click)="setSelectedOrdinanceType(OrdinanceTypes.TSW)"
></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"
[ngClass]="{
'border': vm.selectedOrdinanceType === OrdinanceTypes.KTW
}"
style="border-color: var(--mat-sys-primary);"
[ngClass]="{
border: vm.selectedOrdinanceType === OrdinanceTypes.KTW,
}"
style="border-color: var(--mat-sys-primary)"
[text]="OrdinanceTypes.KTW"
[kpis]="vm.ktwKPI.summaries"
(click)="setSelectedOrdinanceType(OrdinanceTypes.KTW)"
></dks-time-per-tour-kpi>
</div>
@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 -->
<mat-card appearance="outlined" class="w-full">
<mat-card-content>
@@ -1,58 +1,75 @@
import { Component } from '@angular/core';
import { format, Interval } from 'date-fns';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { BehaviorSubject, combineLatest, filter, map } from 'rxjs';
import { ManagerDashboardService } from '../manager-dashboard.service';
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({
selector: 'dks-overview',
templateUrl: './overview.component.html',
styleUrls: ['./overview.component.css'],
standalone: false
selector: 'dks-overview',
templateUrl: './overview.component.html',
styleUrls: ['./overview.component.css'],
standalone: false,
})
export class OverviewComponent {
OrdinanceTypes = OrdinanceTypes;
selectedOrdinanceType$ = new BehaviorSubject<OrdinanceTypes | undefined>(
undefined
undefined,
);
vm$;
btwOverviewQuery = getOverviewQuery(OrdinanceTypes.BTW);
tswOverviewQuery = getOverviewQuery(OrdinanceTypes.TSW);
ktwOverviewQuery = getOverviewQuery(OrdinanceTypes.KTW);
constructor(
private readonly managerDashboardService: ManagerDashboardService
) {
this.vm$ = combineLatest([
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
OrdinanceTypes.BTW
),
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
OrdinanceTypes.TSW
),
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
OrdinanceTypes.KTW
),
toObservable(this.btwOverviewQuery.data),
toObservable(this.tswOverviewQuery.data),
toObservable(this.ktwOverviewQuery.data),
this.selectedOrdinanceType$,
]).pipe(
filter(([btw, tsw, ktw]) => !!btw && !!tsw && !!ktw),
map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({
btwKPI: {
summaries: btwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
cars: this.createCarTableData(
btwKPIs.map(({ interval, cars }) => ({ interval, cars }))
),
summaries:
btwKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [],
cars:
this.createCarTableData(
(btwKPIs as TimePerTourKPISummaryDto[]).map(
({ interval, cars }) => ({ interval, cars }),
),
) ?? [],
},
tswKPI: {
summaries: tswKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
cars: this.createCarTableData(
tswKPIs.map(({ interval, cars }) => ({ interval, cars }))
),
summaries:
tswKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [],
cars:
this.createCarTableData(
(tswKPIs as TimePerTourKPISummaryDto[]).map(
({ interval, cars }) => ({ interval, cars }),
),
) ?? [],
},
ktwKPI: {
summaries: ktwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
cars: this.createCarTableData(
ktwKPIs.map(({ interval, cars }) => ({ interval, cars }))
),
summaries:
ktwKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [],
cars:
this.createCarTableData(
(ktwKPIs as TimePerTourKPISummaryDto[]).map(
({ interval, cars }) => ({ interval, cars }),
),
) ?? [],
},
selectedOrdinanceType,
}))
})),
);
}
@@ -74,15 +91,15 @@ export class OverviewComponent {
meta: { availableTime: number; tourAmount: number };
};
}[];
}[]
}[],
) {
const columns = [
{ id: 'carName', title: 'Fahrzeug' },
...carsByInterval.map(({ interval }, index) => ({
...carsByInterval?.map(({ interval }, index) => ({
id: `slot${index}`,
title: `${format(new Date(interval.start), 'HH:mm')} - ${format(
new Date(interval.end),
'HH:mm'
'HH:mm',
)}`,
})),
];
@@ -106,7 +123,7 @@ export class OverviewComponent {
}
return newGroup;
},
{} as Record<string, any>
{} as Record<string, any>,
);
return { columns, data: Object.values(groupedByCars) };
}
@@ -0,0 +1,341 @@
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),
startWith(undefined)
startWith(undefined),
);
updateTickets$ = new Subject<void>();
@@ -55,7 +55,7 @@ export class TicketsService {
private readonly setDocumentInfoGql: SetDocumentInfoGQL,
private readonly ticketStateKpiGql: TicketStateKpiGQL,
private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
private readonly toast: HotToastService
private readonly toast: HotToastService,
) {
this.updateTickets$.next();
}
@@ -63,21 +63,21 @@ export class TicketsService {
sendApprovalRequest(ticketId: string, email: string) {
return this.sendApprovalRequestGql
.mutate({
email,
ticketId,
variables: {
email,
ticketId,
},
})
.pipe(
tap((mutationResult) => {
if (mutationResult.errors) {
if (mutationResult.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 {
this.toast.success(
'Die Genehmigungsanfrage wird versendet.'
);
this.toast.success('Die Genehmigungsanfrage wird versendet.');
}
})
}),
);
}
@@ -85,13 +85,15 @@ export class TicketsService {
state: TicketValidationState,
cursor: string,
filters: TicketFilterArgs,
take = 20
take = 20,
): Observable<GetTicketsQuery['tickets']> {
const ticketsQuery = this.getTicketsGql.watch({
state,
cursor,
filters,
take,
variables: {
state,
cursor,
filters,
take,
},
});
const ticketsUpdate = combineLatest([
@@ -99,7 +101,7 @@ export class TicketsService {
this.updateTickets$,
]).pipe(
switchMap(() => ticketsQuery.refetch()),
startWith(undefined)
startWith(undefined),
);
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
@@ -109,17 +111,19 @@ export class TicketsService {
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
updatedData ? updatedData : firstData,
),
map((response) => response.data.tickets)
map((response) => response.data?.tickets as GetTicketsQuery['tickets']),
);
}
fetchTicketStateMeta(
state: TicketValidationState
state: TicketValidationState,
): Observable<GetTicketStateMetaQuery['ticketStateMeta']> {
const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({
state,
variables: {
state,
},
});
const ticketsUpdate = combineLatest([
@@ -127,7 +131,7 @@ export class TicketsService {
this.updateTickets$,
]).pipe(
switchMap(() => ticketStateMetaQuery.refetch()),
startWith(undefined)
startWith(undefined),
);
return combineLatest([
@@ -140,17 +144,21 @@ export class TicketsService {
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
updatedData ? updatedData : firstData,
),
map(
(response) =>
response.data
?.ticketStateMeta as GetTicketStateMetaQuery['ticketStateMeta'],
),
map((response) => response.data.ticketStateMeta)
);
}
fetchTicketStateKpi(
state: TicketValidationState
state: TicketValidationState,
): Observable<TicketStateKpiQuery['ticketStateKpi']> {
const ticketStateKpiQuery = this.ticketStateKpiGql.watch({
state,
variables: { state },
});
const ticketsUpdate = combineLatest([
@@ -158,7 +166,7 @@ export class TicketsService {
this.updateTickets$,
]).pipe(
switchMap(() => ticketStateKpiQuery.refetch()),
startWith(undefined)
startWith(undefined),
);
return combineLatest([
@@ -171,36 +179,45 @@ export class TicketsService {
}),
retry({ delay: 1000, count: 3 }),
map(([updatedData, firstData]) =>
updatedData ? updatedData : firstData
updatedData ? updatedData : firstData,
),
map(
(response) =>
response.data
?.ticketStateKpi as TicketStateKpiQuery['ticketStateKpi'],
),
map((response) => response.data.ticketStateKpi)
);
}
searchTickets(
query: string
): Observable<SearchTicketsQuery['searchTickets']> {
query: string,
): Observable<SearchTicketsQuery['searchTickets'] | undefined> {
return this.searchTicketsGql
.fetch({ query })
.pipe(map((response) => response.data.searchTickets));
.fetch({ variables: { query } })
.pipe(map((response) => response.data?.searchTickets));
}
setDocumentInfo(
ticketId: string,
{ approval, tdLocation }: Omit<SetDocumentInfoMutationVariables, 'ticketId'>
{
approval,
tdLocation,
}: Omit<SetDocumentInfoMutationVariables, 'ticketId'>,
) {
if ((approval as any)?.['__typename'])
delete (approval as any)['__typename'];
return this.setDocumentInfoGql
.mutate({
ticketId,
approval,
tdLocation,
variables: {
ticketId,
approval,
tdLocation,
},
})
.pipe(
map((res) => res.data?.setDocumentInfo),
tap(() => this.updateTickets$.next())
tap(() => this.updateTickets$.next()),
);
}
}
@@ -15,3 +15,54 @@
<span>{{ ticket.tourCount }} Tour{{ ticket.tourCount > 1 ? "en" : "" }}</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,7 +20,8 @@ import CategoryColors from './category-colors';
import { TicketListItemDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { lucideCircleAlert } from '@ng-icons/lucide';
import { lucideCalendarClock, lucideCar, lucideCircleAlert } from '@ng-icons/lucide';
import { DatePipe } from '@angular/common';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -38,9 +39,11 @@ import { lucideCircleAlert } from '@ng-icons/lucide';
`,
],
providers: [provideIcons({
lucideCircleAlert
lucideCircleAlert,
lucideCalendarClock,
lucideCar
})],
imports: [NgIcon, HlmIcon],
imports: [NgIcon, HlmIcon, DatePipe],
})
export class TicketItemComponent implements OnInit {
hasActivity = input(false);
@@ -2,17 +2,20 @@
class="shadow"
[state]="ticketState"
></dks-ticket-lane-header>
<div class="flex-1 overflow-auto p-2 space-y-2">
<div class="overflow-auto p-2 space-y-2">
@if (ticketsQuery.isPending()) {
<p>Lade Tickets...</p>
} @if (ticketsQuery.data(); as result) { @for (ticket of result.data; track
ticket.id) {
<dks-ticket-item [ticket]="ticket" (click)="openTicketView(ticket)" />
} @if (result.data.length === 0) {
<div class="text-center p-4 text-muted-foreground">
Keine Tickets in dieser Phase.
</div>
} }
<p>Lade Tickets...</p>
}
@if (ticketsQuery.data(); as result) {
@for (ticket of result.data; track ticket.id) {
<dks-ticket-item [ticket]="ticket" (click)="openTicketView(ticket)" class="mb-2"/>
}
@if (result.data.length === 0) {
<div class="text-center p-4 text-muted-foreground">
Keine Tickets in dieser Phase.
</div>
}
}
</div>
<nav hlmPagination class="mt-auto border-t p-2">
@@ -26,8 +29,10 @@
<li hlmPaginationItem>
<span class="text-sm px-4">
Seite {{ page() }} @if (ticketsQuery.data(); as res) { von
{{ res.meta.totalPages }}
Seite {{ page() }}
@if (ticketsQuery.data(); as res) {
von
{{ res.meta.totalPages }}
}
</span>
</li>
@@ -1,39 +1,31 @@
import {
CdkVirtualScrollViewport,
ScrollingModule,
ScrollingModule
} from '@angular/cdk/scrolling';
import { AsyncPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
ViewChild,
computed,
inject,
input,
output,
signal,
signal
} from '@angular/core';
import { DialogService, DialogCloseDirective } from '@ngneat/dialog';
import { PaginatedResult, TicketListItemDto } from '@avicenna/shared-dtos';
import { DialogService } from '@ngneat/dialog';
import { QueryClient } from '@tanstack/angular-query-experimental';
import { BehaviorSubject, tap } from 'rxjs';
import { HlmPaginationImports } from 'src/app/core/base-components/pagination/src';
import { HlmScrollArea } from 'src/app/core/base-components/scroll-area/src';
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
import { useTicketLane } from 'src/app/core/tickets/tickets.queries';
import {
Ticket,
TicketFilterArgs,
TicketValidationState,
Tour,
TicketValidationState
} from '../../../core/data-access/graphql/generated/generated';
import { ActivityService } from '../../../core/services/activity.service';
import { TicketItemComponent } from '../ticket-item/ticket-item.component';
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
import { TicketsService } from '../tickets.service';
import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
import { PaginatedResult, TicketListItemDto } from '@avicenna/shared-dtos';
import { QueryClient } from '@tanstack/angular-query-experimental';
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -47,18 +39,15 @@ import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
:host {
box-shadow: var(--mat-sys-level1);
background-color: var(--mat-sys-surface-container-low);
grid-template-rows: auto 1fr auto;
grid-template-rows: auto 1fr;
}
`,
],
imports: [
TicketLaneHeaderComponent,
TicketItemComponent,
AsyncPipe,
ScrollingModule,
HlmScrollArea,
HlmPaginationImports,
DialogCloseDirective,
],
})
export class TicketLaneComponent {
@@ -71,7 +60,7 @@ export class TicketLaneComponent {
ticketsQuery = useTicketLane(this.stage, this.page);
hasNextPage = computed(() => {
const data: PaginatedResult<TicketListItemDto> = this.ticketsQuery.data();
const data: PaginatedResult<TicketListItemDto> | undefined = this.ticketsQuery.data();
if (!data) return false;
return this.page() < data.meta.totalPages;
});
@@ -26,14 +26,14 @@ export class TicketStateInfoCardComponent {
constructor() {
effect(() => {
this.isLoading.set(true);
this.service
.fetchTicketStateKpi(this.ticketState())
.pipe(
tap((res) => this.metrics.set(res.additional)),
tap((res) => this.kpi.set(res)),
tap(() => this.isLoading.set(false))
)
.subscribe();
// this.service
// .fetchTicketStateKpi(this.ticketState())
// .pipe(
// tap((res) => this.metrics.set(res.additional)),
// tap((res) => this.kpi.set(res)),
// tap(() => this.isLoading.set(false))
// )
// .subscribe();
});
}
@@ -6,6 +6,7 @@ import {
inject,
input,
output,
signal,
} from '@angular/core';
import { TicketDetailDto } from '@avicenna/shared-dtos';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -23,6 +24,8 @@ import {
lucideMapPin,
lucideRepeat,
lucideShieldCheck,
lucideSquare,
lucideSquareCheck,
lucideStethoscope,
lucideTriangleAlert,
} from '@ng-icons/lucide';
@@ -41,6 +44,10 @@ import { environment } from 'src/environments/environment';
import { lastValueFrom } from 'rxjs';
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 { 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({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -169,9 +176,9 @@ import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components
<section
hlmCard
class="p-4 flex flex-col justify-between"
[class.border-l-4]="!ticket().transportDocument.isUploaded"
[class.border-l-4]="!ticket().transportDocument.isDocumentPresent"
[class.border-l-destructive]="
!ticket().transportDocument.isUploaded
!ticket().transportDocument.isDocumentPresent
"
>
<div class="flex items-start justify-between">
@@ -191,27 +198,23 @@ import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components
<p
class="text-xs"
[class.text-destructive]="
!ticket().transportDocument.isUploaded
!ticket().transportDocument.isDocumentPresent
"
[class.text-muted-foreground]="
ticket().transportDocument.isUploaded
ticket().transportDocument.isDocumentPresent
"
>
@if (ticket().transportDocument.isUploaded) {
Bereits hochgeladen
} @else {
Dokument erforderlich
}
{{ ticket().transportDocument.label }}
</p>
</div>
</div>
@if (ticket().transportDocument.isUploaded) {
@if (ticket().transportDocument.digitalDownloadLink) {
<a
hlmBtn
size="icon"
variant="ghost"
[href]="ticket().transportDocument.link"
[href]="ticket().transportDocument.digitalDownloadLink"
target="_blank"
class="text-green-600 hover:bg-green-50"
>
@@ -220,14 +223,26 @@ import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components
}
</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
[ticketId]="ticket().id"
(uploaded)="uploadedAction.emit()"
/>
</div>
}
}
</div>
</section>
<app-ticket-approval
[ticketId]="ticket().id"
@@ -313,7 +328,7 @@ import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components
lucideShieldCheck,
lucideCalendarClock,
lucideBiohazard,
lucideSquareCheck,
lucideArrowRight,
lucideCalendar,
lucideMapPin,
@@ -341,9 +356,13 @@ import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components
],
})
export class TicketOverviewComponent {
http = inject(HttpClient);
private toast = inject(HotToastService);
private queryClient = inject(QueryClient);
private actions = inject(TicketActionsService);
ticket = input.required<TicketDetailDto>();
uploadedAction = output<void>({ alias: 'uploaded' });
isSubmitting = signal(false);
ApprovalState = ApprovalState;
stageVariant = computed(() => {
@@ -357,18 +376,24 @@ export class TicketOverviewComponent {
}
});
sendApprovalRequest() {
console.log(`approval request not implemented`);
async receivePhysical() {
this.isSubmitting.set(true);
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);
}
}
overrideApprovalRequirement() {
console.log(`request not implemented`);
const url = `${environment.api.baseUrl}/tickets/${
this.ticket().id
}/approval/override`;
lastValueFrom(this.http.post(url, {})).then(() =>
this.uploadedAction.emit(),
);
private refreshData(): void {
this.uploadedAction.emit();
this.queryClient.invalidateQueries({
queryKey: ticketKeys.lists(),
});
}
}
@@ -1,5 +1,5 @@
@if (ticketQuery.data(); as ticket) {
<h3 class="py-2 px-3 !mb-0">
<h3 class="py-2 px-3 mb-0!">
{{ ticket.patient.name}}
</h3>
}
@@ -11,7 +11,7 @@
<button hlmTabsTrigger="history">Verlauf</button>
</hlm-tabs-list> -->
<div hlmTabsContent="overview">
<div hlmTabsContent="overview" class="overflow-y-auto max-h-[calc(85vh-8rem)] pr-1">
@if (ticketQuery.data(); as ticket) {
<dks-ticket-overview [ticket]="ticket" (uploaded)="refreshTicket()"/>
}
@@ -4,18 +4,12 @@ import { MatIconModule } from '@angular/material/icon';
import { DialogService } from '@ngneat/dialog';
import { TicketValidationState } from '../../core/data-access/graphql/generated/generated';
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 { 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({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'dks-tickets-page',
template: `<section class="h-full p-6 grid grid-cols-1 md:grid-cols-4 gap-6">
template: `<section class="h-full flex flex-col p-4 md:p-6 gap-4 md:gap-6">
<div class="col-span-1 md:col-span-4 flex justify-end items-center">
<button mat-icon-button (click)="showSearchDialog()">
<mat-icon>search</mat-icon>
@@ -33,26 +27,45 @@ import { HlmDialogImports } from '@spartan-ng/helm/dialog';
<app-ticket-state-info-card
[ticketState]="TicketState.DocumentsMissing"
></app-ticket-state-info-card> -->
<dks-ticket-lane
[stage]="'DATA_INCOMPLETE'"
class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.Created"
></dks-ticket-lane>
<dks-ticket-lane
[stage]="'ASSESSMENT'"
class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.UserInteractionRequired"
></dks-ticket-lane>
<dks-ticket-lane
[stage]="'DOCS_PENDING'"
class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.TdUncertain"
></dks-ticket-lane>
<dks-ticket-lane
[stage]="'APPROVAL'"
class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.DocumentsMissing"
></dks-ticket-lane>
<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
[stage]="'DATA_INCOMPLETE'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
[ticketState]="TicketState.Created"
></dks-ticket-lane>
<dks-ticket-lane
[stage]="'ASSESSMENT'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
[ticketState]="TicketState.UserInteractionRequired"
></dks-ticket-lane>
<dks-ticket-lane
[stage]="'DOCS_PENDING'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
[ticketState]="TicketState.TdUncertain"
></dks-ticket-lane>
<dks-ticket-lane
[stage]="'APPROVAL'"
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
[ticketState]="TicketState.DocumentsMissing"
></dks-ticket-lane>
<div class="w-1 flex-none md:hidden"></div>
</div>
</section>`,
styles: [
`
@@ -65,13 +78,7 @@ import { HlmDialogImports } from '@spartan-ng/helm/dialog';
}
`,
],
imports: [
TicketStateInfoCardComponent,
TicketLaneComponent,
MatButtonModule,
MatIconModule,
HlmButton,
],
imports: [TicketLaneComponent, MatButtonModule, MatIconModule],
})
export class TicketsPageComponent {
private dialog = inject(DialogService);
@@ -1,6 +1,11 @@
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { AsyncPipe, DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { DialogService } from '@ngneat/dialog';
@@ -18,6 +23,14 @@ import {
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
import { TicketsService } from '../tickets.service';
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({
template: `<div class="flex flex-col items-center justify-start p-4">
@@ -25,45 +38,121 @@ import { format, isAfter } from 'date-fns';
<hr />
<mat-form-field class="w-full">
<mat-label>Einsatznummer oder Patient</mat-label>
<input class="w-full" matInput [formControl]="searchCtrl" name="search" />
<input class="w-full" matInput [(ngModel)]="search" name="search" />
</mat-form-field>
<hr />
<div class="w-full">
@for (ticket of tickets$ | async; track ticket) {
<div
class="flex flex-col justify-between items-center py-2 border-b border-gray-200 cursor-pointer"
(click)="showTicketView(ticket)"
>
<div class="w-full flex justify-between">
<span>
{{ ticket.tours.at(0)?.ordinanceType }}
@if (ticket.tours.at(0)?.category) {
<span class="text-sm text-gray-600">
({{ ticket.tours.at(0)?.category }})
@if (searchQuery.isLoading()) {
<p>Suche Tickets ...</p>
}
@if (searchQuery.data(); as result) {
@for (ticket of result; track ticket.id) {
<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"
(click)="showTicketView(ticket)"
>
<div class="w-full flex justify-between">
<span>
{{ ticket.tours.at(0)?.ordinanceType }}
@if (ticket.tours.at(0)?.category) {
<span class="text-sm text-gray-600">
({{ ticket.tours.at(0)?.category }})
</span>
}
</span>
}
</span>
<!-- <span class="text-sm text-gray-600">{{ getNextStart(ticket.tours) }}</span> -->
</div>
<div class="w-full flex justify-between">
<span>
{{ ticket.tours.at(0)?.patientName }}
{{ ticket.tours.at(0)?.patientSurname }}
</span>
<span>
{{ getToursLength(ticket) }} Tour{{
getToursLength(ticket) > 1 ? 'en' : ''
}}
</span>
</div>
</div>
</div>
<div class="w-full flex justify-between">
<span>
{{ ticket.tours.at(0)?.patientName }}
{{ ticket.tours.at(0)?.patientSurname }}
</span>
<span>
{{ getToursLength(ticket) }} Tour{{
getToursLength(ticket) > 1 ? 'en' : ''
}}
</span>
</div>
</div> -->
}
}
</div>
</div>`,
imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, AsyncPipe],
imports: [
MatFormFieldModule,
MatInputModule,
FormsModule,
NgIcon,
HlmIcon,
DatePipe,
],
providers: [
provideIcons({
lucideCircleAlert,
lucideCalendarClock,
lucideCar,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TicketsSearchDialogComponent {
search = signal<string>('');
searchQuery = useTicketSearch(this.search);
private ticketsService = inject(TicketsService);
private dialog = inject(DialogService);
@@ -72,13 +161,13 @@ export class TicketsSearchDialogComponent {
debounceTime(200),
distinctUntilChanged(),
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 {
const dialogRef = this.dialog.open(TicketViewComponent, {
data: (ticket as Ticket)?.id as string,
width: '40vw',
width: '60vw',
minHeight: '400px',
});
@@ -89,19 +178,21 @@ export class TicketsSearchDialogComponent {
getToursLength(ticket: unknown) {
return (ticket as Ticket & { tours: Tour[] }).tours.filter(
({ check }) => check === 0
({ check }) => check === 0,
).length;
}
getNextStart(tours: Partial<Tour>[]) {
const sortedStartDates = [...tours.map(({startDate}) => new Date(startDate))].sort((a, b) => a.getTime() - b.getTime())
for(let i = 0; i < sortedStartDates.length; i++) {
const currentDate = sortedStartDates.at(i)
if(currentDate && isAfter(currentDate, new Date())) {
return format(currentDate, 'HH:mm | dd.MM.yy')
const sortedStartDates = [
...tours.map(({ startDate }) => new Date(startDate)),
].sort((a, b) => a.getTime() - b.getTime());
for (let i = 0; i < sortedStartDates.length; i++) {
const currentDate = sortedStartDates.at(i);
if (currentDate && isAfter(currentDate, new Date())) {
return format(currentDate, 'HH:mm | dd.MM.yy');
}
}
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,33 +1,17 @@
import { Injectable } from '@angular/core';
import { HotToastService } from '@ngxpert/hot-toast';
import {
catchError,
combineLatest,
map,
Observable,
retry,
shareReplay,
startWith,
Subject,
switchMap,
tap,
throwError,
timer,
} from 'rxjs';
import {
GetTicketsGQL,
GetTicketsQuery,
GetTicketStateMetaGQL,
GetTicketStateMetaQuery,
SearchTicketsGQL,
SearchTicketsQuery,
SendApprovalRequestGQL,
SetDocumentInfoGQL,
SetDocumentInfoMutationVariables,
TicketFilterArgs,
TicketStateKpiGQL,
TicketStateKpiQuery,
TicketValidationState,
} from '../../core/data-access/graphql/generated/generated';
@Injectable({
@@ -43,164 +27,25 @@ export class TicketsService {
}
}),
shareReplay(1),
startWith(undefined)
startWith(undefined),
);
updateTickets$ = new Subject<void>();
constructor(
private readonly getTicketsGql: GetTicketsGQL,
private readonly getTicketStateMetaGql: GetTicketStateMetaGQL,
private readonly searchTicketsGql: SearchTicketsGQL,
private readonly setDocumentInfoGql: SetDocumentInfoGQL,
private readonly ticketStateKpiGql: TicketStateKpiGQL,
private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
private readonly toast: HotToastService
) {
this.updateTickets$.next();
}
sendApprovalRequest(ticketId: string, email: string) {
return this.sendApprovalRequestGql
.mutate({
email,
ticketId,
})
.pipe(
tap((mutationResult) => {
if (mutationResult.errors) {
this.toast.error(
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
);
} else {
this.toast.success(
'Die Genehmigungsanfrage wird versendet.'
);
}
})
);
}
fetchTicketsByState(
state: TicketValidationState,
cursor: string,
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)
);
}
private readonly toast: HotToastService,
) {}
searchTickets(
query: string
): Observable<SearchTicketsQuery['searchTickets']> {
query: string,
): Observable<SearchTicketsQuery['searchTickets'] | undefined> {
return this.searchTicketsGql
.fetch({ query })
.pipe(map((response) => response.data.searchTickets));
}
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,
.fetch({
variables: {
query,
},
})
.pipe(
map((res) => res.data?.setDocumentInfo),
tap(() => this.updateTickets$.next())
);
.pipe(map((response) => response.data?.searchTickets));
}
}
+4 -8
View File
@@ -5,6 +5,7 @@
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"useDefineForClassFields": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
@@ -16,16 +17,8 @@
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom",
"esnext.asynciterable"
],
"baseUrl": "./",
"paths": {
"@angular/*": [
"../../node_modules/@angular/*"
],
"@tanstack/*": [
"../../node_modules/@tanstack/*"
],
@@ -88,6 +81,9 @@
],
"@spartan-ng/helm/textarea": [
"./src/app/core/base-components/textarea/src/index.ts"
],
"@spartan-ng/helm/skeleton": [
"./src/app/core/base-components/skeleton/src/index.ts"
]
}
},
+2 -1
View File
@@ -6,7 +6,7 @@ COPY package*.json .
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
RUN npm ci
RUN npm ci --verbose
COPY packages/shared-dtos ./packages/shared-dtos/
COPY data-hub/data-connector ./data-hub/data-connector/
@@ -33,6 +33,7 @@ FROM node:20.15.1-alpine
WORKDIR /app
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/dist ./dist
COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh .
+3 -1
View File
@@ -24,7 +24,8 @@
"seed": "ts-node prisma/seed/seed.ts"
},
"dependencies": {
"@apollo/server": "^4.12.0",
"@apollo/server": "^5.0.0",
"@as-integrations/express5": "^1.1.2",
"@avicenna/shared-dtos": "^1.0.0",
"@faker-js/faker": "^9.6.0",
"@hyperdx/node-logger": "^0.4.0",
@@ -40,6 +41,7 @@
"@nestjs/graphql": "^13.0.4",
"@nestjs/platform-express": "^11.0.15",
"@nestjs/schedule": "^5.0.1",
"@nestjs/swagger": "^11.2.5",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.60.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
@@ -2,9 +2,6 @@
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.
- 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
@@ -25,14 +22,14 @@ ADD COLUMN "approvalIsOverriden" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3),
ADD COLUMN "approvalPreInquiryMessageId" TEXT,
ADD COLUMN "approvalRecipient" TEXT,
ADD COLUMN "approvalReminderCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "approvalReminderCount" INTEGER DEFAULT 0,
ADD COLUMN "approvalRequestedAt" TIMESTAMP(3),
ADD COLUMN "approvalRequirement" TEXT NOT NULL,
ADD COLUMN "approvalStatus" TEXT NOT NULL,
ADD COLUMN "approvalRequirement" TEXT DEFAULT 'UNKNOWN',
ADD COLUMN "approvalStatus" TEXT DEFAULT 'NOT_STARTED',
ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "patientId" UUID,
ADD COLUMN "stage" TEXT NOT NULL,
ADD COLUMN "stage" TEXT DEFAULT 'DRAFT',
ADD COLUMN "updatedAt" TIMESTAMP(3),
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "currentState" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "lastStageBeforeClosed" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "optimizationInMinutes" INTEGER NOT NULL DEFAULT 0;
@@ -419,11 +419,12 @@ model Tour {
companyName String?
schwerlast Boolean @default(false)
createdAt DateTime?
updatedAt DateTime?
deletedAt DateTime?
Ticket Ticket? @relation(fields: [ticketId], references: [id])
ticketId String? @db.Uuid
createdAt DateTime?
updatedAt DateTime?
deletedAt DateTime?
optimizationInMinutes Int @default(0)
Ticket Ticket? @relation(fields: [ticketId], references: [id])
ticketId String? @db.Uuid
}
model TourFile {
@@ -488,13 +489,13 @@ enum ApprovalState {
model Ticket {
id String @id @db.Uuid
patientId String? @db.Uuid
stage String
stage String? @default("DRAFT")
approvalRequirement String
approvalStatus String
approvalRequirement String? @default("UNKNOWN")
approvalStatus String? @default("NOT_STARTED")
approvalRequestedAt DateTime?
approvalLastInteractionAt DateTime?
approvalReminderCount Int @default(0)
approvalReminderCount Int? @default(0)
approvalRecipient String?
approvalPreInquiryMessageId String?
approvalFullRequestMessageId String?
@@ -502,6 +503,7 @@ model Ticket {
hasPhysicalTransportDocument Boolean @default(false)
hasDigitalTransportDocument Boolean @default(false)
lastStageBeforeClosed String?
updatedAt DateTime? @updatedAt
createdAt DateTime @default(now())
@@ -25,6 +25,7 @@ import { FeatCalculationModule } from './modules/feat-calculation/feat-calculati
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
import { AppPlaygroundModule } from './modules/app-playground/app-playground.module';
@Module({
imports: [
@@ -59,7 +60,7 @@ import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.mod
EventEmitterModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
playground: true,
graphiql: true,
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
sortSchema: true,
subscriptions: {
@@ -77,6 +78,7 @@ import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.mod
ApplicationControlCenterModule,
ApplicationTicketSystemModule,
AppHomeModule,
AppPlaygroundModule,
],
controllers: [AppController],
providers: [AppService, Logger],
@@ -24,13 +24,6 @@ export interface PrismaServiceOptions {
* See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management
*/
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 {
@@ -31,12 +31,6 @@ 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.
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 {
accountingStateMeta(state: AccountingState!): TourStateMeta!
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int): PaginatedTour!
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
anomaliesProto: [Anomaly!]!
anomaly(id: String!): Anomaly!
@@ -303,18 +303,19 @@ export type TariffGroup = {
export type Ticket = {
id: string;
patientId: string | null;
stage: string;
approvalRequirement: string;
approvalStatus: string;
stage: Generated<string | null>;
approvalRequirement: Generated<string | null>;
approvalStatus: Generated<string | null>;
approvalRequestedAt: Timestamp | null;
approvalLastInteractionAt: Timestamp | null;
approvalReminderCount: Generated<number>;
approvalReminderCount: Generated<number | null>;
approvalRecipient: string | null;
approvalPreInquiryMessageId: string | null;
approvalFullRequestMessageId: string | null;
approvalIsOverriden: Generated<boolean>;
hasPhysicalTransportDocument: Generated<boolean>;
hasDigitalTransportDocument: Generated<boolean>;
lastStageBeforeClosed: string | null;
updatedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
currentState: TicketValidationState | null;
@@ -386,6 +387,7 @@ export type Tour = {
createdAt: Timestamp | null;
updatedAt: Timestamp | null;
deletedAt: Timestamp | null;
optimizationInMinutes: Generated<number>;
ticketId: string | null;
};
export type TourFile = {
@@ -37,7 +37,7 @@ export class MailService {
return await this.mailerService.sendMail({
to: recipient,
// bcc: 'info@avicenna.hamburg',
bcc: 'info@avicenna.hamburg',
subject: `Antrag auf Kostenübernahme`,
template: 'genehmigungsanfrage',
headers: this.getThreadingHeaders(replyToId),
@@ -62,7 +62,7 @@ export class MailService {
return await this.mailerService.sendMail({
to: recipient,
// bcc: 'info@avicenna.hamburg',
bcc: 'info@avicenna.hamburg',
headers: this.getThreadingHeaders(replyToId),
subject: `Antrag auf Kostenübernahme Transport am ${params.transportDate} für ${params.patientName}`,
template: 'genehmigung-vorab-anfrage',
@@ -79,6 +79,7 @@ export class MailService {
return await this.mailerService.sendMail({
to: recipient,
bcc: 'info@avicenna.hamburg',
subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading
template: 'genehmigung-reminder',
headers: this.getThreadingHeaders(replyToId),
@@ -1,14 +1,7 @@
import {
ArgsType,
Field,
Float,
InputType,
Int,
ObjectType,
} from '@nestjs/graphql';
import { ArgsType, Field, InputType } from '@nestjs/graphql';
import { PaginationArgs } from 'src/core/base/pagination.input';
import { AccountingState } from './accounting-state';
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
import { AccountingState } from './accounting-state';
@InputType()
export class AccountingFilterArgs implements TourAccountingFilterDto {
@@ -27,15 +20,3 @@ export class AccountingTourArgs extends PaginationArgs {
@Field(() => AccountingFilterArgs, { nullable: true })
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;
}
@@ -0,0 +1,64 @@
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,12 +1,22 @@
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import {
Args,
Int,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
import {
AccountingTourArgs,
TourStateMetaObjectType,
AccountingFilterArgs
} from './accounting-arg.types';
import { AccountingState } from './accounting-state';
import { AccountingValidationService } from './accounting-validation.service';
import { PaginatedTourObjectType, TourCostBreakdownObjectType } from './accounting.object-type';
import {
PaginatedTourObjectType,
TourCostBreakdownObjectType,
TourStateMetaObjectType,
} from './accounting.object-type';
import { TourObjectType } from './tour.object-type';
@Resolver(() => TourObjectType)
@@ -15,9 +25,13 @@ export class AccountingValidationResolver {
@Query(() => PaginatedTourObjectType)
accountingTours(
@Args() { state, filters, cursor, take }: AccountingTourArgs,
@Args('state', { type: () => AccountingState }) state: AccountingState,
@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, filters, take, cursor);
return this.service.paginateToursByState(state, take, cursor);
}
@Query(() => TourStateMetaObjectType)
@@ -30,7 +44,7 @@ export class AccountingValidationResolver {
@Query(() => TourCostBreakdownObjectType)
tourCostBreakdown(@Args('operationId') operationId: string) {
return this.service.getTourCostBreakdown(operationId)
return this.service.getTourCostBreakdown(operationId);
}
@ResolveField(() => TicketObjectType, { nullable: true })
@@ -1,15 +1,18 @@
import {
PaginatedResult,
PaginatedTourDto,
TourDto,
TourStateMetaDto,
} from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common';
import {
decodeOffsetCursor,
encodeCursor,
} 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 { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
import { AccountingState } from './accounting-state';
import { AccountingService } from './accounting.service';
@Injectable()
export class AccountingValidationService {
@@ -19,16 +22,39 @@ export class AccountingValidationService {
private readonly tourService: TourService,
) {}
async paginateToursByState(
async toursByState(
state: AccountingState,
filter: TourAccountingFilterDto,
take: number = 10,
cursor?: string,
): Promise<PaginatedTourObjectType> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
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(
state: AccountingState,
take: number = 10,
cursor?: string,
): Promise<PaginatedTourDto> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const { tours, total } = await this.tourService.findByAccountingState(
state,
filter,
take,
skip,
);
@@ -47,7 +73,7 @@ export class AccountingValidationService {
async getAccountingStateMeta(
accountingState: AccountingState,
): Promise<TourStateMetaObjectType> {
): Promise<TourStateMetaDto> {
return this.tourService.calculateAccountingStateMeta(accountingState);
}
@@ -0,0 +1,96 @@
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);
}
}
@@ -0,0 +1,53 @@
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,8 +1,14 @@
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
import { AccountingState } from './accounting-state';
import { Paginated } from 'src/core/base/pagination.input';
import { TourObjectType } from './tour.object-type';
import {
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 { TourObjectType } from './tour.object-type';
registerEnumType(AccountingState, {
name: 'AccountingState',
@@ -13,7 +19,7 @@ export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
@ObjectType('ServicePosition')
export class ServicePositionObjectType {
@Field()
@Field(() => String)
variant: CostTypeVariant;
@Field()
description: string;
@@ -31,3 +37,15 @@ export class TourCostBreakdownObjectType {
@Field(() => [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,14 +4,22 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { AccountingValidationResolver } from './accounting-validation.resolver';
import { AccountingValidationService } from './accounting-validation.service';
import { AccountingController } from './accounting.controller';
import { AccountingListener } from './accounting.listener';
import { AccountingService } from './accounting.service';
import { RevenueOptimizationService } from './revenue-optimization.service';
import { AccountingQueryService } from './accounting-query.service';
@Module({
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
controllers: [AccountingController],
providers: [
AccountingValidationResolver,
AccountingValidationService,
AccountingService,
AccountingListener,
RevenueOptimizationService,
AccountingQueryService
],
exports: [],
})
@@ -0,0 +1,208 @@
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,6 +3,7 @@ import { Tour } from '@prisma/client';
@ObjectType('Tour')
export class TourObjectType implements Tour {
optimizationInMinutes: number;
insuranceNo: string;
overRTW: boolean;
companyName: string;
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { ManagerController } from './manager.controller';
import { ManagerResolver } from './manager.resolver';
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
@@ -9,6 +10,7 @@ import { TourAmountForecastService } from './tour-amount-forecast.service';
@Module({
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
controllers: [ManagerController],
providers: [
TourAmountForecastService,
TimePerTourKpiService,
@@ -0,0 +1,59 @@
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 { OnEvent } from '@nestjs/event-emitter';
import { addMilliseconds } from 'date-fns';
import { addMilliseconds, Interval } from 'date-fns';
import { PubSub } from 'graphql-subscriptions';
import { generateIntervalForTimeRange } from 'src/utils';
import { CalculationService } from '../feat-calculation/calculation.service';
@@ -115,7 +115,10 @@ export class TimePerTourKpiService {
tourAmount,
);
return {
interval,
interval: {
start: new Date(interval.start),
end: new Date(interval.end)
},
kpi: {
value: baseValue,
meta: {
@@ -1,3 +1,4 @@
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common';
import {
addDays,
@@ -15,11 +16,10 @@ import {
startOfDay,
startOfMonth,
} 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 { 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()
export class TimePerTourMetricsService {
@@ -84,7 +84,7 @@ export class TimePerTourMetricsService {
intervalClamp: Interval,
subIntervals: Interval[],
ordinanceType: string,
): Promise<TimePerTourMetricObjectType[]> {
): Promise<TimePerTourMetricDto[]> {
const carEntities = await this.carEntityService.getCarsInInterval(
intervalClamp,
ordinanceType,
@@ -180,7 +180,10 @@ export class TimePerTourMetricsService {
}
return {
interval,
interval: {
start: new Date(interval.start),
end: new Date(interval.end),
},
tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100,
breakEven:
@@ -0,0 +1,11 @@
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 {}
@@ -0,0 +1,12 @@
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));
}
}
@@ -0,0 +1,57 @@
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,6 +3,7 @@ import {
ApprovalRequirement,
TicketApprovalViewDto,
TicketDetailDto,
TicketTransportDocumentView,
} from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common';
import { isDate } from 'date-fns';
@@ -11,7 +12,6 @@ import { Ticket } from 'src/modules/feat-tickets/domain/ticket.entity';
@Injectable()
export class TicketReadMapper {
toDetailDto(ticket: Ticket): TicketDetailDto {
const transportDocument = ticket.transportDocument;
const tour = ticket.tours
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
.at(0);
@@ -45,10 +45,7 @@ export class TicketReadMapper {
approval: this.toApprovalView(ticket),
transportDocument: {
isUploaded: !!transportDocument,
link: transportDocument?.storagePath,
},
transportDocument: this.toTransportDocumentView(ticket),
tours: ticket.tours
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
@@ -92,6 +89,15 @@ export class TicketReadMapper {
'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),
sendReminder: this.canSendApprovalReminder(ticket),
@@ -105,6 +111,28 @@ 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 {
switch (status) {
case ApprovalRequestStatus.NOT_STARTED:
@@ -113,6 +141,8 @@ export class TicketReadMapper {
return 'Vorab-Anfrage versendet';
case ApprovalRequestStatus.FULL_REQUEST_SENT:
return 'Anfrage versendet';
case ApprovalRequestStatus.EXTERNAL_REQUEST:
return 'Anfrage von Extern versendet';
case ApprovalRequestStatus.GRANTED:
return 'Genehmigung erteilt';
}
@@ -1,7 +1,11 @@
import { TicketListItemDto, PaginatedResult } from '@avicenna/shared-dtos';
import {
PaginatedResult,
TicketListItemDto,
TicketStage,
} from '@avicenna/shared-dtos';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { differenceInDays } from 'date-fns';
import { Kysely } from 'kysely';
import { Kysely, sql } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
@@ -13,6 +17,94 @@ export class TicketQueryService {
@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(
stage: string,
page: number,
@@ -34,6 +126,7 @@ export class TicketQueryService {
.select((eb) => [
'Ticket.id',
'Ticket.stage',
'Ticket.approvalRequirement',
'Ticket.approvalStatus',
'Ticket.approvalRequestedAt',
'Ticket.createdAt',
@@ -61,6 +154,14 @@ export class TicketQueryService {
.select('ordinanceType')
.limit(1)
.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)
.offset(offset)
@@ -72,9 +173,13 @@ export class TicketQueryService {
ordinanceType: row.ordinanceType,
patientName: `${row.patientName} ${row.patientSurname}`,
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,
isOverdue: row.approvalRequestedAt
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
: false,
errorCount: 0,
createdAt: row.createdAt,
@@ -29,6 +29,7 @@ export class DocumentInfoObjectType {
@ObjectType('Ticket')
export class TicketObjectType implements Ticket {
lastStageBeforeClosed: string;
approvalPreInquiryMessageId: string;
approvalFullRequestMessageId: string;
approvalRecipient: string;
@@ -1,3 +1,10 @@
import {
GetBoardQueryDto,
PaginatedResult,
TicketApprovalViewDto,
TicketDetailDto,
TicketListItemDto,
} from '@avicenna/shared-dtos';
import {
BadRequestException,
Body,
@@ -15,25 +22,20 @@ import {
UseInterceptors,
} from '@nestjs/common';
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 { TicketQueryService } from './queries/ticket-query.service';
import {
PaginatedResult,
TicketListItemDto,
GetBoardQueryDto,
TicketDetailDto,
TicketApprovalViewDto,
} from '@avicenna/shared-dtos';
import { TicketId } from '../feat-tickets/domain/ticket.entity';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../feat-tickets/domain/ticket.repository.interface';
import { TicketId } from '../feat-tickets/domain/ticket.entity';
import { TicketReadMapper } from './mappers/ticket-read.mapper';
import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.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';
import { TicketQueryService } from './queries/ticket-query.service';
import { RegisterExternalApprovalRequestUseCase } from '../feat-tickets/application/register-external-approval-request.usecase';
@Controller('api/tickets')
export class TicketsController {
@@ -48,9 +50,22 @@ export class TicketsController {
private readonly overrideUseCase: OverrideApprovalRequirementUseCase,
private readonly requestApprovalUseCase: RequestTicketApprovalUseCase,
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()
async getBoard(
@Query() query: GetBoardQueryDto,
@@ -95,17 +110,30 @@ export class TicketsController {
@Post(':id/approval/request')
@HttpCode(HttpStatus.NO_CONTENT)
async requestApproval(@Param('id') id: string, @Body('recipient') recipient: string) {
async requestApproval(
@Param('id') id: string,
@Body('recipient') recipient: string,
) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
if (!recipient) {
throw new BadRequestException(`no recipient provided`)
throw new BadRequestException(`no recipient provided`);
}
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')
@HttpCode(HttpStatus.NO_CONTENT)
async sendReminder(@Param('id') id: string) {
@@ -136,6 +164,16 @@ export class TicketsController {
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')
@UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED)
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Prisma, TicketValidationState, Tour } from '@prisma/client';
import { endOfDay, Interval, startOfDay } from 'date-fns';
import { ExpressionBuilder, ExpressionWrapper, Kysely, SqlBool } from 'kysely';
import { ExpressionBuilder, ExpressionWrapper, Kysely, sql, SqlBool } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service';
import { DB } from 'src/core/database/types';
@@ -26,8 +26,8 @@ function onlyRealToursKysely(eb: TourEb) {
eb('deletedAt', 'is', null),
eb.or([
eb('patientId', 'is', null),
eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
eb('Tour.patientId', 'is', null),
eb('Tour.patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
]),
]);
}
@@ -77,6 +77,26 @@ export class TourRepository {
.orderBy('Tour.startDate asc')
.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[]> {
return this.db
@@ -307,26 +327,39 @@ export class TourRepository {
// --------------------- Accounting --------------------- //
async findByAccountingState(
state: AccountingState,
filter: TourAccountingFilterDto,
take?: number,
skip?: number,
): Promise<{ tours: Tour[]; total: number }> {
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> {
return await this.db.transaction().execute(async (trx) => {
const tours = await trx
const tours = (await trx
.selectFrom('Tour')
.selectAll()
// .selectAll('Tour')
.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) =>
eb.and([
this.getFilterForAccountingState(eb, state),
onlyRealToursKysely(eb),
]),
)
.orderBy(sql`abs("Tour"."optimizationInMinutes")`, 'desc')
.orderBy('Tour.revenueDeviation desc')
.offset(skip)
.limit(take)
.execute();
.execute()) as unknown as (Tour & { stage: string })[];
const total = await trx
.selectFrom('Tour')
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.where((eb) =>
eb.and([
this.getFilterForAccountingState(eb, state),
@@ -403,33 +436,11 @@ export class TourRepository {
return eb.and([
eb('Tour.check', '=', 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:
return eb.and([
eb('Tour.check', '=', 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:
return eb.and([eb('Tour.check', '=', 1)]);

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