Compare commits
1 Commits
main
..
d448ca6516
| Author | SHA1 | Date | |
|---|---|---|---|
| d448ca6516 |
@@ -7,4 +7,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
@@ -2,6 +2,11 @@ FROM node:20.19.1-slim AS builder
|
|||||||
|
|
||||||
WORKDIR /app/builder
|
WORKDIR /app/builder
|
||||||
|
|
||||||
|
COPY package*.json .
|
||||||
|
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
|
||||||
|
# COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
|
||||||
|
COPY app-hub/dashboard/package*.json ./app-hub/dashboard/
|
||||||
|
|
||||||
ENV LIBC=glibc
|
ENV LIBC=glibc
|
||||||
ENV npm_config_arch=x64
|
ENV npm_config_arch=x64
|
||||||
ENV npm_config_platform=linux
|
ENV npm_config_platform=linux
|
||||||
@@ -12,11 +17,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package*.json .
|
|
||||||
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
|
|
||||||
# COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
|
|
||||||
COPY app-hub/dashboard/package*.json ./app-hub/dashboard/
|
|
||||||
|
|
||||||
RUN npm ci --verbose
|
RUN npm ci --verbose
|
||||||
|
|
||||||
COPY packages/shared-dtos ./packages/shared-dtos/
|
COPY packages/shared-dtos ./packages/shared-dtos/
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/dashboard",
|
"outputPath": "dist/dashboard",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/m3-theme.scss",
|
"src/m3-theme.scss",
|
||||||
"../../node_modules/@ngxpert/hot-toast/src/styles/styles.css",
|
"node_modules/@ngxpert/hot-toast/src/styles/styles.css",
|
||||||
"src/tailwind.css",
|
"src/tailwind.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "500kB",
|
||||||
"maximumError": "2.5MB"
|
"maximumError": "2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "dashboard:build:production"
|
"buildTarget": "dashboard:build:production"
|
||||||
@@ -92,10 +92,10 @@
|
|||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular/build:extract-i18n"
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js",
|
"zone.js",
|
||||||
@@ -119,31 +119,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"schematics": {
|
|
||||||
"@schematics/angular:component": {
|
|
||||||
"type": "component"
|
|
||||||
},
|
|
||||||
"@schematics/angular:directive": {
|
|
||||||
"type": "directive"
|
|
||||||
},
|
|
||||||
"@schematics/angular:service": {
|
|
||||||
"type": "service"
|
|
||||||
},
|
|
||||||
"@schematics/angular:guard": {
|
|
||||||
"typeSeparator": "."
|
|
||||||
},
|
|
||||||
"@schematics/angular:interceptor": {
|
|
||||||
"typeSeparator": "."
|
|
||||||
},
|
|
||||||
"@schematics/angular:module": {
|
|
||||||
"typeSeparator": "."
|
|
||||||
},
|
|
||||||
"@schematics/angular:pipe": {
|
|
||||||
"typeSeparator": "."
|
|
||||||
},
|
|
||||||
"@schematics/angular:resolver": {
|
|
||||||
"typeSeparator": "."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,26 +11,27 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.1.0",
|
"@angular/animations": "^19.2.6",
|
||||||
"@angular/cdk": "^21.1.0",
|
"@angular/cdk": "^19.2.9",
|
||||||
"@angular/common": "^21.1.0",
|
"@angular/common": "^19.2.6",
|
||||||
"@angular/compiler": "^21.1.0",
|
"@angular/compiler": "^19.2.6",
|
||||||
"@angular/core": "^21.1.0",
|
"@angular/core": "^19.2.6",
|
||||||
"@angular/forms": "^21.1.0",
|
"@angular/forms": "^19.2.6",
|
||||||
"@angular/material": "^21.1.0",
|
"@angular/material": "^19.2.9",
|
||||||
"@angular/material-date-fns-adapter": "^21.1.0",
|
"@angular/material-date-fns-adapter": "^19.2.9",
|
||||||
"@angular/platform-browser": "^21.1.0",
|
"@angular/platform-browser": "^19.2.6",
|
||||||
"@angular/platform-browser-dynamic": "^21.1.0",
|
"@angular/platform-browser-dynamic": "^19.2.6",
|
||||||
"@angular/router": "^21.1.0",
|
"@angular/router": "^19.2.6",
|
||||||
"@apollo/client": "^4.1.0",
|
"@apollo/client": "^3.13.8",
|
||||||
|
"@avicenna/shared-dtos": "^1.0.0",
|
||||||
"@azure/msal-angular": "^3.0.22",
|
"@azure/msal-angular": "^3.0.22",
|
||||||
"@azure/msal-browser": "^3.19.1",
|
"@azure/msal-browser": "^3.19.1",
|
||||||
"@hyperdx/browser": "^0.22.0",
|
"@hyperdx/browser": "^0.22.0",
|
||||||
"@ng-icons/core": "^33.0.0",
|
"@ng-icons/core": "^31.4.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^31.4.0",
|
||||||
"@ngneat/dialog": "^5.1.1",
|
"@ngneat/dialog": "^5.1.1",
|
||||||
"@ngneat/overview": "^7.0.0",
|
"@ngneat/overview": "^6.0.0",
|
||||||
"@ngxpert/hot-toast": "^6.0.0",
|
"@ngxpert/hot-toast": "^3.0.0",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/auto-instrumentations-web": "^0.45.1",
|
"@opentelemetry/auto-instrumentations-web": "^0.45.1",
|
||||||
"@opentelemetry/context-zone": "^1.30.1",
|
"@opentelemetry/context-zone": "^1.30.1",
|
||||||
@@ -42,14 +43,14 @@
|
|||||||
"@opentelemetry/resources": "^1.30.1",
|
"@opentelemetry/resources": "^1.30.1",
|
||||||
"@opentelemetry/sdk-trace-web": "^1.30.1",
|
"@opentelemetry/sdk-trace-web": "^1.30.1",
|
||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@spartan-ng/brain": "0.0.1-alpha.605",
|
"@spartan-ng/brain": "^0.0.1-alpha.561",
|
||||||
"@tanstack/angular-query-experimental": "^5.90.20",
|
"@tanstack/angular-query-experimental": "^5.90.20",
|
||||||
"apollo-angular": "^13.0.0",
|
"apollo-angular": "^10.0.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.11.0",
|
||||||
"graphql-ws": "^5.16.0",
|
"graphql-ws": "^5.16.0",
|
||||||
"ngx-scrollbar": "^19.1.1",
|
"ngx-scrollbar": "^19.1.1",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
@@ -58,16 +59,16 @@
|
|||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.1.0",
|
"@angular-devkit/build-angular": "^19.2.7",
|
||||||
"@angular/cli": "^21.1.0",
|
"@angular/cli": "^19.2.7",
|
||||||
"@angular/compiler-cli": "^21.1.0",
|
"@angular/compiler-cli": "^19.2.6",
|
||||||
"@angular/localize": "^21.1.0",
|
"@angular/localize": "^19.2.6",
|
||||||
"@graphql-codegen/cli": "^5.0.5",
|
"@graphql-codegen/cli": "^5.0.5",
|
||||||
"@graphql-codegen/typescript": "^4.1.6",
|
"@graphql-codegen/typescript": "^4.1.6",
|
||||||
"@graphql-codegen/typescript-apollo-angular": "^4.0.0",
|
"@graphql-codegen/typescript-apollo-angular": "^4.0.0",
|
||||||
"@graphql-codegen/typescript-operations": "^4.6.0",
|
"@graphql-codegen/typescript-operations": "^4.6.0",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@spartan-ng/cli": "0.0.1-alpha.605",
|
"@spartan-ng/cli": "^0.0.1-alpha.561",
|
||||||
"@tailwindcss/postcss": "^4.1.4",
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
@@ -77,10 +78,10 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.0",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ import { withDevtools } from '@tanstack/angular-query-experimental/devtools';
|
|||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
|
import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module';
|
||||||
|
import { GlobalErrorHandler } from './core/telemetry/global-error-handler';
|
||||||
import { graphqlProvider } from './graphql.provider';
|
import { graphqlProvider } from './graphql.provider';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -42,6 +43,10 @@ window.__TANSTACK_QUERY_CLIENT__ = queryClient;
|
|||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
|
{
|
||||||
|
provide: ErrorHandler,
|
||||||
|
useClass: GlobalErrorHandler,
|
||||||
|
},
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
|
|||||||
@@ -61,18 +61,6 @@ export const routes: Routes = [
|
|||||||
title: 'Ticketsystem',
|
title: 'Ticketsystem',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'playground',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./pages/playground/playground.component').then(
|
|
||||||
(m) => m.PlaygroundComponent
|
|
||||||
),
|
|
||||||
canActivate: [MsalGuard, roleGuard],
|
|
||||||
data: {
|
|
||||||
title: 'Playground',
|
|
||||||
roles: ['manager', 'admin'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
|
|||||||
+1
-1
@@ -10,5 +10,5 @@ import type { ClassValue } from 'clsx';
|
|||||||
})
|
})
|
||||||
export class HlmCardFooter {
|
export class HlmCardFooter {
|
||||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||||
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-4', this.userClass()));
|
protected readonly _computedClass = computed(() => hlm('flex items-center px-6 [.border-t]:pt-6', this.userClass()));
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ export class HlmCardHeader {
|
|||||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||||
protected readonly _computedClass = computed(() =>
|
protected readonly _computedClass = computed(() =>
|
||||||
hlm(
|
hlm(
|
||||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4',
|
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||||
this.userClass(),
|
this.userClass(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { hlm } from '@spartan-ng/helm/utils';
|
|||||||
import { type VariantProps, cva } from 'class-variance-authority';
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
import type { ClassValue } from 'clsx';
|
import type { ClassValue } from 'clsx';
|
||||||
|
|
||||||
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm', {
|
export const cardVariants = cva('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', {
|
||||||
variants: {},
|
variants: {},
|
||||||
defaultVariants: {},
|
defaultVariants: {},
|
||||||
});
|
});
|
||||||
|
|||||||
+3
@@ -3,6 +3,7 @@ import { Injectable, type TemplateRef, inject } from '@angular/core';
|
|||||||
import {
|
import {
|
||||||
type BrnDialogOptions,
|
type BrnDialogOptions,
|
||||||
BrnDialogService,
|
BrnDialogService,
|
||||||
|
DEFAULT_BRN_DIALOG_OPTIONS,
|
||||||
cssClassesToArray,
|
cssClassesToArray,
|
||||||
} from '@spartan-ng/brain/dialog';
|
} from '@spartan-ng/brain/dialog';
|
||||||
import { HlmDialogContent } from './hlm-dialog-content';
|
import { HlmDialogContent } from './hlm-dialog-content';
|
||||||
@@ -21,6 +22,8 @@ export class HlmDialogService {
|
|||||||
|
|
||||||
public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) {
|
public open(component: ComponentType<unknown> | TemplateRef<unknown>, options?: Partial<HlmDialogOptions>) {
|
||||||
const mergedOptions = {
|
const mergedOptions = {
|
||||||
|
...DEFAULT_BRN_DIALOG_OPTIONS,
|
||||||
|
|
||||||
...(options ?? {}),
|
...(options ?? {}),
|
||||||
backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
|
backdropClass: cssClassesToArray(`${hlmDialogOverlayClass} ${options?.backdropClass ?? ''}`),
|
||||||
context: {
|
context: {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { HlmSkeleton } from './lib/hlm-skeleton';
|
|
||||||
|
|
||||||
export * from './lib/hlm-skeleton';
|
|
||||||
|
|
||||||
export const HlmSkeletonImports = [HlmSkeleton] as const;
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
import { Directive } from '@angular/core';
|
|
||||||
import { classes } from '@spartan-ng/helm/utils';
|
|
||||||
|
|
||||||
@Directive({
|
|
||||||
selector: '[hlmSkeleton],hlm-skeleton',
|
|
||||||
host: {
|
|
||||||
'data-slot': 'skeleton',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class HlmSkeleton {
|
|
||||||
constructor() {
|
|
||||||
classes(() => 'bg-accent block rounded-md motion-safe:animate-pulse');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
computed,
|
|
||||||
input,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { BrnTabs } from '@spartan-ng/brain/tabs';
|
import { BrnTabs } from '@spartan-ng/brain/tabs';
|
||||||
import { hlm } from '@spartan-ng/helm/utils';
|
import { hlm } from '@spartan-ng/helm/utils';
|
||||||
import type { ClassValue } from 'clsx';
|
import type { ClassValue } from 'clsx';
|
||||||
@@ -14,7 +9,7 @@ import type { ClassValue } from 'clsx';
|
|||||||
hostDirectives: [
|
hostDirectives: [
|
||||||
{
|
{
|
||||||
directive: BrnTabs,
|
directive: BrnTabs,
|
||||||
inputs: ['orientation', 'activationMode', 'brnTabs: tab'],
|
inputs: ['orientation', 'direction', 'activationMode', 'brnTabs: tab'],
|
||||||
outputs: ['tabActivated'],
|
outputs: ['tabActivated'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -27,7 +22,5 @@ export class HlmTabs {
|
|||||||
public readonly tab = input.required<string>();
|
public readonly tab = input.required<string>();
|
||||||
|
|
||||||
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
public readonly userClass = input<ClassValue>('', { alias: 'class' });
|
||||||
protected readonly _computedClass = computed(() =>
|
protected readonly _computedClass = computed(() => hlm('flex flex-col gap-2', this.userClass()));
|
||||||
hlm('flex flex-col gap-2', this.userClass()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,257 +1,6 @@
|
|||||||
import { isPlatformBrowser } from '@angular/common';
|
|
||||||
import {
|
|
||||||
DestroyRef,
|
|
||||||
effect,
|
|
||||||
ElementRef,
|
|
||||||
HostAttributeToken,
|
|
||||||
inject,
|
|
||||||
Injector,
|
|
||||||
PLATFORM_ID,
|
|
||||||
runInInjectionContext,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { type ClassValue, clsx } from 'clsx';
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function hlm(...inputs: ClassValue[]) {
|
export function hlm(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global map to track class managers per element
|
|
||||||
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
|
|
||||||
|
|
||||||
// Global mutation observer for all elements
|
|
||||||
let globalObserver: MutationObserver | null = null;
|
|
||||||
const observedElements = new Set<HTMLElement>();
|
|
||||||
|
|
||||||
interface ElementClassManager {
|
|
||||||
element: HTMLElement;
|
|
||||||
sources: Map<number, { classes: Set<string>; order: number }>;
|
|
||||||
baseClasses: Set<string>;
|
|
||||||
isUpdating: boolean;
|
|
||||||
nextOrder: number;
|
|
||||||
hasInitialized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sourceCounter = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function dynamically adds and removes classes for a given element without requiring
|
|
||||||
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
|
|
||||||
*
|
|
||||||
* 1. This will merge the existing classes on the element with the new classes.
|
|
||||||
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
|
|
||||||
* 3. Multiple calls to this function on the same element will be merged efficiently.
|
|
||||||
*/
|
|
||||||
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
|
|
||||||
runInInjectionContext(options.injector ?? inject(Injector), () => {
|
|
||||||
const elementRef = options.elementRef ?? inject(ElementRef);
|
|
||||||
const platformId = inject(PLATFORM_ID);
|
|
||||||
const destroyRef = inject(DestroyRef);
|
|
||||||
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
|
|
||||||
|
|
||||||
const element = elementRef.nativeElement;
|
|
||||||
|
|
||||||
// Create unique identifier for this source
|
|
||||||
const sourceId = sourceCounter++;
|
|
||||||
|
|
||||||
// Get or create the class manager for this element
|
|
||||||
let manager = elementClassManagers.get(element);
|
|
||||||
|
|
||||||
if (!manager) {
|
|
||||||
// Initialize base classes from variation (host attribute 'class')
|
|
||||||
const initialBaseClasses = new Set<string>();
|
|
||||||
|
|
||||||
if (baseClasses) {
|
|
||||||
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
|
|
||||||
}
|
|
||||||
|
|
||||||
manager = {
|
|
||||||
element,
|
|
||||||
sources: new Map(),
|
|
||||||
baseClasses: initialBaseClasses,
|
|
||||||
isUpdating: false,
|
|
||||||
nextOrder: 0,
|
|
||||||
hasInitialized: false,
|
|
||||||
};
|
|
||||||
elementClassManagers.set(element, manager);
|
|
||||||
|
|
||||||
// Setup global observer if needed and register this element
|
|
||||||
setupGlobalObserver(platformId);
|
|
||||||
observedElements.add(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assign order once at registration time
|
|
||||||
const sourceOrder = manager.nextOrder++;
|
|
||||||
|
|
||||||
function updateClasses(): void {
|
|
||||||
// Get the new classes from the computed function
|
|
||||||
const newClasses = toClassList(computed());
|
|
||||||
|
|
||||||
// Update this source's classes, keeping the original order
|
|
||||||
manager!.sources.set(sourceId, {
|
|
||||||
classes: new Set(newClasses),
|
|
||||||
order: sourceOrder,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the element
|
|
||||||
updateElement(manager!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register cleanup with DestroyRef
|
|
||||||
destroyRef.onDestroy(() => {
|
|
||||||
// Remove this source from the manager
|
|
||||||
manager!.sources.delete(sourceId);
|
|
||||||
|
|
||||||
// If no more sources, clean up the manager
|
|
||||||
if (manager!.sources.size === 0) {
|
|
||||||
cleanupManager(element);
|
|
||||||
} else {
|
|
||||||
// Update element without this source's classes
|
|
||||||
updateElement(manager!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We need this effect to track changes to the computed classes. Ideally, we would use
|
|
||||||
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
|
|
||||||
* effect which works in both browser and SSR.
|
|
||||||
*/
|
|
||||||
effect(updateClasses);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
|
|
||||||
function setupGlobalObserver(platformId: Object): void {
|
|
||||||
if (isPlatformBrowser(platformId) && !globalObserver) {
|
|
||||||
// Create single global observer that watches the entire document
|
|
||||||
globalObserver = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
|
|
||||||
const element = mutation.target as HTMLElement;
|
|
||||||
const manager = elementClassManagers.get(element);
|
|
||||||
|
|
||||||
// Only process elements we're managing
|
|
||||||
if (manager && observedElements.has(element)) {
|
|
||||||
if (manager.isUpdating) continue; // Ignore changes we're making
|
|
||||||
|
|
||||||
// Update base classes to include any externally added classes
|
|
||||||
const currentClasses = toClassList(element.className);
|
|
||||||
const allSourceClasses = new Set<string>();
|
|
||||||
|
|
||||||
// Collect all classes from all sources
|
|
||||||
for (const source of manager.sources.values()) {
|
|
||||||
for (const className of source.classes) {
|
|
||||||
allSourceClasses.add(className);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any classes not from sources become new base classes
|
|
||||||
manager.baseClasses.clear();
|
|
||||||
|
|
||||||
for (const className of currentClasses) {
|
|
||||||
if (!allSourceClasses.has(className)) {
|
|
||||||
manager.baseClasses.add(className);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateElement(manager);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start observing the entire document for class attribute changes
|
|
||||||
globalObserver.observe(document, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class'],
|
|
||||||
subtree: true, // Watch all descendants
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateElement(manager: ElementClassManager): void {
|
|
||||||
if (manager.isUpdating) return; // Prevent recursive updates
|
|
||||||
|
|
||||||
manager.isUpdating = true;
|
|
||||||
|
|
||||||
// Handle initialization: capture base classes after first source registration
|
|
||||||
if (!manager.hasInitialized && manager.sources.size > 0) {
|
|
||||||
// Get current classes on element (may include SSR classes)
|
|
||||||
const currentClasses = toClassList(manager.element.className);
|
|
||||||
|
|
||||||
// Get all classes that will be applied by sources
|
|
||||||
const allSourceClasses = new Set<string>();
|
|
||||||
for (const source of manager.sources.values()) {
|
|
||||||
source.classes.forEach((className) => allSourceClasses.add(className));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only consider classes as "base" if they're not produced by any source
|
|
||||||
// This prevents SSR-rendered classes from being preserved as base classes
|
|
||||||
currentClasses.forEach((className) => {
|
|
||||||
if (!allSourceClasses.has(className)) {
|
|
||||||
manager.baseClasses.add(className);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
manager.hasInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get classes from all sources, sorted by registration order (later takes precedence)
|
|
||||||
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
|
|
||||||
|
|
||||||
const allSourceClasses: string[] = [];
|
|
||||||
for (const [, source] of sortedSources) {
|
|
||||||
allSourceClasses.push(...source.classes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine base classes with all source classes, ensuring base classes take precedence
|
|
||||||
const classesToApply =
|
|
||||||
allSourceClasses.length > 0 || manager.baseClasses.size > 0
|
|
||||||
? twMerge(clsx([...allSourceClasses, ...manager.baseClasses]))
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Apply the classes to the element
|
|
||||||
if (manager.element.className !== classesToApply) {
|
|
||||||
manager.element.className = classesToApply;
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.isUpdating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupManager(element: HTMLElement): void {
|
|
||||||
// Remove from global tracking
|
|
||||||
observedElements.delete(element);
|
|
||||||
elementClassManagers.delete(element);
|
|
||||||
|
|
||||||
// If no more elements being tracked, cleanup global observer
|
|
||||||
if (observedElements.size === 0 && globalObserver) {
|
|
||||||
globalObserver.disconnect();
|
|
||||||
globalObserver = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClassesOptions {
|
|
||||||
elementRef?: ElementRef<HTMLElement>;
|
|
||||||
injector?: Injector;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for parsed class lists to avoid repeated string operations
|
|
||||||
const classListCache = new Map<string, string[]>();
|
|
||||||
|
|
||||||
function toClassList(className: string | ClassValue[]): string[] {
|
|
||||||
// For simple string inputs, use cache to avoid repeated parsing
|
|
||||||
if (typeof className === 'string' && classListCache.has(className)) {
|
|
||||||
return classListCache.get(className)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = clsx(className)
|
|
||||||
.split(' ')
|
|
||||||
.filter((c) => c.length > 0);
|
|
||||||
|
|
||||||
// Cache string results, but limit cache size to prevent memory growth
|
|
||||||
if (typeof className === 'string' && classListCache.size < 1000) {
|
|
||||||
classListCache.set(className, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export const accountingKeys = {
|
|
||||||
all: ['accounting'] as const,
|
|
||||||
|
|
||||||
lists: () => [...accountingKeys.all, 'list'] as const,
|
|
||||||
byState: (accountingState: string) =>
|
|
||||||
[...accountingKeys.lists(), { accountingState }] as const,
|
|
||||||
byStatePaginate: (
|
|
||||||
accountingState: string,
|
|
||||||
page: number | undefined,
|
|
||||||
take: number,
|
|
||||||
) => [...accountingKeys.lists(), { accountingState, page, take }] as const,
|
|
||||||
|
|
||||||
details: () => [...accountingKeys.all, 'detail'] as const,
|
|
||||||
byTourId: (tourId: string) => [...accountingKeys.details(), tourId] as const,
|
|
||||||
|
|
||||||
cars: () => [...accountingKeys.all, 'cars'] as const,
|
|
||||||
byCarAndDate: (carName: string, date: string) =>
|
|
||||||
[...accountingKeys.cars(), { carName, date }] as const,
|
|
||||||
};
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { inject } from '@angular/core';
|
|
||||||
import {
|
|
||||||
injectQuery,
|
|
||||||
keepPreviousData,
|
|
||||||
} from '@tanstack/angular-query-experimental';
|
|
||||||
import { environment } from 'src/environments/environment';
|
|
||||||
import { accountingKeys } from './accounting.keys';
|
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
import {
|
|
||||||
AccountingListDto,
|
|
||||||
OptimizationCarDto,
|
|
||||||
PaginatedResult,
|
|
||||||
PaginatedTourDto,
|
|
||||||
TourCostBreakdownDto,
|
|
||||||
TourDto,
|
|
||||||
TourStateMetaDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
|
|
||||||
const BASE_URL = environment.api.baseUrl;
|
|
||||||
const API_URL = '/accounting';
|
|
||||||
|
|
||||||
export function useTours(
|
|
||||||
accountingState: () => string,
|
|
||||||
page: () => number | undefined,
|
|
||||||
take: () => number,
|
|
||||||
) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: accountingKeys.byStatePaginate(accountingState(), page(), take()),
|
|
||||||
queryFn: () => {
|
|
||||||
let params = new HttpParams()
|
|
||||||
.set('state', accountingState())
|
|
||||||
.set('take', take());
|
|
||||||
|
|
||||||
if (!!page()) {
|
|
||||||
params = params.set('page', page()!);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<PaginatedResult<AccountingListDto>>(
|
|
||||||
`${BASE_URL}${API_URL}/tours`,
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTourDetail(tourId: () => string) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: accountingKeys.byTourId(tourId()),
|
|
||||||
queryFn: () =>
|
|
||||||
lastValueFrom(
|
|
||||||
http.get<TourDto>(`${BASE_URL}${API_URL}/tours/${tourId()}`),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAccountingStateMeta(accountingState: () => string) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: accountingKeys.byState(accountingState()),
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new HttpParams().set('state', accountingState());
|
|
||||||
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<TourStateMetaDto>(`${BASE_URL}${API_URL}/state-meta`, {
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTourCostBreakdown(operationId: () => string) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: accountingKeys.byTourId(operationId()),
|
|
||||||
queryFn: () => {
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<TourCostBreakdownDto>(
|
|
||||||
`${BASE_URL}${API_URL}/cost-breakdown/${operationId()}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enabled: !!operationId(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOptimizationCar(
|
|
||||||
carName: () => string | undefined | null,
|
|
||||||
date: () => Date | string | undefined | null,
|
|
||||||
) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => {
|
|
||||||
const car = carName();
|
|
||||||
const d = date();
|
|
||||||
|
|
||||||
// Datum sicher formatieren für den Query Key und API Param
|
|
||||||
let dateStr = '';
|
|
||||||
if (d) {
|
|
||||||
// Quick & Dirty ISO yyyy-MM-dd, alternativ date-fns format(d, 'yyyy-MM-dd')
|
|
||||||
dateStr = new Date(d).toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
queryKey: accountingKeys.byCarAndDate(car ?? '', dateStr),
|
|
||||||
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new HttpParams().set('operation-day', dateStr);
|
|
||||||
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<OptimizationCarDto>(`${BASE_URL}${API_URL}/cars/${car}`, {
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enabled: !!car && !!dateStr,
|
|
||||||
|
|
||||||
staleTime: 1000 * 60 * 5,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
export const managerKeys = {
|
|
||||||
all: ['manager'] as const,
|
|
||||||
|
|
||||||
overview: (ordinanceTypes: string) => [
|
|
||||||
...managerKeys.all,
|
|
||||||
'overview',
|
|
||||||
{ ordinanceTypes },
|
|
||||||
] as const,
|
|
||||||
|
|
||||||
dayview: (ordinanceType: string, day: Date | string) => [
|
|
||||||
...managerKeys.all,
|
|
||||||
'dayview',
|
|
||||||
{ ordinanceType, day: format(new Date(day), 'yyyy-MM-dd') },
|
|
||||||
] as const,
|
|
||||||
|
|
||||||
monthview: (ordinanceType: string, day: Date | string) => [
|
|
||||||
...managerKeys.all,
|
|
||||||
'monthview',
|
|
||||||
{ ordinanceType, day: format(new Date(day), 'yyyy-MM-dd') },
|
|
||||||
] as const,
|
|
||||||
};
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { inject } from '@angular/core';
|
|
||||||
import {
|
|
||||||
TimePerTourKPISummaryDto,
|
|
||||||
TimePerTourMetricDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
import { environment } from 'src/environments/environment';
|
|
||||||
import { managerKeys } from './manager.keys';
|
|
||||||
|
|
||||||
const BASE_URL = environment.api.baseUrl;
|
|
||||||
const API_URL = '/manager';
|
|
||||||
|
|
||||||
export function getOverviewQuery(ordinanceTypes: string) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: managerKeys.overview(ordinanceTypes),
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new HttpParams().set('ordinanceType', ordinanceTypes);
|
|
||||||
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<TimePerTourKPISummaryDto[]>(`${BASE_URL}${API_URL}/overview`, {
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDayviewQuery(
|
|
||||||
day: () => Date | string,
|
|
||||||
ordinanceType: () => string,
|
|
||||||
) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: managerKeys.dayview(ordinanceType(), day()),
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new HttpParams()
|
|
||||||
.set('day', new Date(day()).toISOString())
|
|
||||||
.set('ordinanceType', ordinanceType());
|
|
||||||
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<TimePerTourMetricDto[]>(`${BASE_URL}${API_URL}/dayview`, {
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMonthviewQuery(
|
|
||||||
day: () => Date | string,
|
|
||||||
ordinanceType: () => string,
|
|
||||||
) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: managerKeys.monthview(ordinanceType(), day()),
|
|
||||||
queryFn: () => {
|
|
||||||
const params = new HttpParams()
|
|
||||||
.set('day', new Date(day()).toISOString())
|
|
||||||
.set('ordinanceType', ordinanceType());
|
|
||||||
|
|
||||||
return lastValueFrom(
|
|
||||||
http.get<TimePerTourMetricDto[]>(`${BASE_URL}${API_URL}/monthview`, {
|
|
||||||
params,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ErrorHandler, Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GlobalErrorHandler implements ErrorHandler {
|
||||||
|
handleError(error: any) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// faro.api.pushError(error);
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-28
@@ -15,7 +15,6 @@ import {
|
|||||||
lucideBell,
|
lucideBell,
|
||||||
lucideCircleAlert,
|
lucideCircleAlert,
|
||||||
lucideCircleCheck,
|
lucideCircleCheck,
|
||||||
lucideInbox,
|
|
||||||
lucideSend,
|
lucideSend,
|
||||||
lucideSquareCheck,
|
lucideSquareCheck,
|
||||||
lucideStamp,
|
lucideStamp,
|
||||||
@@ -51,8 +50,7 @@ import { ticketKeys } from '../../ticket.keys';
|
|||||||
lucideSend,
|
lucideSend,
|
||||||
lucideBell,
|
lucideBell,
|
||||||
lucideSquareCheck,
|
lucideSquareCheck,
|
||||||
lucideCircleAlert,
|
lucideCircleAlert
|
||||||
lucideInbox,
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
template: `<section
|
template: `<section
|
||||||
@@ -60,7 +58,8 @@ import { ticketKeys } from '../../ticket.keys';
|
|||||||
class="flex flex-col overflow-hidden border-l-4 py-4"
|
class="flex flex-col overflow-hidden border-l-4 py-4"
|
||||||
[class.border-l-muted]="view().requirement === 'NOT_REQUIRED'"
|
[class.border-l-muted]="view().requirement === 'NOT_REQUIRED'"
|
||||||
[class.border-l-yellow-500]="
|
[class.border-l-yellow-500]="
|
||||||
view().status !== 'GRANTED' && view().requirement === 'REQUIRED'
|
view().status !== 'GRANTED' &&
|
||||||
|
view().requirement === 'REQUIRED'
|
||||||
"
|
"
|
||||||
[class.border-l-green-500]="view().status === 'GRANTED'"
|
[class.border-l-green-500]="view().status === 'GRANTED'"
|
||||||
>
|
>
|
||||||
@@ -69,7 +68,8 @@ import { ticketKeys } from '../../ticket.keys';
|
|||||||
class="h-9 w-9 flex items-center justify-center rounded-full shrink-0"
|
class="h-9 w-9 flex items-center justify-center rounded-full shrink-0"
|
||||||
[class.bg-muted]="view().requirement === 'NOT_REQUIRED'"
|
[class.bg-muted]="view().requirement === 'NOT_REQUIRED'"
|
||||||
[class.bg-yellow-100]="
|
[class.bg-yellow-100]="
|
||||||
view().status !== 'GRANTED' && view().requirement === 'REQUIRED'
|
view().status !== 'GRANTED' &&
|
||||||
|
view().requirement === 'REQUIRED'
|
||||||
"
|
"
|
||||||
[class.bg-green-100]="view().status === 'GRANTED'"
|
[class.bg-green-100]="view().status === 'GRANTED'"
|
||||||
>
|
>
|
||||||
@@ -116,15 +116,12 @@ import { ticketKeys } from '../../ticket.keys';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
<!-- FIXME: move to backend -->
|
|
||||||
@if (view().requirement === 'NOT_REQUIRED') {
|
@if (view().requirement === 'NOT_REQUIRED') {
|
||||||
Für diese Fahrt ist keine Genehmigung notwendig.
|
Für diese Fahrt ist keine Genehmigung notwendig.
|
||||||
} @else if (view().status === 'NOT_STARTED') {
|
} @else if (view().status === 'NOT_STARTED') {
|
||||||
Noch keine Anfrage gestellt.
|
Noch keine Anfrage gestellt.
|
||||||
} @else if (view().status === 'GRANTED') {
|
} @else if (view().status === 'GRANTED') {
|
||||||
Genehmigung erteilt
|
Genehmigung erteilt
|
||||||
} @else if (view().status === 'EXTERNAL_REQUEST') {
|
|
||||||
Wartet auf Rückmeldung des Kunden
|
|
||||||
} @else {
|
} @else {
|
||||||
Wartet auf Rückmeldung der Kasse.
|
Wartet auf Rückmeldung der Kasse.
|
||||||
}
|
}
|
||||||
@@ -209,19 +206,6 @@ import { ticketKeys } from '../../ticket.keys';
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (view().actions.registerExternalRequest.isPossible) {
|
|
||||||
<button
|
|
||||||
hlmBtn
|
|
||||||
variant="outline"
|
|
||||||
class="w-full gap-2"
|
|
||||||
(click)="registerRequest()"
|
|
||||||
[disabled]="isSubmitting()"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideInbox" size="xs" />
|
|
||||||
{{ view().actions.registerExternalRequest.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (view().actions.sendReminder.isPossible) {
|
@if (view().actions.sendReminder.isPossible) {
|
||||||
<button
|
<button
|
||||||
hlmBtn
|
hlmBtn
|
||||||
@@ -346,13 +330,6 @@ export class TicketApprovalComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerRequest(): Promise<void> {
|
|
||||||
this.executeAction(
|
|
||||||
() => this.actionsService.registerExternalRequest(this.ticketId()),
|
|
||||||
'Externe Anfrage registriert',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendReminder(): Promise<void> {
|
async sendReminder(): Promise<void> {
|
||||||
this.executeAction(
|
this.executeAction(
|
||||||
() => this.actionsService.sendReminder(this.ticketId()),
|
() => this.actionsService.sendReminder(this.ticketId()),
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ import { HlmIcon } from '@spartan-ng/helm/icon';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-ticket-validation-errors',
|
selector: 'app-ticket-validation-errors',
|
||||||
imports: [HlmCardImports, HlmButtonImports, NgIcon, HlmIcon],
|
imports: [HlmCardImports, HlmButtonImports, NgIcon, HlmIcon, RouterLink],
|
||||||
providers: [
|
providers: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
lucideTriangleAlert,
|
lucideTriangleAlert,
|
||||||
|
|||||||
+4
-22
@@ -1,12 +1,6 @@
|
|||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@if (!isUploading()) {
|
@if (!isUploading()) {
|
||||||
<label
|
<label
|
||||||
(dragover)="onDragOver($event)"
|
|
||||||
(dragleave)="onDragLeave($event)"
|
|
||||||
(drop)="onDrop($event)"
|
|
||||||
[class.border-primary]="isDragging()"
|
|
||||||
[class.bg-primary\/10]="isDragging()"
|
|
||||||
[class.border-muted-foreground\/25]="!isDragging()"
|
|
||||||
class="group flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/5 border-muted-foreground/25 hover:bg-muted/20 hover:border-primary/50 transition-all"
|
class="group flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/5 border-muted-foreground/25 hover:bg-muted/20 hover:border-primary/50 transition-all"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -16,21 +10,11 @@
|
|||||||
hlmIcon
|
hlmIcon
|
||||||
name="lucideCloudUpload"
|
name="lucideCloudUpload"
|
||||||
size="lg"
|
size="lg"
|
||||||
[class.scale-125]="isDragging()"
|
|
||||||
[class.text-primary]="isDragging()"
|
|
||||||
class="mb-3 text-muted-foreground group-hover:text-primary group-hover:scale-110 transition-transform duration-300"
|
class="mb-3 text-muted-foreground group-hover:text-primary group-hover:scale-110 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p class="text-sm text-foreground font-medium">
|
<p class="text-sm text-foreground font-medium">Klicken zum Hochladen</p>
|
||||||
@if (isDragging()) {
|
<p class="text-xs text-muted-foreground mt-1">PDF oder Bild (Max 5MB)</p>
|
||||||
Loslassen zum Hochladen
|
|
||||||
} @else {
|
|
||||||
Klicken oder Datei hierher ziehen
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
|
||||||
PDF oder Bild (Max 5MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -51,7 +35,7 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="text-primary animate-pulse"
|
class="text-primary animate-pulse"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium truncate max-w-50">{{
|
<span class="text-sm font-medium truncate max-w-[200px]">{{
|
||||||
fileName()
|
fileName()
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,9 +47,7 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="flex justify-between w-full mt-2 text-xs text-muted-foreground">
|
||||||
class="flex justify-between w-full mt-2 text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span>Lade hoch...</span>
|
<span>Lade hoch...</span>
|
||||||
<span>{{ uploadProgress() }}%</span>
|
<span>{{ uploadProgress() }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-26
@@ -45,32 +45,6 @@ export class TransportDocumentUploadComponent {
|
|||||||
uploadProgress = signal(0);
|
uploadProgress = signal(0);
|
||||||
fileName = signal<string | null>(null);
|
fileName = signal<string | null>(null);
|
||||||
|
|
||||||
isDragging = signal(false);
|
|
||||||
|
|
||||||
onDragOver(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.isDragging.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDragLeave(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.isDragging.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: DragEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.isDragging.set(false);
|
|
||||||
|
|
||||||
const files = event.dataTransfer?.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const file = files[0];
|
|
||||||
this.startUpload(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onFileSelected(event: Event): void {
|
onFileSelected(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
|
|||||||
@@ -10,42 +10,24 @@ export class TicketActionsService {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
private baseUrl = environment.api.baseUrl;
|
private baseUrl = environment.api.baseUrl;
|
||||||
|
|
||||||
async receivePhysicalTransportDocument(ticketId: string): Promise<void> {
|
|
||||||
return lastValueFrom(
|
|
||||||
this.http.post<void>(
|
|
||||||
`${this.baseUrl}/tickets/${ticketId}/transport-document/receive`,
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async overrideApproval(ticketId: string): Promise<void> {
|
async overrideApproval(ticketId: string): Promise<void> {
|
||||||
return lastValueFrom(
|
return lastValueFrom(
|
||||||
this.http.post<void>(
|
this.http.post<void>(
|
||||||
`${this.baseUrl}/tickets/${ticketId}/approval/override`,
|
`${this.baseUrl}/tickets/${ticketId}/approval/override`,
|
||||||
{},
|
{}
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestApproval(
|
async requestApproval(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
recipientEmail: string,
|
recipientEmail: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return lastValueFrom(
|
return lastValueFrom(
|
||||||
this.http.post<void>(
|
this.http.post<void>(
|
||||||
`${this.baseUrl}/tickets/${ticketId}/approval/request`,
|
`${this.baseUrl}/tickets/${ticketId}/approval/request`,
|
||||||
{ recipient: recipientEmail },
|
{ recipient: recipientEmail }
|
||||||
),
|
)
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async registerExternalRequest(ticketId: string): Promise<void> {
|
|
||||||
return lastValueFrom(
|
|
||||||
this.http.post<void>(
|
|
||||||
`${this.baseUrl}/tickets/${ticketId}/approval/register-request`,
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +35,8 @@ export class TicketActionsService {
|
|||||||
return lastValueFrom(
|
return lastValueFrom(
|
||||||
this.http.post<void>(
|
this.http.post<void>(
|
||||||
`${this.baseUrl}/tickets/${ticketId}/approval/remind`,
|
`${this.baseUrl}/tickets/${ticketId}/approval/remind`,
|
||||||
{},
|
{}
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +44,8 @@ export class TicketActionsService {
|
|||||||
return lastValueFrom(
|
return lastValueFrom(
|
||||||
this.http.post<void>(
|
this.http.post<void>(
|
||||||
`${this.baseUrl}/tickets/${ticketId}/approval/approve`,
|
`${this.baseUrl}/tickets/${ticketId}/approval/approve`,
|
||||||
{},
|
{}
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ export const ticketKeys = {
|
|||||||
|
|
||||||
// Listen (Kanban Lanes)
|
// Listen (Kanban Lanes)
|
||||||
lists: () => [...ticketKeys.all, 'list'] as const,
|
lists: () => [...ticketKeys.all, 'list'] as const,
|
||||||
byLane: (lane: string, page: number) =>
|
byLane: (lane: string, page: number) => [...ticketKeys.lists(), { lane, page }] as const,
|
||||||
[...ticketKeys.lists(), { lane, page }] as const,
|
|
||||||
search: (searchTerm: string) =>
|
|
||||||
[...ticketKeys.lists(), { searchTerm }] as const,
|
|
||||||
|
|
||||||
// Details
|
// Details
|
||||||
details: () => [...ticketKeys.all, 'detail'] as const,
|
details: () => [...ticketKeys.all, 'detail'] as const,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
|
import { lastValueFrom } from 'rxjs';
|
||||||
|
import { ticketKeys } from './ticket.keys';
|
||||||
import {
|
import {
|
||||||
PaginatedResult,
|
PaginatedResult,
|
||||||
TicketDetailDto,
|
TicketDetailDto,
|
||||||
@@ -9,9 +11,7 @@ import {
|
|||||||
injectQuery,
|
injectQuery,
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
} from '@tanstack/angular-query-experimental';
|
} from '@tanstack/angular-query-experimental';
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
import { environment } from 'src/environments/environment';
|
import { environment } from 'src/environments/environment';
|
||||||
import { ticketKeys } from './ticket.keys';
|
|
||||||
|
|
||||||
const BASE_URL = environment.api.baseUrl;
|
const BASE_URL = environment.api.baseUrl;
|
||||||
const API_URL = '/tickets';
|
const API_URL = '/tickets';
|
||||||
@@ -24,29 +24,14 @@ export function useTicketLane(lane: () => string, page: () => number) {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return lastValueFrom(
|
return lastValueFrom(
|
||||||
http.get<PaginatedResult<TicketListItemDto>>(
|
http.get<PaginatedResult<TicketListItemDto>>(
|
||||||
`${BASE_URL}${API_URL}?stage=${lane()}&page=${page()}`,
|
`${BASE_URL}${API_URL}?stage=${lane()}&page=${page()}`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTicketSearch(search: () => string) {
|
|
||||||
const http = inject(HttpClient);
|
|
||||||
|
|
||||||
return injectQuery(() => ({
|
|
||||||
queryKey: ticketKeys.search(search()),
|
|
||||||
queryFn: async () =>
|
|
||||||
lastValueFrom(
|
|
||||||
http.get<TicketListItemDto[]>(
|
|
||||||
`${BASE_URL}${API_URL}/search?q=${search()}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabled: !!search(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTicketDetail(ticketId: () => string) {
|
export function useTicketDetail(ticketId: () => string) {
|
||||||
const http = inject(HttpClient);
|
const http = inject(HttpClient);
|
||||||
|
|
||||||
@@ -55,7 +40,7 @@ export function useTicketDetail(ticketId: () => string) {
|
|||||||
enabled: !!ticketId(),
|
enabled: !!ticketId(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return lastValueFrom(
|
return lastValueFrom(
|
||||||
http.get<TicketDetailDto>(`${BASE_URL}${API_URL}/${ticketId()}`),
|
http.get<TicketDetailDto>(`${BASE_URL}${API_URL}/${ticketId()}`)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { ApplicationConfig, inject } from '@angular/core';
|
import { ApplicationConfig, inject } from '@angular/core';
|
||||||
import {
|
import { ApolloClientOptions, InMemoryCache, split } from '@apollo/client/core';
|
||||||
ApolloClientOptions,
|
|
||||||
ApolloLink,
|
|
||||||
InMemoryCache,
|
|
||||||
split,
|
|
||||||
} from '@apollo/client/core';
|
|
||||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||||
import { getMainDefinition } from '@apollo/client/utilities';
|
import { getMainDefinition } from '@apollo/client/utilities';
|
||||||
import { provideApollo } from 'apollo-angular';
|
import { Apollo, APOLLO_OPTIONS } from 'apollo-angular';
|
||||||
import { HttpLink } from 'apollo-angular/http';
|
import { HttpLink } from 'apollo-angular/http';
|
||||||
import { OperationDefinitionNode } from 'graphql';
|
import { OperationDefinitionNode } from 'graphql';
|
||||||
import { createClient } from 'graphql-ws';
|
import { createClient } from 'graphql-ws';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
const uri = environment.graphQl.endpoint; // <-- add the URL of the GraphQL server here
|
const uri = environment.graphQl.endpoint; // <-- add the URL of the GraphQL server here
|
||||||
export function apolloOptionsFactory(): ApolloClientOptions {
|
export function apolloOptionsFactory(): ApolloClientOptions<any> {
|
||||||
const httpLink = inject(HttpLink);
|
const httpLink = inject(HttpLink);
|
||||||
|
|
||||||
const http = httpLink.create({ uri });
|
const http = httpLink.create({ uri });
|
||||||
@@ -22,18 +17,18 @@ export function apolloOptionsFactory(): ApolloClientOptions {
|
|||||||
const ws = new GraphQLWsLink(
|
const ws = new GraphQLWsLink(
|
||||||
createClient({
|
createClient({
|
||||||
url: environment.graphQl.ws,
|
url: environment.graphQl.ws,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const link = split(
|
const link = split(
|
||||||
({ query }) => {
|
({ query }) => {
|
||||||
const { kind, operation } = getMainDefinition(
|
const { kind, operation } = getMainDefinition(
|
||||||
query,
|
query
|
||||||
) as OperationDefinitionNode;
|
) as OperationDefinitionNode;
|
||||||
return kind === 'OperationDefinition' && operation === 'subscription';
|
return kind === 'OperationDefinition' && operation === 'subscription';
|
||||||
},
|
},
|
||||||
ws,
|
ws,
|
||||||
http,
|
http
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
link,
|
link,
|
||||||
@@ -42,35 +37,9 @@ export function apolloOptionsFactory(): ApolloClientOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const graphqlProvider: ApplicationConfig['providers'] = [
|
export const graphqlProvider: ApplicationConfig['providers'] = [
|
||||||
provideApollo(() => {
|
Apollo,
|
||||||
const httpLink = inject(HttpLink);
|
{
|
||||||
|
provide: APOLLO_OPTIONS,
|
||||||
const http = httpLink.create({ uri });
|
useFactory: apolloOptionsFactory,
|
||||||
|
|
||||||
const ws = new GraphQLWsLink(
|
|
||||||
createClient({
|
|
||||||
url: environment.graphQl.ws,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const link = ApolloLink.split(
|
|
||||||
({ query }) => {
|
|
||||||
const { kind, operation } = getMainDefinition(
|
|
||||||
query,
|
|
||||||
) as OperationDefinitionNode;
|
|
||||||
return kind === 'OperationDefinition' && operation === 'subscription';
|
|
||||||
},
|
},
|
||||||
ws,
|
|
||||||
http,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
link,
|
|
||||||
cache: new InMemoryCache(),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
// Apollo,
|
|
||||||
// {
|
|
||||||
// provide: APOLLO_OPTIONS,
|
|
||||||
// useFactory: apolloOptionsFactory,
|
|
||||||
// },
|
|
||||||
];
|
];
|
||||||
|
|||||||
+66
-90
@@ -1,5 +1,5 @@
|
|||||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||||
import { CurrencyPipe, DatePipe, NgClass } from '@angular/common';
|
import { CurrencyPipe, NgClass } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
@@ -11,121 +11,97 @@ import {
|
|||||||
import { MatBadgeModule } from '@angular/material/badge';
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { AccountingListDto } from '@avicenna/shared-dtos';
|
|
||||||
import { HotToastService } from '@ngxpert/hot-toast';
|
import { HotToastService } from '@ngxpert/hot-toast';
|
||||||
import { AccountingState } from '../../../core/data-access/graphql/generated/generated';
|
import {
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
AccountingState,
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
Ticket,
|
||||||
import { lucideCalendar } from '@ng-icons/lucide';
|
TicketValidationState,
|
||||||
|
Tour,
|
||||||
|
} from '../../../core/data-access/graphql/generated/generated';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: 'dks-accounting-item',
|
selector: 'dks-accounting-item',
|
||||||
template: `
|
template: `
|
||||||
<div
|
<div class="flex justify-between">
|
||||||
class="group relative flex flex-col justify-between rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all hover:shadow-md hover:border-primary/50 mb-2"
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="notifyCopy($event)"
|
||||||
|
[cdkCopyToClipboard]="tour.operationId"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between p-3 pb-1">
|
<mat-icon class="text-sm">content_copy</mat-icon>
|
||||||
<div class="flex flex-col">
|
</button>
|
||||||
|
<a
|
||||||
|
class="dispolive-btn"
|
||||||
|
target="_blank"
|
||||||
|
[href]="'https://avicenna.dispolive.de/fahrbericht/' + tour.id"
|
||||||
|
>
|
||||||
|
{{ tour.operationId }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span>{{ tour.ordinanceType }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@if (accountingState !== AccountingState.Billed) {
|
||||||
<span
|
<span
|
||||||
class="text-[10px] uppercase text-muted-foreground font-medium tracking-wider"
|
[title]="!tour.healthInsurance ? 'Keine Krankenkasse hinterlegt' : ''"
|
||||||
|
[ngClass]="{ 'bg-yellow-200': !tour.healthInsurance }"
|
||||||
>
|
>
|
||||||
Ref
|
{{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
} @else {
|
||||||
class="font-mono text-xs text-foreground/80 font-semibold group-hover:text-primary transition-colors"
|
{{
|
||||||
>
|
tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
|
||||||
#{{ tour.operationId }}
|
}}
|
||||||
</span>
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-2 py-2">
|
|
||||||
<div
|
|
||||||
[class]="
|
|
||||||
tour.isTicketOpen
|
|
||||||
? 'border-sky-700 text-sky-700 bg-sky-50/10'
|
|
||||||
: 'border-border text-muted-foreground/30'
|
|
||||||
"
|
|
||||||
class="flex px-2 py-1 items-center justify-center rounded-md text-xs font-bold border-2 transition-all"
|
|
||||||
title="Ticket Offen"
|
|
||||||
>
|
|
||||||
TO
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
[class]="
|
|
||||||
tour.hasRevenueDeviation
|
|
||||||
? 'border-sky-700 text-sky-700 bg-sky-50/10'
|
|
||||||
: 'border-border text-muted-foreground/30'
|
|
||||||
"
|
|
||||||
class="flex px-2 py-1 items-center justify-center rounded-md text-xs font-bold border-2 transition-all"
|
|
||||||
title="Abrechnungsabweichung"
|
|
||||||
>
|
|
||||||
AA
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
[class]="
|
|
||||||
tour.hasOptimizationPotential
|
|
||||||
? 'border-sky-700 text-sky-700 bg-sky-50/10'
|
|
||||||
: 'border-border text-muted-foreground/30'
|
|
||||||
"
|
|
||||||
class="flex px-2 py-1 items-center justify-center rounded-md text-xs font-bold border-2 transition-all"
|
|
||||||
title="Optimierungspotenzial"
|
|
||||||
>
|
|
||||||
AO
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground ring-1 ring-inset ring-gray-500/10"
|
|
||||||
>
|
|
||||||
{{ tour.ordinanceType }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-end justify-between border-t border-border/50 bg-muted/5 p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1 text-muted-foreground">
|
|
||||||
<ng-icon hlmIcon size="sm" name="lucideCalendar" class="opacity-80"></ng-icon>
|
|
||||||
<span class="text-xs font-medium">
|
|
||||||
{{ tour.startDate | date: 'dd.MM.yy' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="text-sm font-bold tracking-tight text-foreground">
|
|
||||||
{{ tour.revenue | currency: 'EUR' : 'symbol' : '1.2-2' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
host: {
|
||||||
|
class: 'flex flex-col rounded border p-2 m-1 bg-white cursor-pointer',
|
||||||
|
},
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
:host {
|
||||||
|
@include mat.icon-button-overrides(
|
||||||
|
(
|
||||||
|
icon-size: 14,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatBadgeModule,
|
MatBadgeModule,
|
||||||
ClipboardModule,
|
ClipboardModule,
|
||||||
|
NgClass,
|
||||||
CurrencyPipe,
|
CurrencyPipe,
|
||||||
DatePipe,
|
|
||||||
NgIcon,
|
|
||||||
HlmIcon,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
provideIcons({
|
|
||||||
lucideCalendar,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AccountingItemComponent {
|
export class AccountingItemComponent implements AfterViewInit {
|
||||||
toast = inject(HotToastService);
|
toast = inject(HotToastService);
|
||||||
|
|
||||||
@Input() tour!: AccountingListDto;
|
@Input() tour!: Tour & { ticket: Ticket };
|
||||||
@Input() accountingState!: AccountingState;
|
@Input() accountingState!: AccountingState;
|
||||||
|
|
||||||
|
@HostBinding('style.border-color')
|
||||||
|
borderColor = '#ffffff';
|
||||||
|
|
||||||
AccountingState = AccountingState;
|
AccountingState = AccountingState;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (
|
||||||
|
this.tour.ticket.currentState !== TicketValidationState.Archived &&
|
||||||
|
(this.accountingState === AccountingState.Billable ||
|
||||||
|
this.accountingState === AccountingState.PreparedForBilling)
|
||||||
|
) {
|
||||||
|
this.borderColor = '#dc2626';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notifyCopy(event: Event) {
|
notifyCopy(event: Event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.toast.success('Einsatznummer kopiert!', {
|
this.toast.success('Einsatznummer kopiert!', {
|
||||||
|
|||||||
+20
-61
@@ -1,69 +1,28 @@
|
|||||||
<dks-accounting-lane-header
|
<dks-accounting-lane-header
|
||||||
class="border-b border-border/50 bg-card z-10"
|
class="shadow"
|
||||||
[state]="accountingState()"
|
[state]="accountingState"
|
||||||
|
(filterChange)="filterChanged($event)"
|
||||||
></dks-accounting-lane-header>
|
></dks-accounting-lane-header>
|
||||||
|
@if (tours$ | async; as tours) {
|
||||||
<div class="overflow-y-auto p-3 space-y-3 scroll-smooth">
|
<cdk-virtual-scroll-viewport
|
||||||
@if (toursQuery.isPending()) {
|
itemSize="86"
|
||||||
<div
|
(scrolledIndexChange)="fetchNextPage()"
|
||||||
class="flex h-20 items-center justify-center text-muted-foreground text-sm animate-pulse"
|
|
||||||
>
|
>
|
||||||
Lade Touren...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (toursQuery.data(); as result) {
|
|
||||||
@for (tour of result.data; track tour.tourId) {
|
|
||||||
<dks-accounting-item
|
<dks-accounting-item
|
||||||
class="block animate-in fade-in zoom-in-95 duration-200"
|
*cdkVirtualFor="let tour of tours; trackBy: trackByFn"
|
||||||
[accountingState]="accountingState()"
|
class="animate-fadeIn"
|
||||||
|
style="box-shadow: var(--mat-sys-level2);"
|
||||||
|
[accountingState]="accountingState"
|
||||||
[tour]="tour"
|
[tour]="tour"
|
||||||
(click)="openTourView(tour)"
|
(click)="openTourView(tour)"
|
||||||
>
|
>
|
||||||
</dks-accounting-item>
|
</dks-accounting-item>
|
||||||
}
|
</cdk-virtual-scroll-viewport>
|
||||||
@if (result.data.length === 0) {
|
<!-- <ng-container *ngIf="accountingStateMeta$ | async as accountingStateMeta">
|
||||||
<div
|
<dks-accounting-lane-footer
|
||||||
class="flex h-28 flex-col items-center justify-center rounded-xl border border-dashed border-muted-foreground/25 bg-muted/50 p-4 text-center text-sm text-muted-foreground"
|
[tourStateMeta]="accountingStateMeta"
|
||||||
>
|
[withTourCount]="true"
|
||||||
Keine Touren in dieser Lane.
|
[withRevenueSum]="accountingState !== AccountingState.Billed"
|
||||||
</div>
|
></dks-accounting-lane-footer>
|
||||||
}
|
</ng-container> -->
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav
|
|
||||||
hlmPagination
|
|
||||||
class="border-t border-border bg-card/50 p-2 backdrop-blur-[2px]"
|
|
||||||
>
|
|
||||||
<ul hlmPaginationContent class="justify-between px-2 w-full">
|
|
||||||
<li hlmPaginationItem>
|
|
||||||
<hlm-pagination-previous
|
|
||||||
class="h-8 w-8"
|
|
||||||
variant="ghost"
|
|
||||||
iconOnly
|
|
||||||
(click)="prevPage()"
|
|
||||||
[class.opacity-50]="!hasPrevPage()"
|
|
||||||
[class.pointer-events-none]="!hasPrevPage()"
|
|
||||||
></hlm-pagination-previous>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li hlmPaginationItem class="text-xs font-medium text-muted-foreground">
|
|
||||||
@if (toursQuery.data(); as res) {
|
|
||||||
Seite {{ page() }} / {{ res.meta.totalPages }}
|
|
||||||
} @else {
|
|
||||||
- / -
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li hlmPaginationItem>
|
|
||||||
<hlm-pagination-next
|
|
||||||
class="h-8 w-8"
|
|
||||||
variant="ghost"
|
|
||||||
iconOnly
|
|
||||||
(click)="nextPage()"
|
|
||||||
[class.opacity-50]="!hasNextPage()"
|
|
||||||
[class.pointer-events-none]="!hasNextPage()"
|
|
||||||
></hlm-pagination-next>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|||||||
+156
-38
@@ -1,23 +1,36 @@
|
|||||||
import {
|
import {
|
||||||
ScrollingModule
|
CdkVirtualScrollViewport,
|
||||||
|
ScrollingModule,
|
||||||
} from '@angular/cdk/scrolling';
|
} from '@angular/cdk/scrolling';
|
||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
Input,
|
||||||
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { AccountingListDto, PaginatedResult } from '@avicenna/shared-dtos';
|
|
||||||
import { DialogService } from '@ngneat/dialog';
|
import { DialogService } from '@ngneat/dialog';
|
||||||
import { HlmPaginationImports } from '@spartan-ng/helm/pagination';
|
|
||||||
import { tap } from 'rxjs';
|
|
||||||
import { useTours } from 'src/app/core/data-access/accounting/accounting.queries';
|
|
||||||
import {
|
import {
|
||||||
AccountingState
|
BehaviorSubject,
|
||||||
|
Observable,
|
||||||
|
combineLatest,
|
||||||
|
debounceTime,
|
||||||
|
filter,
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
scan,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
AccountingFilterArgs,
|
||||||
|
AccountingState,
|
||||||
|
Ticket,
|
||||||
|
Tour,
|
||||||
} from '../../../core/data-access/graphql/generated/generated';
|
} from '../../../core/data-access/graphql/generated/generated';
|
||||||
import { AccountingTourViewComponent } from '../accounting-tour-view/accounting-tour-view.component';
|
import { AccountingTourViewComponent } from '../accounting-tour-view/accounting-tour-view.component';
|
||||||
|
import { DashboardAccountingService } from '../dashboard-accounting.service';
|
||||||
import { AccountingItemComponent } from './accounting-item.component';
|
import { AccountingItemComponent } from './accounting-item.component';
|
||||||
import { AccountingLaneHeaderComponent } from './accounting-lane-header.component';
|
import { AccountingLaneHeaderComponent } from './accounting-lane-header.component';
|
||||||
|
|
||||||
@@ -26,49 +39,99 @@ import { AccountingLaneHeaderComponent } from './accounting-lane-header.componen
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
templateUrl: './accounting-lane.component.html',
|
templateUrl: './accounting-lane.component.html',
|
||||||
host: {
|
host: {
|
||||||
class:
|
class: 'rounded h-full w-full grid',
|
||||||
'grid grid-rows-[auto_1fr_auto] h-full w-full rounded-2xl border border-border bg-muted/5 shadow-sm overflow-hidden',
|
|
||||||
},
|
},
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
box-shadow: var(--mat-sys-level1);
|
||||||
|
background-color: var(--mat-sys-surface-container-low);
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
imports: [
|
imports: [
|
||||||
AccountingLaneHeaderComponent,
|
AccountingLaneHeaderComponent,
|
||||||
AccountingItemComponent,
|
AccountingItemComponent,
|
||||||
ScrollingModule,
|
AsyncPipe,
|
||||||
HlmPaginationImports,
|
ScrollingModule
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AccountingLaneComponent {
|
export class AccountingLaneComponent {
|
||||||
private dialog = inject(DialogService)
|
private dialog = inject(DialogService);
|
||||||
accountingState = input.required<AccountingState>();
|
private accountingService = inject(DashboardAccountingService);
|
||||||
|
|
||||||
page = signal(1);
|
private accountingState$ = new BehaviorSubject<AccountingState | undefined>(
|
||||||
private readonly take = signal<number>(10);
|
undefined
|
||||||
|
);
|
||||||
|
private accountingFilter$ = new BehaviorSubject<AccountingFilterArgs>({});
|
||||||
|
|
||||||
toursQuery = useTours(this.accountingState, this.page, this.take);
|
@Input() set accountingState(accountingState: AccountingState) {
|
||||||
|
this.accountingState$.next(accountingState);
|
||||||
hasNextPage = computed(() => {
|
|
||||||
const data: PaginatedResult<AccountingListDto> | undefined =
|
|
||||||
this.toursQuery.data();
|
|
||||||
if (!data) return false;
|
|
||||||
return this.page() < data.meta.totalPages;
|
|
||||||
});
|
|
||||||
|
|
||||||
hasPrevPage = computed(() => this.page() > 1);
|
|
||||||
|
|
||||||
prevPage(): void {
|
|
||||||
if (this.hasPrevPage()) {
|
|
||||||
this.page.update((p) => p - 1);
|
|
||||||
}
|
}
|
||||||
|
get accountingState() {
|
||||||
|
return this.accountingState$.getValue() as AccountingState;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextPage(): void {
|
@ViewChild(CdkVirtualScrollViewport)
|
||||||
if (this.hasNextPage()) {
|
viewport!: CdkVirtualScrollViewport;
|
||||||
this.page.update((p) => p + 1);
|
|
||||||
}
|
AccountingState = AccountingState;
|
||||||
|
|
||||||
|
take = 20;
|
||||||
|
hasNextPage = true;
|
||||||
|
nextPage = '';
|
||||||
|
|
||||||
|
cursor$ = new BehaviorSubject<string>('');
|
||||||
|
accountingStateMeta$ = this.accountingState$.pipe(
|
||||||
|
filter(Boolean),
|
||||||
|
switchMap((accountingState) =>
|
||||||
|
this.accountingService.fetchAccountingStateMeta(accountingState)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
tours$: Observable<(Tour & { ticket: Ticket })[]>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const batchMap: Observable<Record<string, Tour & { ticket: Ticket }>> =
|
||||||
|
combineLatest([this.cursor$, this.accountingFilter$]).pipe(
|
||||||
|
debounceTime(200),
|
||||||
|
mergeMap(([cursor, filter]) =>
|
||||||
|
this.fetchTours(cursor, filter).pipe(
|
||||||
|
map((tours) => ({ tours, cursor }))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
scan((acc, { tours, cursor }) => {
|
||||||
|
const normalizedCursor = this.getNormalizedCursor(cursor);
|
||||||
|
const existingTours = Object.entries(acc)
|
||||||
|
.filter(
|
||||||
|
([id]) =>
|
||||||
|
id.split('_').at(0) !== normalizedCursor ||
|
||||||
|
Object.keys(tours).includes(id.split('_').at(1) ?? '')
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(acc, [id, tour]) => ({
|
||||||
|
...acc,
|
||||||
|
[`${normalizedCursor}_${id}`]: tour,
|
||||||
|
}),
|
||||||
|
{} as Record<string, Tour & { ticket: Ticket }>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...existingTours, ...tours };
|
||||||
|
}, {} as Record<string, Tour & { ticket: Ticket }>)
|
||||||
|
);
|
||||||
|
this.tours$ = batchMap.pipe(
|
||||||
|
map((tourNodes) =>
|
||||||
|
Object.values(tourNodes).sort(
|
||||||
|
({ createdAt: aCreated }, { createdAt: bCreated }) =>
|
||||||
|
new Date(bCreated).getTime() - new Date(aCreated).getTime()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
openTourView(tour: AccountingListDto): void {
|
openTourView(tour: Tour & { ticket: Ticket }): void {
|
||||||
const dialogRef = this.dialog.open(AccountingTourViewComponent, {
|
const dialogRef = this.dialog.open(AccountingTourViewComponent, {
|
||||||
data: tour.tourId,
|
data: tour,
|
||||||
width: '60vw',
|
width: '60vw',
|
||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
});
|
});
|
||||||
@@ -76,4 +139,59 @@ export class AccountingLaneComponent {
|
|||||||
.pipe(tap((result) => result && this.openTourView(result)))
|
.pipe(tap((result) => result && this.openTourView(result)))
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackByFn(index: number, tour: Tour): string {
|
||||||
|
return tour.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNextPage() {
|
||||||
|
if (!this.hasNextPage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = this.viewport.getRenderedRange().start;
|
||||||
|
const end = this.viewport.getRenderedRange().end;
|
||||||
|
const total = this.viewport.getDataLength();
|
||||||
|
|
||||||
|
if (this.cursor$.getValue() !== '' && start === 0) {
|
||||||
|
this.cursor$.next('');
|
||||||
|
} else if (total - end < 5) {
|
||||||
|
this.cursor$.next(this.nextPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchTours(
|
||||||
|
cursor: string,
|
||||||
|
filter: AccountingFilterArgs
|
||||||
|
): Observable<Record<string, Tour & { ticket: Ticket }>> {
|
||||||
|
const normalizedCursor = this.getNormalizedCursor(cursor);
|
||||||
|
|
||||||
|
return this.accountingService
|
||||||
|
.fetchToursByState(this.accountingState, cursor, filter, this.take)
|
||||||
|
.pipe(
|
||||||
|
tap(({ endCursor, hasNextPage }) => {
|
||||||
|
this.nextPage = endCursor;
|
||||||
|
this.hasNextPage = hasNextPage;
|
||||||
|
}),
|
||||||
|
map(({ nodes }) =>
|
||||||
|
nodes?.reduce(
|
||||||
|
(acc, cur) => ({
|
||||||
|
...acc,
|
||||||
|
[`${normalizedCursor}_${cur.id}`]: cur as Tour & {
|
||||||
|
ticket: Ticket;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{} as Record<string, Tour & { ticket: Ticket }>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNormalizedCursor(cursor: string) {
|
||||||
|
return cursor === '' ? 'MA==' : cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChanged(filter: AccountingFilterArgs) {
|
||||||
|
this.accountingFilter$.next(filter);
|
||||||
|
this.cursor$.next('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-250
@@ -1,291 +1,105 @@
|
|||||||
import { CurrencyPipe, DecimalPipe, NgClass } from '@angular/common';
|
import { CurrencyPipe, NgClass, NgStyle } from '@angular/common';
|
||||||
import {
|
import { Component, effect, input, signal } from '@angular/core';
|
||||||
Component,
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
input,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { TourDto } from '@avicenna/shared-dtos';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideAlertTriangle,
|
|
||||||
lucideCheckCircle2,
|
|
||||||
lucideReceipt,
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { HlmCardImports } from '@spartan-ng/helm/card';
|
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
|
||||||
import { tap } from 'rxjs';
|
import { tap } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
GetTourCostBreakdownGQL,
|
GetTourCostBreakdownGQL,
|
||||||
GetTourCostBreakdownQuery,
|
GetTourCostBreakdownQuery,
|
||||||
|
Tour,
|
||||||
} from '../../../core/data-access/graphql/generated/generated';
|
} from '../../../core/data-access/graphql/generated/generated';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-accounting-tour-cost-breakdown',
|
selector: 'app-accounting-tour-cost-breakdown',
|
||||||
imports: [
|
imports: [MatDividerModule, CurrencyPipe, NgClass],
|
||||||
CurrencyPipe,
|
|
||||||
DecimalPipe,
|
|
||||||
NgClass,
|
|
||||||
HlmCardImports,
|
|
||||||
NgIcon,
|
|
||||||
HlmIcon,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
provideIcons({ lucideReceipt, lucideAlertTriangle, lucideCheckCircle2 }),
|
|
||||||
],
|
|
||||||
template: `
|
template: `
|
||||||
<section
|
@if (costBreakdown() !== undefined) {
|
||||||
hlmCard
|
<div class="px-4 py-2 flex justify-between">
|
||||||
class="m-4 border-border shadow-sm bg-card text-card-foreground overflow-hidden"
|
<div class="flex flex-col text-gray-600 text-sm">
|
||||||
>
|
<span class="font-medium">Tarif</span>
|
||||||
<div hlmCardHeader class="border-b bg-muted/20 px-6 py-4">
|
<span>
|
||||||
<div hlmCardTitle>
|
{{ costBreakdown()?.tariffDescription }}
|
||||||
<span
|
|
||||||
class="text-xs font-medium text-muted-foreground uppercase tracking-wider block mb-1"
|
|
||||||
>
|
|
||||||
Angewandter Tarif
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col text-gray-600 text-sm">
|
||||||
|
<span class="font-medium">Gültigkeitszeitraum</span>
|
||||||
|
<span>
|
||||||
|
{{ costBreakdown()?.tariffValidityPeriod }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div class="px-4 py-2 grid *:py-1">
|
||||||
<div
|
<div
|
||||||
class="font-semibold text-foreground text-sm flex items-center gap-2"
|
class="w-full justify-self-start"
|
||||||
|
style="background-color: var(--mat-sys-secondary-container)"
|
||||||
>
|
>
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideReceipt"
|
|
||||||
size="xs"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
/>
|
|
||||||
{{ costBreakdown()?.tariffDescription || 'Lade Tarifdaten...' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div hlmCardAction>
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium text-muted-foreground uppercase tracking-wider block mb-1"
|
|
||||||
>
|
|
||||||
Gültigkeit
|
|
||||||
</span>
|
|
||||||
<div class="font-medium text-foreground text-sm">
|
|
||||||
{{ costBreakdown()?.tariffValidityPeriod || '—' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (costBreakdown()) {
|
|
||||||
<div hlmCardContent class="p-0">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm text-left">
|
|
||||||
<thead
|
|
||||||
class="text-xs text-muted-foreground uppercase bg-gray-50/50 border-b border-border"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="px-6 py-3 font-medium">
|
|
||||||
Beschreibung
|
Beschreibung
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 font-medium text-right">
|
|
||||||
Einzelwert
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="px-6 py-3 font-medium text-right">
|
|
||||||
Kumulativ
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-border">
|
|
||||||
@for (pos of calculatedRows(); track $index) {
|
|
||||||
<tr class="hover:bg-muted/50 transition-colors">
|
|
||||||
<td class="px-6 py-3 font-medium text-foreground">
|
|
||||||
{{ pos.item.description }}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 text-right font-mono text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
pos.item.value / 100
|
|
||||||
| currency: 'EUR' : 'symbol' : '.2' : 'de'
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="px-6 py-3 text-right font-mono font-medium text-foreground"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
pos.runningTotal / 100
|
|
||||||
| currency: 'EUR' : 'symbol' : '.2' : 'de'
|
|
||||||
}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- <div
|
||||||
|
class="w-full justify-self-center"
|
||||||
<div class="border-t border-border p-4 bg-gray-50/30">
|
style="background-color: var(--mat-sys-secondary-container)"
|
||||||
@if (!hasDeviation()) {
|
>
|
||||||
|
Typ
|
||||||
|
</div> -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-2 rounded-lg border border-green-200 bg-green-50 text-green-800"
|
class="w-full justify-self-end"
|
||||||
|
style="background-color: var(--mat-sys-secondary-container)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
Wert
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
class="bg-green-100 h-8 w-8 flex justify-center items-center rounded-full"
|
class="w-full justify-self-end"
|
||||||
|
style="background-color: var(--mat-sys-secondary-container)"
|
||||||
>
|
>
|
||||||
<ng-icon
|
Summe
|
||||||
hlmIcon
|
|
||||||
name="lucideCheckCircle2"
|
|
||||||
class="text-green-600"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
@for (servicePosition of costBreakdown()?.servicePositions; track $index;
|
||||||
<span class="text-sm font-bold">Umsatz abgeglichen</span>
|
let o = $odd) {
|
||||||
<span class="text-xs opacity-80"
|
<div class="w-full" [ngClass]="{ 'bg-gray-100': o }">
|
||||||
>Berechnung deckt sich mit Dispo-System</span
|
{{ servicePosition.description }}
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<!-- <div class="justify-self-center w-full break-all" [ngClass]="{ 'bg-gray-100': o }">
|
||||||
<span class="text-lg font-bold font-mono px-2">
|
{{ servicePosition.variant }}
|
||||||
|
</div> -->
|
||||||
|
<div class="justify-self-end w-full" [ngClass]="{ 'bg-gray-100': o }">
|
||||||
{{
|
{{
|
||||||
totalCalculated() | currency: 'EUR' : 'symbol' : '.2' : 'de'
|
servicePosition.value / 100
|
||||||
|
| currency : 'EUR' : 'symbol' : '.2' : 'de'
|
||||||
}}
|
}}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
<div class="justify-self-end w-full" [ngClass]="{ 'bg-gray-100': o }">
|
||||||
<div
|
|
||||||
class="flex flex-col gap-3 p-3 rounded-lg border border-amber-200 bg-amber-50 text-amber-900"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 pb-2 border-b border-amber-200/60"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideAlertTriangle"
|
|
||||||
class="text-amber-600"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<span class="font-bold text-sm">Umsatz-Abweichung erkannt</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2! gap-4 text-sm">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-xs uppercase text-amber-700/70 font-semibold"
|
|
||||||
>System Berechnung</span
|
|
||||||
>
|
|
||||||
<span class="font-mono font-medium">
|
|
||||||
{{
|
{{
|
||||||
totalCalculated()
|
sumServicePositionToIndex(costBreakdown()?.servicePositions, $index) /
|
||||||
| currency: 'EUR' : 'symbol' : '.2' : 'de'
|
100 | currency : 'EUR' : 'symbol' : '.2' : 'de'
|
||||||
}}
|
}}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col text-right">
|
|
||||||
<span
|
|
||||||
class="text-xs uppercase text-amber-700/70 font-semibold"
|
|
||||||
>Dispolive</span
|
|
||||||
>
|
|
||||||
<span class="font-mono font-medium">
|
|
||||||
{{
|
|
||||||
tour().revenueDispoLive ?? 0
|
|
||||||
| currency: 'EUR' : 'symbol' : '.2' : 'de'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="pt-2 flex justify-between items-center bg-white/50 p-2 rounded border border-amber-100"
|
|
||||||
>
|
|
||||||
<span class="text-xs font-semibold text-amber-800"
|
|
||||||
>Differenz</span
|
|
||||||
>
|
|
||||||
<span class="font-mono font-bold text-red-600">
|
|
||||||
{{
|
|
||||||
getDeviation() | currency: 'EUR' : 'symbol' : '.2' : 'de'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
|
||||||
<div
|
|
||||||
class="p-8 text-center text-muted-foreground text-sm animate-pulse"
|
|
||||||
>
|
|
||||||
Berechne Positionen...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: 3fr 1fr 1fr;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
styles: `.grid {grid-template-columns: 3fr 1fr 1fr;}`,
|
||||||
})
|
})
|
||||||
export class AccountingTourCostBreakdownComponent {
|
export class AccountingTourCostBreakdownComponent {
|
||||||
private gql = inject(GetTourCostBreakdownGQL);
|
tour = input.required<Tour>();
|
||||||
|
|
||||||
tour = input.required<TourDto>();
|
|
||||||
|
|
||||||
costBreakdown = signal<
|
costBreakdown = signal<
|
||||||
GetTourCostBreakdownQuery['tourCostBreakdown'] | undefined
|
GetTourCostBreakdownQuery['tourCostBreakdown'] | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
constructor() {
|
constructor(private readonly tourCostBreakdownGql: GetTourCostBreakdownGQL) {
|
||||||
// Data Fetching Effect
|
effect(() => {
|
||||||
effect((onCleanup) => {
|
tourCostBreakdownGql
|
||||||
const operationId = this.tour().operationId;
|
.fetch({ operationId: this.tour().operationId })
|
||||||
if (!operationId) return;
|
.pipe(tap((res) => this.costBreakdown.set(res.data.tourCostBreakdown)))
|
||||||
|
|
||||||
const sub = this.gql
|
|
||||||
.fetch({ variables: { operationId } })
|
|
||||||
.pipe(tap((res) => this.costBreakdown.set(res.data?.tourCostBreakdown)))
|
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|
||||||
onCleanup(() => sub.unsubscribe());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Computed Logic für das Template ---
|
sumServicePositionToIndex(
|
||||||
|
servicePositions: any[] | undefined,
|
||||||
/** * Berechnet die Zeilen inklusive Running Total vorab,
|
idx: number
|
||||||
* damit wir keine Funktionen im Template aufrufen müssen.
|
): number {
|
||||||
*/
|
return (servicePositions ?? [])
|
||||||
calculatedRows = computed(() => {
|
.slice(0, idx + 1)
|
||||||
const positions = this.costBreakdown()?.servicePositions || [];
|
.reduce((sum, { value }) => (sum += value), 0);
|
||||||
let runningTotal = 0;
|
}
|
||||||
|
|
||||||
return positions.map((item) => {
|
|
||||||
runningTotal += item.value;
|
|
||||||
return { item, runningTotal };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Gesamtsumme unserer Berechnung (letzter Wert des Running Totals) */
|
|
||||||
totalCalculated = computed(() => {
|
|
||||||
const rows = this.calculatedRows();
|
|
||||||
return rows.length > 0 ? rows[rows.length - 1].runningTotal / 100 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Prüfung auf Abweichung (Toleranz von 1 Cent wegen Rundung ist oft sinnvoll) */
|
|
||||||
hasDeviation = computed(() => {
|
|
||||||
const calculated = this.totalCalculated();
|
|
||||||
const external = this.tour().revenueDispoLive ?? 0; // Fallback auf 0 falls null
|
|
||||||
|
|
||||||
// Wenn revenueDispoLive null/undefined ist, behandeln wir es als 0 oder als "kein Vergleich möglich"?
|
|
||||||
// Hier Annahme: Wenn null, dann ignorieren wir den Vergleich (oder Error).
|
|
||||||
// Ich setze es mal so: Wenn external existiert UND abweicht -> Error.
|
|
||||||
if (
|
|
||||||
this.tour().revenueDispoLive === null ||
|
|
||||||
this.tour().revenueDispoLive === undefined
|
|
||||||
)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return Math.abs(calculated - external) > 1; // > 1 Cent Toleranz
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Differenz Betrag */
|
|
||||||
getDeviation = computed(() => {
|
|
||||||
return this.totalCalculated() - (this.tour().revenueDispoLive ?? 0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
-377
@@ -1,377 +0,0 @@
|
|||||||
import { DatePipe } from '@angular/common';
|
|
||||||
import { Component, computed, input } from '@angular/core';
|
|
||||||
import { OptimizationTourDto, TourDto } from '@avicenna/shared-dtos';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideAlertTriangle,
|
|
||||||
lucideArrowRight,
|
|
||||||
lucideExternalLink,
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { HlmButton } from '@spartan-ng/helm/button';
|
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
|
||||||
import { useOptimizationCar } from 'src/app/core/data-access/accounting/accounting.queries';
|
|
||||||
import { simulateOptimization } from './optimization.utils';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-accounting-tour-optimization',
|
|
||||||
imports: [DatePipe, NgIcon, HlmIcon, HlmButton],
|
|
||||||
providers: [
|
|
||||||
provideIcons({ lucideArrowRight, lucideAlertTriangle, lucideExternalLink }),
|
|
||||||
],
|
|
||||||
template: `@if (carQuery.isPending()) {
|
|
||||||
<div class="p-4 text-sm text-muted-foreground animate-pulse">
|
|
||||||
Berechne Simulation...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (vm(); as state) {
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-4 p-4 rounded-lg m-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-xs font-bold text-muted-foreground uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Abrechnungsoptimierung
|
|
||||||
</span>
|
|
||||||
<div class="flex items-baseline gap-2">
|
|
||||||
<div class="flex justify-center items-center gap-1">
|
|
||||||
<span class="text-sm font-mono font-medium">
|
|
||||||
#{{ state.original.operationId }}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
[href]="
|
|
||||||
'https://avicenna.dispolive.de/fahrbericht/' +
|
|
||||||
state.original.id
|
|
||||||
"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="group inline-flex items-center justify-center rounded-sm text-gray-800/60 transition-colors hover:text-gray-900 hover:bg-gray-100/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
title="Fahrbericht im externen System öffnen"
|
|
||||||
aria-label="Fahrbericht öffnen"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideExternalLink" class="size-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border"
|
|
||||||
[class.bg-green-500_10]="
|
|
||||||
state.original.optimizationInMinutes > 0
|
|
||||||
"
|
|
||||||
[class.text-green-600]="
|
|
||||||
state.original.optimizationInMinutes > 0
|
|
||||||
"
|
|
||||||
[class.border-green-200]="
|
|
||||||
state.original.optimizationInMinutes > 0
|
|
||||||
"
|
|
||||||
[class.bg-red-500_10]="state.original.optimizationInMinutes < 0"
|
|
||||||
[class.text-red-600]="state.original.optimizationInMinutes < 0"
|
|
||||||
[class.border-red-200]="
|
|
||||||
state.original.optimizationInMinutes < 0
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ state.original.optimizationInMinutes > 0 ? '+' : ''
|
|
||||||
}}{{ state.original.optimizationInMinutes }} Min
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-5 gap-2 text-center text-xs">
|
|
||||||
<div
|
|
||||||
class="contents px-2 text-[10px] text-muted-foreground uppercase mb-1"
|
|
||||||
>
|
|
||||||
<div>Anfahrt</div>
|
|
||||||
<div>Ankunft Abholort</div>
|
|
||||||
<div>Abfahrt</div>
|
|
||||||
<div>Ankunft Zielort</div>
|
|
||||||
<div>Frei</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contents text-muted-foreground/70">
|
|
||||||
<div>{{ state.original.departedForPickup | date: 'HH:mm' }}</div>
|
|
||||||
<div>{{ state.original.arrivedAtPickup | date: 'HH:mm' }}</div>
|
|
||||||
<div>{{ state.original.departedPickup | date: 'HH:mm' }}</div>
|
|
||||||
<div>{{ state.original.arrivedAtTarget | date: 'HH:mm' }}</div>
|
|
||||||
<div>{{ state.original.completed | date: 'HH:mm' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contents font-bold text-foreground">
|
|
||||||
<div
|
|
||||||
[class.text-amber-600]="
|
|
||||||
isChanged(
|
|
||||||
state.original.departedForPickup,
|
|
||||||
state.simulated.departedForPickup
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ state.simulated.departedForPickup | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
[class.text-amber-600]="
|
|
||||||
isChanged(
|
|
||||||
state.original.arrivedAtPickup,
|
|
||||||
state.simulated.arrivedAtPickup
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ state.simulated.arrivedAtPickup | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
[class.text-amber-600]="
|
|
||||||
isChanged(
|
|
||||||
state.original.departedPickup,
|
|
||||||
state.simulated.departedPickup
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ state.simulated.departedPickup | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
[class.text-amber-600]="
|
|
||||||
isChanged(
|
|
||||||
state.original.arrivedAtTarget,
|
|
||||||
state.simulated.arrivedAtTarget
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ state.simulated.arrivedAtTarget | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
[class.text-amber-600]="
|
|
||||||
isChanged(state.original.completed, state.simulated.completed)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ state.simulated.completed | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (state.conflicts.length > 0) {
|
|
||||||
<div
|
|
||||||
class="mt-4 flex flex-col gap-3 rounded-lg bg-red-50/50 border border-red-200 p-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 text-red-700 mb-1">
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideAlertTriangle"
|
|
||||||
size="sm"
|
|
||||||
class="stroke-2"
|
|
||||||
/>
|
|
||||||
<span class="text-xs font-bold uppercase tracking-wider">
|
|
||||||
{{ state.conflicts.length }} Zeitkonflikt(e) erkannt
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@for (conflict of state.conflicts; track conflict.id) {
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 rounded border border-red-100 bg-white/60 p-2 shadow-sm"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-baseline border-b border-red-100 pb-1 mb-1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-center gap-1">
|
|
||||||
<span class="font-mono text-xs font-bold text-red-900">
|
|
||||||
#{{ conflict.operationId }}
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
[href]="
|
|
||||||
'https://avicenna.dispolive.de/fahrbericht/' +
|
|
||||||
conflict.id
|
|
||||||
"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="group inline-flex items-center justify-center rounded-sm text-red-800/60 transition-colors hover:text-red-900 hover:bg-red-100/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
title="Fahrbericht im externen System öffnen"
|
|
||||||
aria-label="Fahrbericht öffnen"
|
|
||||||
>
|
|
||||||
<ng-icon name="lucideExternalLink" class="size-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-red-900">{{
|
|
||||||
conflict.startDate | date: 'HH:mm - dd.MM.yy'
|
|
||||||
}}</span>
|
|
||||||
<span class="text-[10px] uppercase text-red-400 font-medium">
|
|
||||||
Kollidierende Tour
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-5 gap-2 text-center text-xs">
|
|
||||||
<div
|
|
||||||
class="contents px-2 text-[10px] text-muted-foreground uppercase mb-1"
|
|
||||||
>
|
|
||||||
<div>Anfahrt</div>
|
|
||||||
<div>Ankunft Abholort</div>
|
|
||||||
<div>Abfahrt</div>
|
|
||||||
<div>Ankunft Zielort</div>
|
|
||||||
<div>Frei</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
[class.text-red-600]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.departedForPickup,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[class.font-extrabold]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.departedForPickup,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[class.bg-red-100]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.departedForPickup,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="rounded py-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
{{ conflict.departedForPickup | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
[class.text-red-600]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.arrivedAtPickup,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[class.font-extrabold]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.arrivedAtPickup,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[class.bg-red-100]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.arrivedAtPickup,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="rounded py-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
{{ conflict.arrivedAtPickup | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
[class.text-red-600]="
|
|
||||||
isTimeInConflict(conflict.departedPickup, state.simulated)
|
|
||||||
"
|
|
||||||
[class.font-extrabold]="
|
|
||||||
isTimeInConflict(conflict.departedPickup, state.simulated)
|
|
||||||
"
|
|
||||||
[class.bg-red-100]="
|
|
||||||
isTimeInConflict(conflict.departedPickup, state.simulated)
|
|
||||||
"
|
|
||||||
class="rounded py-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
{{ conflict.departedPickup | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
[class.text-red-600]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.arrivedAtTarget,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[class.font-extrabold]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.arrivedAtTarget,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[class.bg-red-100]="
|
|
||||||
isTimeInConflict(
|
|
||||||
conflict.arrivedAtTarget,
|
|
||||||
state.simulated
|
|
||||||
)
|
|
||||||
"
|
|
||||||
class="rounded py-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
{{ conflict.arrivedAtTarget | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
[class.text-red-600]="
|
|
||||||
isTimeInConflict(conflict.completed, state.simulated)
|
|
||||||
"
|
|
||||||
[class.font-extrabold]="
|
|
||||||
isTimeInConflict(conflict.completed, state.simulated)
|
|
||||||
"
|
|
||||||
[class.bg-red-100]="
|
|
||||||
isTimeInConflict(conflict.completed, state.simulated)
|
|
||||||
"
|
|
||||||
class="rounded py-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
{{ conflict.completed | date: 'HH:mm' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}`,
|
|
||||||
})
|
|
||||||
export class AccountingTourOptimizationComponent {
|
|
||||||
tour = input.required<TourDto>();
|
|
||||||
// 1. Inputs ableiten
|
|
||||||
// Hinweis: Checke, ob dein TourDto 'carName' hat. Falls nicht, müssen wir es anders übergeben.
|
|
||||||
// Ich nehme hier an, es ist da, wie vorhin besprochen.
|
|
||||||
carName = computed(() => this.tour().carName ?? 'UNKNOWN_CAR');
|
|
||||||
tourDate = computed(() => this.tour().startDate);
|
|
||||||
|
|
||||||
private queryCarName = computed(() => this.carName().split('1/').at(1));
|
|
||||||
|
|
||||||
// 2. Data Fetching
|
|
||||||
carQuery = useOptimizationCar(this.queryCarName, this.tourDate);
|
|
||||||
|
|
||||||
// 3. View Model Berechnung
|
|
||||||
vm = computed(() => {
|
|
||||||
const allTours = this.carQuery.data()?.tours;
|
|
||||||
const currentTourId = this.tour().id;
|
|
||||||
|
|
||||||
console.log(this.tour());
|
|
||||||
|
|
||||||
if (!allTours) return null;
|
|
||||||
|
|
||||||
// Finde die volle Tour im geladenen Set (da das Input-DTO evtl. unvollständig ist)
|
|
||||||
// oder nutze das Input-DTO und caste es auf OptimizationTourDto
|
|
||||||
const focusTour = allTours.find((t) => t.id === currentTourId);
|
|
||||||
|
|
||||||
if (!focusTour) return null;
|
|
||||||
|
|
||||||
// Simulation starten
|
|
||||||
return simulateOptimization(focusTour, allTours);
|
|
||||||
});
|
|
||||||
|
|
||||||
isTimeInConflict(
|
|
||||||
timeStr: string | null,
|
|
||||||
simulatedTour: OptimizationTourDto,
|
|
||||||
): boolean {
|
|
||||||
if (!timeStr) return false;
|
|
||||||
|
|
||||||
const time = new Date(timeStr).getTime();
|
|
||||||
|
|
||||||
// Start der Simulation
|
|
||||||
const simStart = new Date(simulatedTour.departedForPickup!).getTime();
|
|
||||||
|
|
||||||
// Ende der Simulation (Fallback falls completed fehlt, wie in der Utils-Logik)
|
|
||||||
const simEnd = simulatedTour.completed
|
|
||||||
? new Date(simulatedTour.completed).getTime()
|
|
||||||
: simStart + 30 * 60 * 1000;
|
|
||||||
|
|
||||||
// Prüfung: Ist der Zeitstempel >= Start UND <= Ende der Simulation?
|
|
||||||
// Wir nutzen >= und <= für "harte" Kollisionen
|
|
||||||
return time >= simStart && time <= simEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper für das Template
|
|
||||||
isChanged(orig: string | null, sim: string | null): boolean {
|
|
||||||
if (!orig || !sim) return false;
|
|
||||||
return new Date(orig).getTime() !== new Date(sim).getTime();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+70
-290
@@ -1,307 +1,87 @@
|
|||||||
import { DatePipe, DecimalPipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
import { TourDto } from '@avicenna/shared-dtos';
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import {
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
lucideAmbulance,
|
import { Tour } from '../../../core/data-access/graphql/generated/generated';
|
||||||
lucideCalendar,
|
|
||||||
lucideCheckCircle2,
|
|
||||||
lucideClock,
|
|
||||||
lucideMapPin,
|
|
||||||
lucideUser,
|
|
||||||
lucideUsers,
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { HlmCardImports } from '@spartan-ng/helm/card';
|
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: 'dks-accounting-tour-overview',
|
selector: 'dks-accounting-tour-overview',
|
||||||
template: `
|
template: `
|
||||||
<div class="p-4 min-h-125 space-y-6">
|
<div class="px-4 py-2 flex justify-between">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex flex-col text-gray-600 text-sm">
|
||||||
<div>
|
<span class="font-medium">Startzeit</span>
|
||||||
<h2
|
|
||||||
class="text-xl font-bold tracking-tight text-foreground flex items-center gap-2"
|
|
||||||
>
|
|
||||||
#{{ tour().operationId }}
|
|
||||||
@if (tour().done) {
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideCheckCircle2"
|
|
||||||
class="text-green-600"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</h2>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-3 mt-1 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<ng-icon hlmIcon name="lucideCalendar" size="xs" />
|
|
||||||
{{ tour().startDate | date: 'dd.MM.yyyy' }}
|
|
||||||
</span>
|
|
||||||
<span class="w-1 h-1 bg-border rounded-full"></span>
|
|
||||||
<span>{{ tour().category || 'Standardfahrt' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
|
|
||||||
[class.bg-green-100]="tour().done"
|
|
||||||
[class.text-green-800]="tour().done"
|
|
||||||
[class.border-green-200]="tour().done"
|
|
||||||
[class.bg-gray-100]="!tour().done"
|
|
||||||
[class.text-gray-800]="!tour().done"
|
|
||||||
[class.border-gray-200]="!tour().done"
|
|
||||||
>
|
|
||||||
{{ tour().done ? 'ABGESCHLOSSEN' : 'OFFEN' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
|
||||||
<div class="md:col-span-5 space-y-6">
|
|
||||||
<section
|
|
||||||
hlmCard
|
|
||||||
class="border-border shadow-sm bg-card text-card-foreground"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
hlmCardHeader
|
|
||||||
class="border-b bg-muted/20 flex flex-row justify-between items-center space-y-0"
|
|
||||||
>
|
|
||||||
<h3 hlmCardTitle class="text-sm font-semibold">Einsatzmittel</h3>
|
|
||||||
</div>
|
|
||||||
<div hlmCardContent>
|
|
||||||
<dl class="space-y-4">
|
|
||||||
@if (tour().carName) {
|
|
||||||
<div>
|
|
||||||
<dt
|
|
||||||
class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1"
|
|
||||||
>
|
|
||||||
Fahrzeug
|
|
||||||
</dt>
|
|
||||||
<dd
|
|
||||||
class="text-sm font-medium text-foreground flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="p-1.5 bg-blue-50 text-blue-600 rounded-md flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideAmbulance" size="sm" />
|
|
||||||
</span>
|
|
||||||
{{ tour().carName }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
@if (tour().driverName) {
|
|
||||||
<div>
|
|
||||||
<dt
|
|
||||||
class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1"
|
|
||||||
>
|
|
||||||
Fahrer
|
|
||||||
</dt>
|
|
||||||
<dd
|
|
||||||
class="text-sm text-foreground flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideUser"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
{{ tour().driverName }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (tour().codriverName) {
|
|
||||||
<div>
|
|
||||||
<dt
|
|
||||||
class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1"
|
|
||||||
>
|
|
||||||
Beifahrer
|
|
||||||
</dt>
|
|
||||||
<dd
|
|
||||||
class="text-sm text-foreground flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideUsers"
|
|
||||||
class="text-muted-foreground"
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
{{ tour().codriverName }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (tour().patientName) {
|
|
||||||
<section
|
|
||||||
hlmCard
|
|
||||||
class="border-border shadow-sm bg-card text-card-foreground"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
hlmCardHeader
|
|
||||||
class="border-b bg-muted/20 flex flex-row justify-between items-center space-y-0"
|
|
||||||
>
|
|
||||||
<h3 hlmCardTitle class="text-sm font-semibold">Patient</h3>
|
|
||||||
</div>
|
|
||||||
<div hlmCardContent>
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
class="h-10 w-10 rounded-full bg-indigo-50 text-indigo-600 flex items-center justify-center font-bold text-sm border border-indigo-100 shrink-0"
|
|
||||||
>
|
|
||||||
{{ getInitials(tour().patientName, tour().patientSurname) }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-foreground">
|
|
||||||
{{ tour().patientName }} {{ tour().patientSurname }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-muted-foreground mt-0.5">
|
|
||||||
{{ tour().patientStreet }}<br />
|
|
||||||
{{ tour().patientZip }} {{ tour().patientCity }}
|
|
||||||
</p>
|
|
||||||
@if (tour().healthInsurance) {
|
|
||||||
<div
|
|
||||||
class="mt-3 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-muted text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ tour().healthInsurance }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-7">
|
|
||||||
<section
|
|
||||||
hlmCard
|
|
||||||
class="border-border shadow-sm bg-card text-card-foreground h-full flex flex-col"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
hlmCardHeader
|
|
||||||
class="border-b bg-muted/20 flex flex-row justify-between items-center space-y-0"
|
|
||||||
>
|
|
||||||
<h3 hlmCardTitle class="text-sm font-semibold">
|
|
||||||
Verlauf & Route
|
|
||||||
</h3>
|
|
||||||
@if (tour().totalKm) {
|
|
||||||
<span class="text-xs text-muted-foreground font-mono">
|
|
||||||
{{ tour().totalKm | number: '.1-3' : 'de' }} km
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div hlmCardContent class="flex-1 flex flex-col px-6 relative">
|
|
||||||
<div class="relative pl-8 pb-10 group z-10">
|
|
||||||
<div
|
|
||||||
class="absolute left-[11px] top-4 -bottom-4 w-0.5 bg-border -z-10"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute left-0 top-1 h-6 w-6 rounded-full border-2 border-background bg-background shadow-sm flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div class="h-2.5 w-2.5 rounded-full bg-green-500"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-xs text-muted-foreground mb-0.5 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideClock" size="xs" />
|
|
||||||
Start • {{ tour().startDate | date: 'HH:mm' }} Uhr
|
|
||||||
</span>
|
|
||||||
<h4 class="text-base font-semibold text-foreground">
|
|
||||||
{{ tour().startInstitution || 'Startort' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-muted-foreground mt-0.5">
|
|
||||||
{{ tour().startStreet }}, {{ tour().startZip }}
|
|
||||||
{{ tour().startCity }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative pl-8 group z-10">
|
|
||||||
<div
|
|
||||||
class="absolute left-0 top-1 h-6 w-6 rounded-full border-2 border-background bg-background shadow-sm flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div class="h-2.5 w-2.5 rounded-full bg-red-500"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-xs text-muted-foreground mb-0.5 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideMapPin" size="xs" />
|
|
||||||
Ziel •
|
|
||||||
{{ getEndTime(tour()) }} Uhr
|
|
||||||
</span>
|
|
||||||
<h4 class="text-base font-semibold text-foreground">
|
|
||||||
{{ tour().targetInstitution || 'Zielort' }}
|
|
||||||
</h4>
|
|
||||||
<p class="text-sm text-muted-foreground mt-0.5">
|
|
||||||
{{ tour().targetStreet }}, {{ tour().targetZip }}
|
|
||||||
{{ tour().targetCity }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (tour().createdAt) {
|
|
||||||
<div
|
|
||||||
hlmCardFooter
|
|
||||||
class="mt-auto border-t text-xs text-muted-foreground flex justify-end gap-4 relative z-10"
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
Erstellt: {{ tour().createdAt | date: 'dd.MM.yyyy HH:mm' }}
|
{{ tour.startDate | date : 'dd.MM.yyyy' }}
|
||||||
|
{{ tour.startDate | date : 'HH:mm' }} Uhr
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col text-gray-600 text-sm">
|
||||||
|
@if (tour.createdAt) {
|
||||||
|
<span class="font-medium">Erstellt am</span>
|
||||||
|
}
|
||||||
|
@if (tour.createdAt) {
|
||||||
|
<span>
|
||||||
|
{{ tour.createdAt | date : 'dd.MM.yyyy' }}
|
||||||
|
{{ tour.createdAt | date : 'HH:mm' }} Uhr
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
@if (tour.driverName || tour.codriverName || tour.carName) {
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap md:flex-nowrap justify-between px-4 py-2"
|
||||||
|
>
|
||||||
|
@if (tour.carName) {
|
||||||
|
<div
|
||||||
|
class="flex flex-col pb-1 md:pb-0"
|
||||||
|
>
|
||||||
|
<span class="font-semibold pb-1">Fahrzeug</span>
|
||||||
|
{{ tour.carName }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (tour.driverName) {
|
||||||
|
<div
|
||||||
|
class="flex flex-col pb-1 md:pb-0"
|
||||||
|
>
|
||||||
|
<span class="font-semibold pb-1">Fahrer</span>
|
||||||
|
<span>{{ tour.driverName }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (tour.codriverName) {
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold pb-1">Beifahrer</span>
|
||||||
|
<span>{{ tour.codriverName }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="flex flex-col px-4 py-2">
|
||||||
|
<span class="font-semibold pb-1">Patient</span>
|
||||||
|
<span>{{ tour.patientName }} {{ tour.patientSurname }}</span>
|
||||||
|
<span>{{ tour.patientStreet }}</span>
|
||||||
|
<span>{{ tour.patientZip }} {{ tour.patientCity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap md:flex-nowrap px-4 py-2">
|
||||||
|
<div class="w-full md:w-1/2 flex flex-col pb-1 md:pb-0">
|
||||||
|
<span class="font-semibold pb-1">Start</span>
|
||||||
|
<span>{{ tour.startInstitution }}</span>
|
||||||
|
<span>{{ tour.startStreet }}</span>
|
||||||
|
<span>{{ tour.startZip }} {{ tour.startCity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 flex flex-col">
|
||||||
|
<span class="font-semibold pb-1">Ziel</span>
|
||||||
|
<span>{{ tour.targetInstitution }}</span>
|
||||||
|
<span>{{ tour.targetStreet }}</span>
|
||||||
|
<span>{{ tour.targetZip }} {{ tour.targetCity }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [],
|
styles: [],
|
||||||
imports: [DatePipe, DecimalPipe, HlmCardImports, NgIcon, HlmIcon],
|
imports: [MatTabsModule, MatIconModule, MatDividerModule, DatePipe]
|
||||||
providers: [
|
|
||||||
provideIcons({
|
|
||||||
lucideAmbulance,
|
|
||||||
lucideUser,
|
|
||||||
lucideUsers,
|
|
||||||
lucideClock,
|
|
||||||
lucideCalendar,
|
|
||||||
lucideCheckCircle2,
|
|
||||||
lucideMapPin,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class AccountingTourOverviewComponent {
|
export class AccountingTourOverviewComponent {
|
||||||
tour = input.required<TourDto>();
|
@Input() tour!: Tour;
|
||||||
|
|
||||||
getInitials(name: string | null, surname: string | null): string {
|
|
||||||
const n = name?.charAt(0) || '';
|
|
||||||
const s = surname?.charAt(0) || '';
|
|
||||||
return (n + s).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
getShortId(id: string): string {
|
|
||||||
return id ? id.substring(0, 8) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
getStartTime(tour: TourDto): string {
|
|
||||||
return tour.startEnd
|
|
||||||
? tour.startEnd
|
|
||||||
: format(new Date(tour.startDate), 'HH:MM');
|
|
||||||
}
|
|
||||||
|
|
||||||
getEndTime(tour: TourDto): string {
|
|
||||||
return tour.targetBegin ? tour.targetBegin : '--:--';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-15
@@ -1,9 +1,9 @@
|
|||||||
|
import { DatePipe } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
import { MatDividerModule } from '@angular/material/divider';
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatListModule } from '@angular/material/list';
|
import { MatListModule } from '@angular/material/list';
|
||||||
import { TourDto } from '@avicenna/shared-dtos';
|
import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/graphql/generated/generated';
|
||||||
import { TicketValidationState } from '../../../core/data-access/graphql/generated/generated';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -16,19 +16,34 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
|
|||||||
Status:
|
Status:
|
||||||
@switch (tour.ticket.currentState) {
|
@switch (tour.ticket.currentState) {
|
||||||
@case (TicketValidationState.Created) {
|
@case (TicketValidationState.Created) {
|
||||||
<span class="font-bold">Touren angenommen </span>
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
>Touren angenommen
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
@case (TicketValidationState.UserInteractionRequired) {
|
@case (TicketValidationState.UserInteractionRequired) {
|
||||||
<span class="font-bold">Genehmigung / Transportschein </span>
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
>Genehmigung / Transportschein
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
@case (TicketValidationState.TdUncertain) {
|
@case (TicketValidationState.TdUncertain) {
|
||||||
<span class="font-bold">Transportschein unklar </span>
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
>Transportschein unklar
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
@case (TicketValidationState.DocumentsMissing) {
|
@case (TicketValidationState.DocumentsMissing) {
|
||||||
<span class="font-bold">Dokumente fehlen </span>
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
>Dokumente fehlen
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
@case (TicketValidationState.Archived) {
|
@case (TicketValidationState.Archived) {
|
||||||
<span class="font-bold">Archiviert </span>
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
>Archiviert
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</mat-list-item>
|
</mat-list-item>
|
||||||
@@ -46,11 +61,17 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
|
|||||||
</mat-list-item>
|
</mat-list-item>
|
||||||
</mat-list>
|
</mat-list>
|
||||||
@if (tour.ticket.errors.length > 0 || tour.ticket.notes.length > 0) {
|
@if (tour.ticket.errors.length > 0 || tour.ticket.notes.length > 0) {
|
||||||
<mat-list class="col-span-2 max-h-96 overflow-y-auto" role="list">
|
<mat-list
|
||||||
|
class="col-span-2 max-h-96 overflow-y-auto"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
<div mat-subheader class="text-sm">Ticket Anmerkungen</div>
|
<div mat-subheader class="text-sm">Ticket Anmerkungen</div>
|
||||||
@for (error of tour.ticket.errors; track error) {
|
@for (error of tour.ticket.errors; track error) {
|
||||||
<mat-list-item lines="2" role="listitem">
|
<mat-list-item
|
||||||
<mat-icon matListItemIcon class="text-red-600!">error</mat-icon>
|
lines="2"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon class="!text-red-600">error</mat-icon>
|
||||||
<span matListItemTitle>{{ error.split(':').at(0) }}</span>
|
<span matListItemTitle>{{ error.split(':').at(0) }}</span>
|
||||||
<span [title]="error.split(':').at(1)">{{
|
<span [title]="error.split(':').at(1)">{{
|
||||||
error.split(':').at(1)
|
error.split(':').at(1)
|
||||||
@@ -58,10 +79,11 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
|
|||||||
</mat-list-item>
|
</mat-list-item>
|
||||||
}
|
}
|
||||||
@for (note of tour.ticket.notes; track note) {
|
@for (note of tour.ticket.notes; track note) {
|
||||||
<mat-list-item lines="2" role="listitem">
|
<mat-list-item
|
||||||
<mat-icon matListItemIcon class="text-yellow-400!"
|
lines="2"
|
||||||
>warning</mat-icon
|
role="listitem"
|
||||||
>
|
>
|
||||||
|
<mat-icon matListItemIcon class="!text-yellow-400">warning</mat-icon>
|
||||||
<span matListItemTitle>{{ note.split(':').at(0) }}</span>
|
<span matListItemTitle>{{ note.split(':').at(0) }}</span>
|
||||||
<span [title]="note.split(':').at(1)">{{
|
<span [title]="note.split(':').at(1)">{{
|
||||||
note.split(':').at(1)
|
note.split(':').at(1)
|
||||||
@@ -73,10 +95,14 @@ import { TicketValidationState } from '../../../core/data-access/graphql/generat
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [],
|
styles: [],
|
||||||
imports: [MatIconModule, MatListModule, MatDividerModule],
|
imports: [
|
||||||
|
MatIconModule,
|
||||||
|
MatListModule,
|
||||||
|
MatDividerModule
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class AccountingTourTicketComponent {
|
export class AccountingTourTicketComponent {
|
||||||
@Input() tour!: TourDto;
|
@Input() tour!: Tour & { ticket: Ticket };
|
||||||
|
|
||||||
TicketValidationState = TicketValidationState;
|
TicketValidationState = TicketValidationState;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-38
@@ -1,65 +1,51 @@
|
|||||||
import {
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { TourDto } from '@avicenna/shared-dtos';
|
|
||||||
import { DialogRef } from '@ngneat/dialog';
|
import { DialogRef } from '@ngneat/dialog';
|
||||||
import { AccountingTourCostBreakdownComponent } from './accounting-tour-cost-breakdown.component';
|
import {
|
||||||
|
Ticket,
|
||||||
|
Tour,
|
||||||
|
} from '../../../core/data-access/graphql/generated/generated';
|
||||||
import { AccountingTourOverviewComponent } from './accounting-tour-overview.component';
|
import { AccountingTourOverviewComponent } from './accounting-tour-overview.component';
|
||||||
import { AccountingTourTicketComponent } from './accounting-tour-ticket.component';
|
import { AccountingTourTicketComponent } from './accounting-tour-ticket.component';
|
||||||
import { useTourDetail } from 'src/app/core/data-access/accounting/accounting.queries';
|
import { AccountingTourCostBreakdownComponent } from './accounting-tour-cost-breakdown.component';
|
||||||
import { AccountingTourOptimizationComponent } from "./accounting-tour-optimization.component";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: 'dks-accounting-tour-view',
|
selector: 'dks-accounting-tour-view',
|
||||||
template: ` @if (tourQuery.data(); as tour) {
|
template: `<h3 class="py-2 px-3 !mb-0">
|
||||||
<h3 class="py-2 px-3 mb-0!">
|
{{ ref.data.ordinanceType }} -
|
||||||
{{ tour.ordinanceType }} -
|
{{ ref.data.patientName }}
|
||||||
{{ tour.patientName }}
|
{{ ref.data.patientSurname }}
|
||||||
{{ tour.patientSurname }}
|
|
||||||
</h3>
|
</h3>
|
||||||
<hr />
|
<hr />
|
||||||
<mat-tab-group class="overflow-auto">
|
<mat-tab-group>
|
||||||
<mat-tab label="Übersicht">
|
|
||||||
<dks-accounting-tour-overview
|
|
||||||
[tour]="tour"
|
|
||||||
></dks-accounting-tour-overview>
|
|
||||||
</mat-tab>
|
|
||||||
<mat-tab label="Umsatzberechnung">
|
<mat-tab label="Umsatzberechnung">
|
||||||
<app-accounting-tour-cost-breakdown
|
<app-accounting-tour-cost-breakdown
|
||||||
[tour]="tour"
|
[tour]="ref.data"
|
||||||
></app-accounting-tour-cost-breakdown>
|
></app-accounting-tour-cost-breakdown>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
@if(tourQuery.data()?.optimizationInMinutes != 0) {
|
<mat-tab label="Übersicht">
|
||||||
<mat-tab label="Abrechnungsoptimierung">
|
<dks-accounting-tour-overview
|
||||||
<app-accounting-tour-optimization
|
[tour]="ref.data"
|
||||||
[tour]="tour"
|
></dks-accounting-tour-overview>
|
||||||
></app-accounting-tour-optimization>
|
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
}
|
<mat-tab label="Ticket">
|
||||||
<!-- <mat-tab label="Ticket">
|
|
||||||
<dks-accounting-tour-ticket
|
<dks-accounting-tour-ticket
|
||||||
[tour]="ref.data"
|
[tour]="ref.data"
|
||||||
></dks-accounting-tour-ticket>
|
></dks-accounting-tour-ticket>
|
||||||
</mat-tab> -->
|
</mat-tab>
|
||||||
</mat-tab-group>
|
</mat-tab-group> `,
|
||||||
}`,
|
|
||||||
styles: [],
|
styles: [],
|
||||||
imports: [
|
imports: [
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
AccountingTourOverviewComponent,
|
AccountingTourOverviewComponent,
|
||||||
AccountingTourCostBreakdownComponent,
|
AccountingTourTicketComponent,
|
||||||
AccountingTourOptimizationComponent
|
AccountingTourCostBreakdownComponent
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AccountingTourViewComponent {
|
export class AccountingTourViewComponent {
|
||||||
ref = inject<DialogRef<string, undefined | any>>(DialogRef);
|
ref =
|
||||||
tourId = signal(this.ref.data);
|
inject<DialogRef<Tour & { ticket: Ticket }, undefined | any>>(DialogRef);
|
||||||
tourQuery = useTourDetail(this.tourId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
import { addMinutes, subMinutes, isBefore, isAfter, parseISO } from 'date-fns';
|
|
||||||
import { OptimizationTourDto } from '@avicenna/shared-dtos';
|
|
||||||
|
|
||||||
export interface SimulatedTourResult {
|
|
||||||
original: OptimizationTourDto;
|
|
||||||
simulated: OptimizationTourDto;
|
|
||||||
conflicts: OptimizationTourDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const MIN_DISTANCE = 2 * 60 * 1000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simuliert die Verschiebung einer Tour und prüft auf Kollisionen.
|
|
||||||
*/
|
|
||||||
export function simulateOptimization(
|
|
||||||
focusTour: OptimizationTourDto,
|
|
||||||
allTours: OptimizationTourDto[],
|
|
||||||
): SimulatedTourResult {
|
|
||||||
const sim = structuredClone(focusTour);
|
|
||||||
|
|
||||||
const getTime = (isoStr: string | null) =>
|
|
||||||
isoStr ? new Date(isoStr).getTime() : null;
|
|
||||||
|
|
||||||
let currentStart = getTime(sim.arrivedAtPickup) ?? getTime(sim.startDate)!;
|
|
||||||
|
|
||||||
// Die Verschiebung anwenden
|
|
||||||
currentStart += sim.optimizationInMinutes * 60 * 1000;
|
|
||||||
|
|
||||||
// Datum zurückschreiben
|
|
||||||
sim.arrivedAtPickup = new Date(currentStart).toISOString();
|
|
||||||
|
|
||||||
if (sim.optimizationInMinutes < 0) {
|
|
||||||
const prevTime = getTime(sim.departedForPickup);
|
|
||||||
const currTime = getTime(sim.arrivedAtPickup);
|
|
||||||
if (prevTime && currTime && prevTime >= currTime) {
|
|
||||||
sim.departedForPickup = new Date(currTime - MIN_DISTANCE).toISOString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sim.optimizationInMinutes > 0) {
|
|
||||||
const timestamps = [
|
|
||||||
// 'departedForPickup',
|
|
||||||
'arrivedAtPickup',
|
|
||||||
'departedPickup',
|
|
||||||
'arrivedAtTarget',
|
|
||||||
'completed',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// const adjustTime = (nextTime: number, currTime: number) =>
|
|
||||||
// nextTime <= currTime ? currTime + MIN_DISTANCE : nextTime;
|
|
||||||
// const optimizedTime = getTime(sim.arrivedAtPickup);
|
|
||||||
|
|
||||||
for (let i = 1; i < timestamps.length; i++) {
|
|
||||||
const prevKey = timestamps[i - 1];
|
|
||||||
const currKey = timestamps[i];
|
|
||||||
|
|
||||||
const prevTime = getTime(sim[prevKey]);
|
|
||||||
const currTime = getTime(sim[currKey]);
|
|
||||||
if (prevTime && currTime && currTime - prevTime < MIN_DISTANCE) {
|
|
||||||
sim[currKey] = new Date(prevTime + MIN_DISTANCE).toISOString();
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Interne Konsistenz herstellen (Domino-Effekt)
|
|
||||||
// Wir definieren die Kette der Zeitstempel
|
|
||||||
|
|
||||||
// 3a. Forward Propagation (Nachfolgende schieben)
|
|
||||||
// Wir starten beim zweiten Element und vergleichen mit dem Vorgänger
|
|
||||||
// let wasPrevAdjusted = false;
|
|
||||||
// for (let i = 1; i < timestamps.length; i++) {
|
|
||||||
// const prevKey = timestamps[i - 1];
|
|
||||||
// const currKey = timestamps[i];
|
|
||||||
|
|
||||||
// const prevTime = getTime(sim[prevKey]);
|
|
||||||
// let currTime = getTime(sim[currKey]);
|
|
||||||
|
|
||||||
// if (prevTime && currTime) {
|
|
||||||
// // Regel: curr muss mindestens prev + 2 Min sein
|
|
||||||
// const minAllowed = prevTime + 2 * 60 * 1000;
|
|
||||||
|
|
||||||
// if (currTime < minAllowed) {
|
|
||||||
// // Konflikt intern: Schieben!
|
|
||||||
// currTime = minAllowed;
|
|
||||||
// sim[currKey] = new Date(currTime).toISOString();
|
|
||||||
// wasPrevAdjusted = true;
|
|
||||||
// } else {
|
|
||||||
// wasPrevAdjusted = false;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 3b. Backward Propagation (Theoretisch für Zeiten VOR dem Start, falls vorhanden)
|
|
||||||
// Da 'departedForPickup' unser Start ist, gibt es davor meist nichts relevantes im DTO für die Kollision,
|
|
||||||
// aber falls du z.B. 'startDate' als Plan-Zeit auch anpassen willst:
|
|
||||||
// (Hier lassen wir es erstmal weg, da der Algorithmus meist vorwärts fließt).
|
|
||||||
|
|
||||||
// 4. Kollisionsprüfung (Extern)
|
|
||||||
// Zeitraum der simulierten Tour bestimmen
|
|
||||||
const simStart = getTime(sim.arrivedAtPickup)!;
|
|
||||||
const simEnd = getTime(sim.completed) ?? simStart + 30 * 60 * 1000; // Fallback falls completed fehlt
|
|
||||||
|
|
||||||
const conflicts = allTours.filter((other) => {
|
|
||||||
// Sich selbst nicht prüfen
|
|
||||||
if (other.id === focusTour.id) return false;
|
|
||||||
|
|
||||||
const otherStart =
|
|
||||||
getTime(other.arrivedAtPickup) ?? getTime(other.startDate)!;
|
|
||||||
const otherEnd = getTime(other.completed) ?? otherStart + 1 * 60 * 1000; // Fallback
|
|
||||||
|
|
||||||
// Overlap Logik: (StartA < EndB) && (EndA > StartB)
|
|
||||||
return simStart < otherEnd && simEnd > otherStart;
|
|
||||||
}).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
|
||||||
|
|
||||||
return {
|
|
||||||
original: focusTour,
|
|
||||||
simulated: sim,
|
|
||||||
conflicts: conflicts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+4
-3
@@ -22,10 +22,11 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
|
|||||||
class="self-center"
|
class="self-center"
|
||||||
[accountingState]="AccountingState.PreparedForBilling"
|
[accountingState]="AccountingState.PreparedForBilling"
|
||||||
></app-accounting-info-card>
|
></app-accounting-info-card>
|
||||||
|
<div>
|
||||||
<dks-accounting-lane
|
<dks-accounting-lane
|
||||||
[accountingState]="AccountingState.Pending"
|
[accountingState]="AccountingState.Pending"
|
||||||
></dks-accounting-lane>
|
></dks-accounting-lane>
|
||||||
|
</div>
|
||||||
<dks-accounting-lane
|
<dks-accounting-lane
|
||||||
[accountingState]="AccountingState.Billable"
|
[accountingState]="AccountingState.Billable"
|
||||||
></dks-accounting-lane>
|
></dks-accounting-lane>
|
||||||
@@ -40,8 +41,8 @@ import { AccountingLaneComponent } from './accounting-lane/accounting-lane.compo
|
|||||||
height: calc(100% - 64px);
|
height: calc(100% - 64px);
|
||||||
}
|
}
|
||||||
section {
|
section {
|
||||||
/* grid-template-rows: 2rem auto 1fr; */
|
grid-template-rows: 2rem auto 1fr;
|
||||||
grid-template-rows: 2rem 1fr;
|
/* grid-template-rows: 2rem 1fr; */
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
|
|||||||
+15
-20
@@ -34,7 +34,7 @@ export class DashboardAccountingService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
updateTours$ = new Subject<void>();
|
updateTours$ = new Subject<void>();
|
||||||
@@ -42,7 +42,7 @@ export class DashboardAccountingService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly getAccountingToursGql: GetAccountingToursGQL,
|
private readonly getAccountingToursGql: GetAccountingToursGQL,
|
||||||
private readonly getAccountingStateMetaGql: GetAccountingStateMetaGQL,
|
private readonly getAccountingStateMetaGql: GetAccountingStateMetaGQL,
|
||||||
private readonly toast: HotToastService,
|
private readonly toast: HotToastService
|
||||||
) {
|
) {
|
||||||
this.updateTours$.next();
|
this.updateTours$.next();
|
||||||
}
|
}
|
||||||
@@ -51,10 +51,13 @@ export class DashboardAccountingService {
|
|||||||
state: AccountingState,
|
state: AccountingState,
|
||||||
cursor: string,
|
cursor: string,
|
||||||
filters: AccountingFilterArgs,
|
filters: AccountingFilterArgs,
|
||||||
take = 20,
|
take = 20
|
||||||
): Observable<GetAccountingToursQuery['accountingTours']> {
|
): Observable<GetAccountingToursQuery['accountingTours']> {
|
||||||
const ticketsQuery = this.getAccountingToursGql.watch({
|
const ticketsQuery = this.getAccountingToursGql.watch({
|
||||||
variables: { state, cursor, filters, take },
|
state,
|
||||||
|
cursor,
|
||||||
|
filters,
|
||||||
|
take,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ticketsUpdate = combineLatest([
|
const ticketsUpdate = combineLatest([
|
||||||
@@ -62,7 +65,7 @@ export class DashboardAccountingService {
|
|||||||
this.updateTours$,
|
this.updateTours$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(() => ticketsQuery.refetch()),
|
switchMap(() => ticketsQuery.refetch()),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
|
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
|
||||||
@@ -72,21 +75,17 @@ export class DashboardAccountingService {
|
|||||||
}),
|
}),
|
||||||
retry({ delay: 1000, count: 3 }),
|
retry({ delay: 1000, count: 3 }),
|
||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData,
|
updatedData ? updatedData : firstData
|
||||||
),
|
|
||||||
map(
|
|
||||||
(response) =>
|
|
||||||
response.data
|
|
||||||
?.accountingTours as GetAccountingToursQuery['accountingTours'],
|
|
||||||
),
|
),
|
||||||
|
map((response) => response.data.accountingTours)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAccountingStateMeta(
|
fetchAccountingStateMeta(
|
||||||
state: AccountingState,
|
state: AccountingState
|
||||||
): Observable<GetAccountingStateMetaQuery['accountingStateMeta']> {
|
): Observable<GetAccountingStateMetaQuery['accountingStateMeta']> {
|
||||||
const ticketStateMetaQuery = this.getAccountingStateMetaGql.watch({
|
const ticketStateMetaQuery = this.getAccountingStateMetaGql.watch({
|
||||||
variables: { state },
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toursUpdate = combineLatest([
|
const toursUpdate = combineLatest([
|
||||||
@@ -94,7 +93,7 @@ export class DashboardAccountingService {
|
|||||||
this.updateTours$,
|
this.updateTours$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(() => ticketStateMetaQuery.refetch()),
|
switchMap(() => ticketStateMetaQuery.refetch()),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
return combineLatest([toursUpdate, ticketStateMetaQuery.valueChanges]).pipe(
|
return combineLatest([toursUpdate, ticketStateMetaQuery.valueChanges]).pipe(
|
||||||
@@ -104,13 +103,9 @@ export class DashboardAccountingService {
|
|||||||
}),
|
}),
|
||||||
retry({ delay: 1000, count: 3 }),
|
retry({ delay: 1000, count: 3 }),
|
||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData,
|
updatedData ? updatedData : firstData
|
||||||
),
|
|
||||||
map(
|
|
||||||
(response) =>
|
|
||||||
response.data
|
|
||||||
?.accountingStateMeta as GetAccountingStateMetaQuery['accountingStateMeta'],
|
|
||||||
),
|
),
|
||||||
|
map((response) => response.data.accountingStateMeta)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { EventEmitter, Injectable } from '@angular/core';
|
|||||||
import { HotToastService } from '@ngxpert/hot-toast';
|
import { HotToastService } from '@ngxpert/hot-toast';
|
||||||
import { catchError, delay, EMPTY, map, Observable, take, tap } from 'rxjs';
|
import { catchError, delay, EMPTY, map, Observable, take, tap } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AcceptAnomalyGQL,
|
|
||||||
GetAnomaliesGQL,
|
GetAnomaliesGQL,
|
||||||
GetGroupedAnomaliesGQL,
|
GetGroupedAnomaliesGQL,
|
||||||
|
SetAnomalyToSleepGQL,
|
||||||
|
AcceptAnomalyGQL,
|
||||||
GetUpcomingToursGQL,
|
GetUpcomingToursGQL,
|
||||||
PaginatedAnomaly,
|
PaginatedAnomaly,
|
||||||
PaginatedAnomalyGroup,
|
PaginatedAnomalyGroup,
|
||||||
SetAnomalyToSleepGQL,
|
|
||||||
} from '../../core/data-access/graphql/generated/generated';
|
} from '../../core/data-access/graphql/generated/generated';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -23,86 +23,98 @@ export class AnomaliesService {
|
|||||||
private readonly setAnomalyToSleepGql: SetAnomalyToSleepGQL,
|
private readonly setAnomalyToSleepGql: SetAnomalyToSleepGQL,
|
||||||
private readonly acceptAnomalyGql: AcceptAnomalyGQL,
|
private readonly acceptAnomalyGql: AcceptAnomalyGQL,
|
||||||
private readonly upcomingToursGql: GetUpcomingToursGQL,
|
private readonly upcomingToursGql: GetUpcomingToursGQL,
|
||||||
private readonly toast: HotToastService,
|
private readonly toast: HotToastService
|
||||||
) {
|
) {
|
||||||
this.upcomingToursQuery = this.upcomingToursGql.watch({
|
this.upcomingToursQuery = this.upcomingToursGql.watch(
|
||||||
pollInterval: 60 * 1000,
|
{},
|
||||||
});
|
{ pollInterval: 60 * 1000 }
|
||||||
|
);
|
||||||
|
|
||||||
this.upcomingTours$ = this.upcomingToursQuery.valueChanges;
|
this.upcomingTours$ = this.upcomingToursQuery.valueChanges;
|
||||||
}
|
}
|
||||||
|
|
||||||
getAnomaliesByPage(
|
getAnomaliesByPage(
|
||||||
cursor: string,
|
cursor: string,
|
||||||
take: number = 10,
|
take: number = 10
|
||||||
): Observable<PaginatedAnomaly> {
|
): Observable<PaginatedAnomaly> {
|
||||||
return this.getAnomaliesGql
|
return this.getAnomaliesGql
|
||||||
.fetch({ variables: { cursor, take }, fetchPolicy: 'no-cache' })
|
.fetch(
|
||||||
|
{ cursor, take },
|
||||||
|
{
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
}
|
||||||
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
map((queryResult) => queryResult.data?.anomalies as PaginatedAnomaly),
|
map((queryResult) => queryResult.data.anomalies),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getGroupedAnomaliesByPage(
|
getGroupedAnomaliesByPage(
|
||||||
cursor: string,
|
cursor: string,
|
||||||
take: number = 10,
|
take: number = 10
|
||||||
): Observable<PaginatedAnomalyGroup> {
|
): Observable<PaginatedAnomalyGroup> {
|
||||||
return this.getGroupedAnomaliesGql
|
return this.getGroupedAnomaliesGql
|
||||||
.fetch({ variables: { cursor, take }, fetchPolicy: 'no-cache' })
|
.fetch(
|
||||||
|
{ cursor, take },
|
||||||
|
{
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
}
|
||||||
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(
|
map(
|
||||||
(queryResult) =>
|
(queryResult) =>
|
||||||
queryResult.data
|
queryResult.data
|
||||||
?.groupedAnomalies as unknown as PaginatedAnomalyGroup,
|
.groupedAnomalies as unknown as PaginatedAnomalyGroup
|
||||||
),
|
),
|
||||||
catchError((err) => {
|
catchError((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnomalyToSleep(anomalyId: string): void {
|
setAnomalyToSleep(anomalyId: string): void {
|
||||||
this.setAnomalyToSleepGql
|
this.setAnomalyToSleepGql
|
||||||
.mutate({ variables: { id: anomalyId } })
|
.mutate({ id: anomalyId })
|
||||||
.pipe(
|
.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
tap(() => this.anomaliesRefresh$.emit()),
|
tap(() => this.anomaliesRefresh$.emit()),
|
||||||
tap((mutationResult) => {
|
tap((mutationResult) => {
|
||||||
if (mutationResult.error) {
|
if (mutationResult.errors) {
|
||||||
this.toast.error(
|
this.toast.error(
|
||||||
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal',
|
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.toast.success(
|
this.toast.success(
|
||||||
'Die Anomalie wurde für eine Stunde stummgeschaltet!',
|
'Die Anomalie wurde für eine Stunde stummgeschaltet!'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptAnomaly(anomalyId: string): void {
|
acceptAnomaly(anomalyId: string): void {
|
||||||
|
|
||||||
this.acceptAnomalyGql
|
this.acceptAnomalyGql
|
||||||
.mutate({ variables: { id: anomalyId } })
|
.mutate({ id: anomalyId })
|
||||||
.pipe(
|
.pipe(
|
||||||
take(1),
|
take(1),
|
||||||
delay(10),
|
delay(10),
|
||||||
tap(() => this.anomaliesRefresh$.emit()),
|
tap(() => this.anomaliesRefresh$.emit()),
|
||||||
tap((mutationResult) => {
|
tap((mutationResult) => {
|
||||||
if (mutationResult.error) {
|
if (mutationResult.errors) {
|
||||||
this.toast.error(
|
this.toast.error(
|
||||||
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal',
|
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.toast.success('Die Anomalie wurde akzeptiert!');
|
this.toast.success('Die Anomalie wurde akzeptiert!');
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-11
@@ -43,25 +43,31 @@ export class AnomalyViewComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.anomalyByIdGql
|
this.anomalyByIdGql
|
||||||
.fetch({ variables: { id: this.anomalyId()} })
|
.fetch({ id: this.anomalyId() })
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
tap((res) => this.anomaly.set(res.data?.anomaly))
|
tap((res) => this.anomaly.set(res.data.anomaly))
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
this.associatedEntitiesGql
|
this.associatedEntitiesGql
|
||||||
.fetch({ variables:{id: this.anomalyId()} })
|
.fetch({ id: this.anomalyId() })
|
||||||
.pipe(
|
.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map((res) => res.data?.associatedEntities),
|
map((res) => res.data.associatedEntities),
|
||||||
tap(
|
tap(
|
||||||
(props) => {
|
({
|
||||||
if (props?.tour) this.tours.set(props?.tour as Tour[]);
|
tour,
|
||||||
if (props?.attendanceRegistration)
|
attendanceRegistration,
|
||||||
this.attendanceRegistrations.set(props?.attendanceRegistration);
|
plannedTime,
|
||||||
if (props?.plannedTime) this.plannedTimes.set(props?.plannedTime);
|
workTime,
|
||||||
if (props?.workTime) this.workTimes.set(props?.workTime);
|
employee,
|
||||||
if (props?.employee) this.employees.set(props?.employee);
|
}) => {
|
||||||
|
if (tour) this.tours.set(tour as Tour[]);
|
||||||
|
if (attendanceRegistration)
|
||||||
|
this.attendanceRegistrations.set(attendanceRegistration);
|
||||||
|
if (plannedTime) this.plannedTimes.set(plannedTime);
|
||||||
|
if (workTime) this.workTimes.set(workTime);
|
||||||
|
if (employee) this.employees.set(employee);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-10
@@ -16,7 +16,7 @@ import {
|
|||||||
templateUrl: './tours-per-time-kpi.component.html',
|
templateUrl: './tours-per-time-kpi.component.html',
|
||||||
styleUrls: ['./tours-per-time-kpi.component.css'],
|
styleUrls: ['./tours-per-time-kpi.component.css'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: false,
|
standalone: false
|
||||||
})
|
})
|
||||||
export class ToursPerTimeKPIComponent implements OnInit {
|
export class ToursPerTimeKPIComponent implements OnInit {
|
||||||
@Input() ordinanceType!: string;
|
@Input() ordinanceType!: string;
|
||||||
@@ -27,16 +27,13 @@ export class ToursPerTimeKPIComponent implements OnInit {
|
|||||||
currentTimeSlot$!: Observable<number>;
|
currentTimeSlot$!: Observable<number>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL,
|
private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.toursPerTimeKPI$ = this.getToursPerTimeKPIGql
|
this.toursPerTimeKPI$ = this.getToursPerTimeKPIGql
|
||||||
.watch({
|
.watch({ ordinanceType: this.ordinanceType }, { pollInterval: 60000 })
|
||||||
variables: { ordinanceType: this.ordinanceType },
|
.valueChanges.pipe(map((result) => result.data.toursPerTimeKPI));
|
||||||
pollInterval: 60000,
|
|
||||||
})
|
|
||||||
.valueChanges.pipe(map((result) => result.data?.toursPerTimeKPI as GetTourAmountPerTimeKpiQuery['toursPerTimeKPI']));
|
|
||||||
this.currentTimeSlot$ = combineLatest([
|
this.currentTimeSlot$ = combineLatest([
|
||||||
interval(60000).pipe(startWith(0)),
|
interval(60000).pipe(startWith(0)),
|
||||||
this.toursPerTimeKPI$,
|
this.toursPerTimeKPI$,
|
||||||
@@ -46,9 +43,9 @@ export class ToursPerTimeKPIComponent implements OnInit {
|
|||||||
this.isInInterval({
|
this.isInInterval({
|
||||||
start: kpi.interval.start,
|
start: kpi.interval.start,
|
||||||
end: kpi.interval.end,
|
end: kpi.interval.end,
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -21,9 +21,9 @@ export class UpcomingToursComponent {
|
|||||||
private readonly diaolog: DialogService
|
private readonly diaolog: DialogService
|
||||||
) {
|
) {
|
||||||
this.upcomingTours$ = this.anomaliesService.upcomingTours$.pipe(
|
this.upcomingTours$ = this.anomaliesService.upcomingTours$.pipe(
|
||||||
map((result) => result.data?.upcomingTours),
|
map((result) => result.data.upcomingTours),
|
||||||
map((tours) =>
|
map((tours) =>
|
||||||
(tours ?? [])
|
tours
|
||||||
.map((tour) => ({
|
.map((tour) => ({
|
||||||
...tour,
|
...tour,
|
||||||
colorClass:
|
colorClass:
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { LinkCardComponent } from './link-card/link-card.component';
|
|||||||
<app-manager-info-card ordinanceType="KTW"></app-manager-info-card>
|
<app-manager-info-card ordinanceType="KTW"></app-manager-info-card>
|
||||||
<!-- <app-manager-info-card ordinanceType="ALL"></app-manager-info-card> -->
|
<!-- <app-manager-info-card ordinanceType="ALL"></app-manager-info-card> -->
|
||||||
</div>
|
</div>
|
||||||
<!-- <div
|
<div
|
||||||
class="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
|
class="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
|
||||||
>
|
>
|
||||||
<div class="col-span-4 text-lg">Ticket System - Bearbeitungsstatus</div>
|
<div class="col-span-4 text-lg">Ticket System - Bearbeitungsstatus</div>
|
||||||
@@ -167,7 +167,7 @@ import { LinkCardComponent } from './link-card/link-card.component';
|
|||||||
<app-ticket-state-info-card
|
<app-ticket-state-info-card
|
||||||
[ticketState]="TicketState.DocumentsMissing"
|
[ticketState]="TicketState.DocumentsMissing"
|
||||||
></app-ticket-state-info-card>
|
></app-ticket-state-info-card>
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: ``,
|
styles: ``,
|
||||||
@@ -210,7 +210,7 @@ export class HomeComponent implements OnInit {
|
|||||||
effect(() => {
|
effect(() => {
|
||||||
this.homeDashboardGql
|
this.homeDashboardGql
|
||||||
.fetch()
|
.fetch()
|
||||||
.pipe(tap((res) => this.kpis.set(res.data?.homeDashboardKpis)))
|
.pipe(tap((res) => this.kpis.set(res.data.homeDashboardKpis)))
|
||||||
.subscribe();
|
.subscribe();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -1,22 +1,22 @@
|
|||||||
@if (kpiQuery.isPending()) {
|
<!-- <ng-container
|
||||||
<hlm-skeleton class="h-75 w-92.5"></hlm-skeleton>
|
*skeleton="isLoading(); repeat: 5; className: 'mr-2'; height: '50px'"
|
||||||
}
|
></ng-container> -->
|
||||||
@if (kpiQuery.data(); as result) {
|
@if (kpi()?.main?.label) {
|
||||||
<app-info-card
|
<app-info-card
|
||||||
[chartValue]="result.main.value"
|
[chartValue]="kpi()?.main?.value!"
|
||||||
[chartLabel]="result.main.label"
|
[chartLabel]="kpi()?.main?.label!"
|
||||||
[title]="ordinanceType()"
|
[title]="ordinanceType()"
|
||||||
>
|
>
|
||||||
<app-info-card-content
|
<app-info-card-content
|
||||||
[value]="result.secondary.value | number: '.0-1' : 'de'"
|
[value]="kpi()?.secondary?.value | number : '.0-1' : 'de'"
|
||||||
[label]="result.secondary.label"
|
[label]="kpi()?.secondary?.label"
|
||||||
></app-info-card-content>
|
></app-info-card-content>
|
||||||
@for (metric of result.additional; track $index) {
|
@for (metric of kpi()?.additional; track $index) {
|
||||||
<app-info-card-footer
|
<app-info-card-footer
|
||||||
[value]="metric.value | number: '.0-2' : 'de'"
|
[value]="metric.value | number : '.0-2' : 'de'"
|
||||||
[label]="metric.label"
|
[label]="metric.label"
|
||||||
>
|
>
|
||||||
</app-info-card-footer>
|
</app-info-card-footer>
|
||||||
}
|
}
|
||||||
</app-info-card>
|
</app-info-card>
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-35
@@ -1,50 +1,38 @@
|
|||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { Component, effect, inject, input, signal } from '@angular/core';
|
||||||
import { Component, inject, input } from '@angular/core';
|
import { tap } from 'rxjs';
|
||||||
import { KpiInfoDto } from '@avicenna/shared-dtos';
|
|
||||||
import { HlmSkeleton } from '@spartan-ng/helm/skeleton';
|
|
||||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
import { environment } from 'src/environments/environment';
|
|
||||||
import { InfoCardModule } from '../../../../core/components/info-card/info-card.component';
|
import { InfoCardModule } from '../../../../core/components/info-card/info-card.component';
|
||||||
|
import {
|
||||||
|
KpiInfo,
|
||||||
|
Metric,
|
||||||
|
} from '../../../../core/data-access/graphql/generated/generated';
|
||||||
|
import { ManagerDashboardService } from '../../manager-dashboard.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manager-info-card',
|
selector: 'app-manager-info-card',
|
||||||
imports: [InfoCardModule, DecimalPipe, HlmSkeleton],
|
imports: [InfoCardModule, DecimalPipe],
|
||||||
templateUrl: './manager-info-card.component.html',
|
templateUrl: './manager-info-card.component.html',
|
||||||
styleUrl: './manager-info-card.component.scss',
|
styleUrl: './manager-info-card.component.scss',
|
||||||
})
|
})
|
||||||
export class ManagerInfoCardComponent {
|
export class ManagerInfoCardComponent {
|
||||||
private readonly http = inject(HttpClient);
|
private service = inject(ManagerDashboardService);
|
||||||
|
|
||||||
|
isLoading = signal<boolean>(false);
|
||||||
ordinanceType = input.required<string>();
|
ordinanceType = input.required<string>();
|
||||||
|
kpi = signal<KpiInfo | undefined>(undefined);
|
||||||
kpiQuery = injectQuery(() => ({
|
metrics = signal<Metric[]>([]);
|
||||||
queryKey: ['home'],
|
|
||||||
queryFn: () =>
|
|
||||||
lastValueFrom(
|
|
||||||
this.http.get<KpiInfoDto>(
|
|
||||||
`${environment.api.baseUrl}/kpis/manager?ordinanceType=${this.ordinanceType()}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
// private service = inject(ManagerDashboardService);
|
|
||||||
|
|
||||||
// isLoading = signal<boolean>(false);
|
|
||||||
// kpi = signal<KpiInfo | undefined>(undefined);
|
|
||||||
// metrics = signal<Metric[]>([]);
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// effect(() => {
|
effect(() => {
|
||||||
// this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
// this.service
|
this.service
|
||||||
// .fetchManagerKpi(this.ordinanceType())
|
.fetchManagerKpi(this.ordinanceType())
|
||||||
// .pipe(
|
.pipe(
|
||||||
// tap((res) => this.metrics.set(res.additional)),
|
tap((res) => this.metrics.set(res.additional)),
|
||||||
// tap((res) => this.kpi.set(res)),
|
tap((res) => this.kpi.set(res)),
|
||||||
// tap(() => this.isLoading.set(false)),
|
tap(() => this.isLoading.set(false))
|
||||||
// )
|
)
|
||||||
// .subscribe();
|
.subscribe();
|
||||||
// });
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -24,14 +24,12 @@ import { TimePerTourSummaryComponent } from '../time-per-tour-summary/time-per-t
|
|||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
],
|
],
|
||||||
templateUrl: './metric-summary.component.html',
|
templateUrl: './metric-summary.component.html',
|
||||||
styleUrls: ['./metric-summary.component.css'],
|
styleUrls: ['./metric-summary.component.css']
|
||||||
})
|
})
|
||||||
export class MetricSummaryComponent {
|
export class MetricSummaryComponent {
|
||||||
@Input() set metrics(metrics: TimePerTourMetric[] | undefined) {
|
@Input() set metrics(metrics: TimePerTourMetric[]) {
|
||||||
if (metrics) {
|
|
||||||
this._metrics$.next(metrics);
|
this._metrics$.next(metrics);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour;
|
@Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour;
|
||||||
@Input() metricTimeScale!: MetricTimeScale;
|
@Input() metricTimeScale!: MetricTimeScale;
|
||||||
@Input() labelFormat!: string;
|
@Input() labelFormat!: string;
|
||||||
@@ -43,7 +41,7 @@ export class MetricSummaryComponent {
|
|||||||
vm$ = this._metrics$.pipe(
|
vm$ = this._metrics$.pipe(
|
||||||
map((metrics) => ({
|
map((metrics) => ({
|
||||||
metrics,
|
metrics,
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string {
|
getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string {
|
||||||
@@ -53,7 +51,7 @@ export class MetricSummaryComponent {
|
|||||||
return 'd.M.yy';
|
return 'd.M.yy';
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'No label format for time scale defined, TimeScale: ' + timeScale,
|
'No label format for time scale defined, TimeScale: ' + timeScale
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -9,7 +9,13 @@ import { summarizeMetrics } from '../../util/summarize-metrics';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'dks-view-type-selector',
|
selector: 'dks-view-type-selector',
|
||||||
imports: [NgClass, AsyncPipe, CurrencyPipe, MatIconModule, MatCardModule],
|
imports: [
|
||||||
|
NgClass,
|
||||||
|
AsyncPipe,
|
||||||
|
CurrencyPipe,
|
||||||
|
MatIconModule,
|
||||||
|
MatCardModule
|
||||||
|
],
|
||||||
templateUrl: './view-type-selector.component.html',
|
templateUrl: './view-type-selector.component.html',
|
||||||
styleUrls: ['./view-type-selector.component.css'],
|
styleUrls: ['./view-type-selector.component.css'],
|
||||||
})
|
})
|
||||||
@@ -17,16 +23,14 @@ export class ViewTypeSelectorComponent {
|
|||||||
@Input() viewType!: DashboardViewType;
|
@Input() viewType!: DashboardViewType;
|
||||||
@Input() currentMetric!: DiagrammType;
|
@Input() currentMetric!: DiagrammType;
|
||||||
@Input() selected = false;
|
@Input() selected = false;
|
||||||
@Input() set metrics(metrics: TimePerTourMetric[] | undefined) {
|
@Input() set metrics(metrics: TimePerTourMetric[]) {
|
||||||
if (metrics) {
|
|
||||||
this.metrics$.next(metrics);
|
this.metrics$.next(metrics);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
@Output() viewTypeSelected = new EventEmitter<void>();
|
@Output() viewTypeSelected = new EventEmitter<void>();
|
||||||
|
|
||||||
private metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
private metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
||||||
metricSummary$ = this.metrics$.pipe(
|
metricSummary$ = this.metrics$.pipe(
|
||||||
map((metrics) => summarizeMetrics(metrics)),
|
map((metrics) => summarizeMetrics(metrics))
|
||||||
);
|
);
|
||||||
DiagrammType = DiagrammType;
|
DiagrammType = DiagrammType;
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="pr-2">
|
<span class="pr-2">
|
||||||
{{ vm.selectedDate | date: "dd.MM.yyyy" }}
|
{{ vm.selectedDate | date : "dd.MM.yyyy" }}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
[formControl]="selectedDate"
|
[formControl]="selectedDate"
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
import { MatChipListboxChange } from '@angular/material/chips';
|
import { MatChipListboxChange } from '@angular/material/chips';
|
||||||
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
|
|
||||||
import { addDays, addHours, format, isSunday } from 'date-fns';
|
import { addDays, addHours, format, isSunday } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
filter,
|
|
||||||
map,
|
map,
|
||||||
Observable,
|
|
||||||
startWith,
|
startWith,
|
||||||
|
switchMap,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { getDayviewQuery } from 'src/app/core/data-access/managers/manager.queries';
|
|
||||||
import { groupBy } from '../../../core/util/group-by';
|
import { groupBy } from '../../../core/util/group-by';
|
||||||
import { ManagerDashboardService } from '../manager-dashboard.service';
|
import { ManagerDashboardService } from '../manager-dashboard.service';
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +23,7 @@ import { mergeMetrics } from '../util/merge-metric';
|
|||||||
selector: 'dks-dayview',
|
selector: 'dks-dayview',
|
||||||
templateUrl: './dayview.component.html',
|
templateUrl: './dayview.component.html',
|
||||||
styleUrls: ['./dayview.component.css'],
|
styleUrls: ['./dayview.component.css'],
|
||||||
standalone: false,
|
standalone: false
|
||||||
})
|
})
|
||||||
export class DayviewComponent {
|
export class DayviewComponent {
|
||||||
OrdinanceTypes = OrdinanceTypes;
|
OrdinanceTypes = OrdinanceTypes;
|
||||||
@@ -37,39 +33,49 @@ export class DayviewComponent {
|
|||||||
metricTimeScale = MetricTimeScale.Dayview;
|
metricTimeScale = MetricTimeScale.Dayview;
|
||||||
selectedDate = new FormControl<Date>(new Date());
|
selectedDate = new FormControl<Date>(new Date());
|
||||||
selectedDate$ = this.selectedDate.valueChanges.pipe(
|
selectedDate$ = this.selectedDate.valueChanges.pipe(
|
||||||
startWith(this.selectedDate.value as Date),
|
startWith(this.selectedDate.value),
|
||||||
map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date)),
|
map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date))
|
||||||
) as Observable<Date>;
|
);
|
||||||
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
|
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
|
||||||
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
|
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
|
||||||
DashboardViewType.Gesamt,
|
DashboardViewType.Gesamt
|
||||||
);
|
);
|
||||||
|
|
||||||
vm$;
|
vm$;
|
||||||
|
|
||||||
private readonly dateSignal = toSignal(this.selectedDate$, {
|
|
||||||
initialValue: new Date(),
|
|
||||||
});
|
|
||||||
btwQuery = getDayviewQuery(this.dateSignal, signal(OrdinanceTypes.BTW));
|
|
||||||
tswQuery = getDayviewQuery(this.dateSignal, signal(OrdinanceTypes.TSW));
|
|
||||||
ktwQuery = getDayviewQuery(this.dateSignal, signal(OrdinanceTypes.KTW));
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly managerDashboardService: ManagerDashboardService,
|
private readonly managerDashboardService: ManagerDashboardService
|
||||||
) {
|
) {
|
||||||
this.vm$ = combineLatest([
|
this.vm$ = combineLatest([
|
||||||
toObservable(this.btwQuery.data),
|
this.selectedDate$.pipe(
|
||||||
toObservable(this.tswQuery.data),
|
switchMap((selectedDate) =>
|
||||||
toObservable(this.ktwQuery.data),
|
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
|
||||||
|
OrdinanceTypes.BTW,
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
this.selectedDate$.pipe(
|
||||||
|
switchMap((selectedDate) =>
|
||||||
|
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
|
||||||
|
OrdinanceTypes.TSW,
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
this.selectedDate$.pipe(
|
||||||
|
switchMap((selectedDate) =>
|
||||||
|
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
|
||||||
|
OrdinanceTypes.KTW,
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
this.selectedMetric$,
|
this.selectedMetric$,
|
||||||
this.selectedDate$,
|
this.selectedDate$,
|
||||||
this.managerDashboardService.isLoadingDayview$,
|
this.managerDashboardService.isLoadingDayview$,
|
||||||
this.selectedViewType$,
|
this.selectedViewType$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
filter(
|
|
||||||
([btwMetrics, tswMetrics, ktwMetrics]) =>
|
|
||||||
!!btwMetrics && !!tswMetrics && !!ktwMetrics,
|
|
||||||
),
|
|
||||||
map(
|
map(
|
||||||
([
|
([
|
||||||
btwMetrics,
|
btwMetrics,
|
||||||
@@ -90,17 +96,13 @@ export class DayviewComponent {
|
|||||||
combinedMetrics: mergeMetrics(
|
combinedMetrics: mergeMetrics(
|
||||||
Object.values(
|
Object.values(
|
||||||
groupBy(
|
groupBy(
|
||||||
[
|
[...btwMetrics, ...tswMetrics, ...ktwMetrics],
|
||||||
...(btwMetrics as TimePerTourMetricDto[]),
|
({ interval: { start } }) => format(new Date(start), 'HH:mm')
|
||||||
...(tswMetrics as TimePerTourMetricDto[]),
|
)
|
||||||
...(ktwMetrics as TimePerTourMetricDto[]),
|
)
|
||||||
],
|
|
||||||
({ interval: { start } }) => format(new Date(start), 'HH:mm'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export class DayviewComponent {
|
|||||||
addDayToSelectedDate(days: number): void {
|
addDayToSelectedDate(days: number): void {
|
||||||
const nextSelectedDate = addDays(
|
const nextSelectedDate = addDays(
|
||||||
this.selectedDate.value ?? new Date(),
|
this.selectedDate.value ?? new Date(),
|
||||||
days,
|
days
|
||||||
);
|
);
|
||||||
this.selectedDate.setValue(nextSelectedDate);
|
this.selectedDate.setValue(nextSelectedDate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ export class ManagerDashboardService {
|
|||||||
ordinanceType: string
|
ordinanceType: string
|
||||||
): Observable<ManagerKpiQuery['managerKpi']> {
|
): Observable<ManagerKpiQuery['managerKpi']> {
|
||||||
const managerKpiQuery = this.managerKpiGql.watch({
|
const managerKpiQuery = this.managerKpiGql.watch({
|
||||||
variables: {
|
|
||||||
ordinanceType,
|
ordinanceType,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const update = this.timePerTourUpdate$.pipe(
|
const update = this.timePerTourUpdate$.pipe(
|
||||||
@@ -76,48 +74,44 @@ export class ManagerDashboardService {
|
|||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData
|
updatedData ? updatedData : firstData
|
||||||
),
|
),
|
||||||
map((response) => response.data?.managerKpi as ManagerKpiQuery['managerKpi'])
|
map((response) => response.data.managerKpi)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTimePerTourKPIByOrdinanceTypes(
|
getTimePerTourKPIByOrdinanceTypes(
|
||||||
// ordinanceType: string
|
ordinanceType: string
|
||||||
// ): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> {
|
): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> {
|
||||||
// const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({
|
const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({
|
||||||
// variables: {
|
ordinanceType,
|
||||||
// ordinanceType,
|
});
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
||||||
// switchMap(() => timePerTourKPIQuery.refetch()),
|
switchMap(() => timePerTourKPIQuery.refetch()),
|
||||||
// startWith(undefined)
|
startWith(undefined)
|
||||||
// );
|
);
|
||||||
// return combineLatest([
|
return combineLatest([
|
||||||
// timePerTourKPIUpdate,
|
timePerTourKPIUpdate,
|
||||||
// timePerTourKPIQuery.valueChanges,
|
timePerTourKPIQuery.valueChanges,
|
||||||
// ]).pipe(
|
]).pipe(
|
||||||
// catchError((error) => {
|
catchError((error) => {
|
||||||
// console.error(error);
|
console.error(error);
|
||||||
// return throwError(() => error);
|
return throwError(() => error);
|
||||||
// }),
|
}),
|
||||||
// retry({ delay: 1000, count: 3 }),
|
retry({ delay: 1000, count: 3 }),
|
||||||
// map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
// updatedData ? updatedData : firstData
|
updatedData ? updatedData : firstData
|
||||||
// ),
|
),
|
||||||
// map((response) => response.data?.toursPerTimeKPI as GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI'])
|
map((response) => response.data.toursPerTimeKPI)
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
|
|
||||||
getTimePerTourMetricByOrdinanceTypeForDay(
|
getTimePerTourMetricByOrdinanceTypeForDay(
|
||||||
ordinanceType: string,
|
ordinanceType: string,
|
||||||
day: Date
|
day: Date
|
||||||
): Observable<GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']> {
|
): Observable<GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']> {
|
||||||
const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({
|
const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({
|
||||||
variables: {
|
|
||||||
ordinanceType,
|
ordinanceType,
|
||||||
day,
|
day,
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
||||||
@@ -140,7 +134,7 @@ export class ManagerDashboardService {
|
|||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData
|
updatedData ? updatedData : firstData
|
||||||
),
|
),
|
||||||
map((response) => response.data?.timePerTourMetricForDay as GetTimePerTourMetricForDayQuery['timePerTourMetricForDay'])
|
map((response) => response.data.timePerTourMetricForDay)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +146,10 @@ export class ManagerDashboardService {
|
|||||||
> {
|
> {
|
||||||
const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch(
|
const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch(
|
||||||
{
|
{
|
||||||
variables: {
|
|
||||||
ordinanceType,
|
ordinanceType,
|
||||||
day,
|
day,
|
||||||
},
|
},
|
||||||
errorPolicy: 'all'
|
{ errorPolicy: 'all' }
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
||||||
@@ -181,10 +173,10 @@ export class ManagerDashboardService {
|
|||||||
updatedData ? updatedData : firstData
|
updatedData ? updatedData : firstData
|
||||||
),
|
),
|
||||||
map((response) => {
|
map((response) => {
|
||||||
if (response.error || response.error) {
|
if (response.error || response.errors) {
|
||||||
console.error(response.error, response.error);
|
console.error(response.error, response.errors);
|
||||||
}
|
}
|
||||||
return response.data?.timePerTourMetricForMonth as GetTimePerTourMetricForMonthQuery['timePerTourMetricForMonth'];
|
return response.data.timePerTourMetricForMonth;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
import { MatChipListboxChange } from '@angular/material/chips';
|
import { MatChipListboxChange } from '@angular/material/chips';
|
||||||
import { MatDatepicker } from '@angular/material/datepicker';
|
import { MatDatepicker } from '@angular/material/datepicker';
|
||||||
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
|
|
||||||
import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns';
|
import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
filter,
|
|
||||||
map,
|
map,
|
||||||
Observable,
|
|
||||||
startWith,
|
startWith,
|
||||||
|
switchMap,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { getMonthviewQuery } from 'src/app/core/data-access/managers/manager.queries';
|
|
||||||
import { groupBy } from '../../../core/util/group-by';
|
import { groupBy } from '../../../core/util/group-by';
|
||||||
import { ManagerDashboardService } from '../manager-dashboard.service';
|
import { ManagerDashboardService } from '../manager-dashboard.service';
|
||||||
import {
|
import {
|
||||||
@@ -38,39 +34,48 @@ export class MonthviewComponent {
|
|||||||
metricTimeScale = MetricTimeScale.Monthview;
|
metricTimeScale = MetricTimeScale.Monthview;
|
||||||
selectedDate = new FormControl<Date>(addDays(startOfMonth(new Date()), 1));
|
selectedDate = new FormControl<Date>(addDays(startOfMonth(new Date()), 1));
|
||||||
selectedDate$ = this.selectedDate.valueChanges.pipe(
|
selectedDate$ = this.selectedDate.valueChanges.pipe(
|
||||||
startWith(this.selectedDate.value as Date),
|
startWith(this.selectedDate.value)
|
||||||
map((data) => data),
|
);
|
||||||
) as Observable<Date>;
|
|
||||||
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
|
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.Revenue);
|
||||||
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
|
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
|
||||||
DashboardViewType.Gesamt,
|
DashboardViewType.Gesamt
|
||||||
);
|
);
|
||||||
|
|
||||||
vm$;
|
vm$;
|
||||||
|
|
||||||
private readonly dateSignal = toSignal(this.selectedDate$, {
|
|
||||||
initialValue: new Date(),
|
|
||||||
});
|
|
||||||
btwQuery = getMonthviewQuery(this.dateSignal, signal(OrdinanceTypes.BTW));
|
|
||||||
tswQuery = getMonthviewQuery(this.dateSignal, signal(OrdinanceTypes.TSW));
|
|
||||||
ktwQuery = getMonthviewQuery(this.dateSignal, signal(OrdinanceTypes.KTW));
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly managerDashboardService: ManagerDashboardService,
|
private readonly managerDashboardService: ManagerDashboardService
|
||||||
) {
|
) {
|
||||||
this.vm$ = combineLatest([
|
this.vm$ = combineLatest([
|
||||||
toObservable(this.btwQuery.data),
|
this.selectedDate$.pipe(
|
||||||
toObservable(this.tswQuery.data),
|
switchMap((selectedDate) =>
|
||||||
toObservable(this.ktwQuery.data),
|
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||||
|
OrdinanceTypes.BTW,
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
this.selectedDate$.pipe(
|
||||||
|
switchMap((selectedDate) =>
|
||||||
|
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||||
|
OrdinanceTypes.TSW,
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
this.selectedDate$.pipe(
|
||||||
|
switchMap((selectedDate) =>
|
||||||
|
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||||
|
OrdinanceTypes.KTW,
|
||||||
|
selectedDate ?? new Date()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
this.selectedMetric$,
|
this.selectedMetric$,
|
||||||
this.selectedDate$,
|
this.selectedDate$,
|
||||||
this.managerDashboardService.isLoadingMonthview$,
|
this.managerDashboardService.isLoadingMonthview$,
|
||||||
this.selectedViewType$,
|
this.selectedViewType$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
filter(
|
|
||||||
([btwMetrics, tswMetrics, ktwMetrics]) =>
|
|
||||||
!!btwMetrics && !!tswMetrics && !!ktwMetrics,
|
|
||||||
),
|
|
||||||
map(
|
map(
|
||||||
([
|
([
|
||||||
btwMetrics,
|
btwMetrics,
|
||||||
@@ -91,17 +96,13 @@ export class MonthviewComponent {
|
|||||||
combinedMetrics: mergeMetrics(
|
combinedMetrics: mergeMetrics(
|
||||||
Object.values(
|
Object.values(
|
||||||
groupBy(
|
groupBy(
|
||||||
[
|
[...btwMetrics, ...tswMetrics, ...ktwMetrics],
|
||||||
...(btwMetrics as TimePerTourMetricDto[]),
|
({ interval: { start } }) => format(new Date(start), 'd.M.yy')
|
||||||
...(tswMetrics as TimePerTourMetricDto[]),
|
)
|
||||||
...(ktwMetrics as TimePerTourMetricDto[]),
|
)
|
||||||
],
|
|
||||||
({ interval: { start } }) => format(new Date(start), 'd.M.yy'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ export class MonthviewComponent {
|
|||||||
addMonthToSelectedDate(months: number): void {
|
addMonthToSelectedDate(months: number): void {
|
||||||
const nextSelectedDate = addMonths(
|
const nextSelectedDate = addMonths(
|
||||||
this.selectedDate.value ?? new Date(),
|
this.selectedDate.value ?? new Date(),
|
||||||
months,
|
months
|
||||||
);
|
);
|
||||||
if (isSunday(nextSelectedDate)) {
|
if (isSunday(nextSelectedDate)) {
|
||||||
this.selectedDate.setValue(addDays(nextSelectedDate, 1));
|
this.selectedDate.setValue(addDays(nextSelectedDate, 1));
|
||||||
@@ -127,7 +128,7 @@ export class MonthviewComponent {
|
|||||||
|
|
||||||
setMonthAndYear(
|
setMonthAndYear(
|
||||||
normalizedMonthAndYear: Date,
|
normalizedMonthAndYear: Date,
|
||||||
datepicker: MatDatepicker<Date>,
|
datepicker: MatDatepicker<Date>
|
||||||
) {
|
) {
|
||||||
this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1));
|
this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1));
|
||||||
datepicker.close();
|
datepicker.close();
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
@if (vm$ | async; as vm) {
|
@if (vm$ | async; as vm) {
|
||||||
<div class="flex flex-col m-0 md:m-4">
|
<div
|
||||||
|
class="flex flex-col m-0 md:m-4"
|
||||||
|
>
|
||||||
<div class="flex flex-wrap md:flex-nowrap">
|
<div class="flex flex-wrap md:flex-nowrap">
|
||||||
<!-- KPI -->
|
<!-- KPI -->
|
||||||
<dks-time-per-tour-kpi
|
<dks-time-per-tour-kpi
|
||||||
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
|
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
border: vm.selectedOrdinanceType === OrdinanceTypes.BTW,
|
'border': vm.selectedOrdinanceType === OrdinanceTypes.BTW
|
||||||
}"
|
}"
|
||||||
style="border-color: var(--mat-sys-primary)"
|
style="border-color: var(--mat-sys-primary);"
|
||||||
[text]="OrdinanceTypes.BTW"
|
[text]="OrdinanceTypes.BTW"
|
||||||
[kpis]="vm.btwKPI.summaries"
|
[kpis]="vm.btwKPI.summaries"
|
||||||
(click)="setSelectedOrdinanceType(OrdinanceTypes.BTW)"
|
(click)="setSelectedOrdinanceType(OrdinanceTypes.BTW)"
|
||||||
@@ -15,9 +17,9 @@
|
|||||||
<dks-time-per-tour-kpi
|
<dks-time-per-tour-kpi
|
||||||
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
|
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
border: vm.selectedOrdinanceType === OrdinanceTypes.TSW,
|
'border': vm.selectedOrdinanceType === OrdinanceTypes.TSW
|
||||||
}"
|
}"
|
||||||
style="border-color: var(--mat-sys-primary)"
|
style="border-color: var(--mat-sys-primary);"
|
||||||
[text]="OrdinanceTypes.TSW"
|
[text]="OrdinanceTypes.TSW"
|
||||||
[kpis]="vm.tswKPI.summaries"
|
[kpis]="vm.tswKPI.summaries"
|
||||||
(click)="setSelectedOrdinanceType(OrdinanceTypes.TSW)"
|
(click)="setSelectedOrdinanceType(OrdinanceTypes.TSW)"
|
||||||
@@ -25,16 +27,18 @@
|
|||||||
<dks-time-per-tour-kpi
|
<dks-time-per-tour-kpi
|
||||||
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
|
class="w-full md:w-1/3 m-2 cursor-pointer rounded-xl transition ease-in-out hover:scale-105 duration-200"
|
||||||
[ngClass]="{
|
[ngClass]="{
|
||||||
border: vm.selectedOrdinanceType === OrdinanceTypes.KTW,
|
'border': vm.selectedOrdinanceType === OrdinanceTypes.KTW
|
||||||
}"
|
}"
|
||||||
style="border-color: var(--mat-sys-primary)"
|
style="border-color: var(--mat-sys-primary);"
|
||||||
[text]="OrdinanceTypes.KTW"
|
[text]="OrdinanceTypes.KTW"
|
||||||
[kpis]="vm.ktwKPI.summaries"
|
[kpis]="vm.ktwKPI.summaries"
|
||||||
(click)="setSelectedOrdinanceType(OrdinanceTypes.KTW)"
|
(click)="setSelectedOrdinanceType(OrdinanceTypes.KTW)"
|
||||||
></dks-time-per-tour-kpi>
|
></dks-time-per-tour-kpi>
|
||||||
</div>
|
</div>
|
||||||
@if (vm.selectedOrdinanceType) {
|
@if (vm.selectedOrdinanceType) {
|
||||||
<div class="md:p-2 flex flex-wrap md:flex-nowrap">
|
<div
|
||||||
|
class="md:p-2 flex flex-wrap md:flex-nowrap"
|
||||||
|
>
|
||||||
<!-- Data Table -->
|
<!-- Data Table -->
|
||||||
<mat-card appearance="outlined" class="w-full">
|
<mat-card appearance="outlined" class="w-full">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
|
|||||||
@@ -1,75 +1,58 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { format, Interval } from 'date-fns';
|
import { format, Interval } from 'date-fns';
|
||||||
import { BehaviorSubject, combineLatest, filter, map } from 'rxjs';
|
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||||
import { ManagerDashboardService } from '../manager-dashboard.service';
|
import { ManagerDashboardService } from '../manager-dashboard.service';
|
||||||
import { OrdinanceTypes } from '../types';
|
import { OrdinanceTypes } from '../types';
|
||||||
import { getOverviewQuery } from 'src/app/core/data-access/managers/manager.queries';
|
|
||||||
import { toObservable } from '@angular/core/rxjs-interop';
|
|
||||||
import { injectQueries } from '@tanstack/angular-query-experimental/inject-queries';
|
|
||||||
import {
|
|
||||||
TimePerTourKPISummaryDto,
|
|
||||||
TimePerTourMetricDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'dks-overview',
|
selector: 'dks-overview',
|
||||||
templateUrl: './overview.component.html',
|
templateUrl: './overview.component.html',
|
||||||
styleUrls: ['./overview.component.css'],
|
styleUrls: ['./overview.component.css'],
|
||||||
standalone: false,
|
standalone: false
|
||||||
})
|
})
|
||||||
export class OverviewComponent {
|
export class OverviewComponent {
|
||||||
OrdinanceTypes = OrdinanceTypes;
|
OrdinanceTypes = OrdinanceTypes;
|
||||||
selectedOrdinanceType$ = new BehaviorSubject<OrdinanceTypes | undefined>(
|
selectedOrdinanceType$ = new BehaviorSubject<OrdinanceTypes | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
vm$;
|
vm$;
|
||||||
|
|
||||||
btwOverviewQuery = getOverviewQuery(OrdinanceTypes.BTW);
|
|
||||||
tswOverviewQuery = getOverviewQuery(OrdinanceTypes.TSW);
|
|
||||||
ktwOverviewQuery = getOverviewQuery(OrdinanceTypes.KTW);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly managerDashboardService: ManagerDashboardService
|
||||||
) {
|
) {
|
||||||
this.vm$ = combineLatest([
|
this.vm$ = combineLatest([
|
||||||
toObservable(this.btwOverviewQuery.data),
|
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
|
||||||
toObservable(this.tswOverviewQuery.data),
|
OrdinanceTypes.BTW
|
||||||
toObservable(this.ktwOverviewQuery.data),
|
),
|
||||||
|
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
|
||||||
|
OrdinanceTypes.TSW
|
||||||
|
),
|
||||||
|
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
|
||||||
|
OrdinanceTypes.KTW
|
||||||
|
),
|
||||||
this.selectedOrdinanceType$,
|
this.selectedOrdinanceType$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
filter(([btw, tsw, ktw]) => !!btw && !!tsw && !!ktw),
|
|
||||||
map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({
|
map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({
|
||||||
btwKPI: {
|
btwKPI: {
|
||||||
summaries:
|
summaries: btwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
|
||||||
btwKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [],
|
cars: this.createCarTableData(
|
||||||
cars:
|
btwKPIs.map(({ interval, cars }) => ({ interval, cars }))
|
||||||
this.createCarTableData(
|
|
||||||
(btwKPIs as TimePerTourKPISummaryDto[]).map(
|
|
||||||
({ interval, cars }) => ({ interval, cars }),
|
|
||||||
),
|
),
|
||||||
) ?? [],
|
|
||||||
},
|
},
|
||||||
tswKPI: {
|
tswKPI: {
|
||||||
summaries:
|
summaries: tswKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
|
||||||
tswKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [],
|
cars: this.createCarTableData(
|
||||||
cars:
|
tswKPIs.map(({ interval, cars }) => ({ interval, cars }))
|
||||||
this.createCarTableData(
|
|
||||||
(tswKPIs as TimePerTourKPISummaryDto[]).map(
|
|
||||||
({ interval, cars }) => ({ interval, cars }),
|
|
||||||
),
|
),
|
||||||
) ?? [],
|
|
||||||
},
|
},
|
||||||
ktwKPI: {
|
ktwKPI: {
|
||||||
summaries:
|
summaries: ktwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
|
||||||
ktwKPIs?.map(({ interval, kpi }) => ({ interval, kpi })) ?? [],
|
cars: this.createCarTableData(
|
||||||
cars:
|
ktwKPIs.map(({ interval, cars }) => ({ interval, cars }))
|
||||||
this.createCarTableData(
|
|
||||||
(ktwKPIs as TimePerTourKPISummaryDto[]).map(
|
|
||||||
({ interval, cars }) => ({ interval, cars }),
|
|
||||||
),
|
),
|
||||||
) ?? [],
|
|
||||||
},
|
},
|
||||||
selectedOrdinanceType,
|
selectedOrdinanceType,
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +74,15 @@ export class OverviewComponent {
|
|||||||
meta: { availableTime: number; tourAmount: number };
|
meta: { availableTime: number; tourAmount: number };
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
}[],
|
}[]
|
||||||
) {
|
) {
|
||||||
const columns = [
|
const columns = [
|
||||||
{ id: 'carName', title: 'Fahrzeug' },
|
{ id: 'carName', title: 'Fahrzeug' },
|
||||||
...carsByInterval?.map(({ interval }, index) => ({
|
...carsByInterval.map(({ interval }, index) => ({
|
||||||
id: `slot${index}`,
|
id: `slot${index}`,
|
||||||
title: `${format(new Date(interval.start), 'HH:mm')} - ${format(
|
title: `${format(new Date(interval.start), 'HH:mm')} - ${format(
|
||||||
new Date(interval.end),
|
new Date(interval.end),
|
||||||
'HH:mm',
|
'HH:mm'
|
||||||
)}`,
|
)}`,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
@@ -123,7 +106,7 @@ export class OverviewComponent {
|
|||||||
}
|
}
|
||||||
return newGroup;
|
return newGroup;
|
||||||
},
|
},
|
||||||
{} as Record<string, any>,
|
{} as Record<string, any>
|
||||||
);
|
);
|
||||||
return { columns, data: Object.values(groupedByCars) };
|
return { columns, data: Object.values(groupedByCars) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
import { DatePipe, NgClass } from '@angular/common';
|
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
import { Component, inject, Input, signal } from '@angular/core';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import {
|
|
||||||
lucideAmbulance,
|
|
||||||
lucideChevronLeft,
|
|
||||||
lucideChevronRight,
|
|
||||||
lucideLoader2,
|
|
||||||
lucideUser,
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
import { HlmButtonImports } from '@spartan-ng/helm/button';
|
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
|
||||||
import { injectQuery } from '@tanstack/angular-query-experimental';
|
|
||||||
import { addDays, addMinutes, format, parse, subDays } from 'date-fns';
|
|
||||||
import { lastValueFrom } from 'rxjs';
|
|
||||||
import { environment } from 'src/environments/environment';
|
|
||||||
|
|
||||||
export interface Car {
|
|
||||||
name: string;
|
|
||||||
drivers: Driver[];
|
|
||||||
tours: Tour[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Driver {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tour {
|
|
||||||
id: string;
|
|
||||||
operationId: string;
|
|
||||||
ordinanceType: string;
|
|
||||||
startDate: string;
|
|
||||||
optimizationInMinutes: number;
|
|
||||||
departedForPickup?: string;
|
|
||||||
arrivedAtPickup?: string;
|
|
||||||
departedPickup?: string;
|
|
||||||
arrivedAtTarget?: string;
|
|
||||||
completed?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-time-cell',
|
|
||||||
standalone: true,
|
|
||||||
imports: [DatePipe, NgClass],
|
|
||||||
template: `
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center rounded py-1 px-0.5 text-xs font-mono tabular-nums min-h-6"
|
|
||||||
[ngClass]="
|
|
||||||
date
|
|
||||||
? [bg, color, 'font-medium']
|
|
||||||
: 'bg-muted/30 text-muted-foreground/30'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
@if (date) {
|
|
||||||
{{ date }}
|
|
||||||
} @else {
|
|
||||||
-
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
export class TimeCellComponent {
|
|
||||||
@Input() date: Date | string | null | undefined;
|
|
||||||
@Input() color = 'text-foreground';
|
|
||||||
@Input() bg = 'bg-muted';
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-playground',
|
|
||||||
imports: [NgIcon, HlmIcon, HlmButtonImports, DatePipe, TimeCellComponent],
|
|
||||||
providers: [
|
|
||||||
provideIcons({
|
|
||||||
lucideChevronRight,
|
|
||||||
lucideChevronLeft,
|
|
||||||
lucideAmbulance,
|
|
||||||
lucideUser,
|
|
||||||
lucideLoader2,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
template: ` <div class="flex flex-col h-[calc(100vh-6rem)] gap-4 m-0 md:m-4">
|
|
||||||
<div
|
|
||||||
class="flex-none flex items-center justify-between p-4 bg-card border rounded-lg shadow-sm"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex items-center gap-2 bg-muted/30 rounded-md p-1">
|
|
||||||
<button
|
|
||||||
hlmBtn
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-8"
|
|
||||||
(click)="prevDay()"
|
|
||||||
>
|
|
||||||
<ng-icon hlm size="sm" name="lucideChevronLeft" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="font-mono font-medium min-w-24 text-center">
|
|
||||||
{{ selectedDate() | date: 'dd.MM.yyyy' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
hlmBtn
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-8"
|
|
||||||
(click)="nextDay()"
|
|
||||||
>
|
|
||||||
<ng-icon hlm size="sm" name="lucideChevronRight" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-muted-foreground hidden md:inline-block">
|
|
||||||
Playground: Disposition & Timestamps
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 relative">
|
|
||||||
@if (carsQuery.isLoading()) {
|
|
||||||
<div
|
|
||||||
class="flex h-full items-center justify-center text-muted-foreground animate-pulse"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideLoader2" class="mr-2 animate-spin" />
|
|
||||||
Lade Fahrzeuge ...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (carsQuery.data(); as cars) {
|
|
||||||
@if(cars.length === 0) {
|
|
||||||
Kein Optimierungspotenzial für den Einsatztag {{selectedDate() | date:"dd.MM.yyyy"}}
|
|
||||||
}
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex flex-row gap-4 overflow-x-auto pb-4 px-1 snap-x snap-mandatory"
|
|
||||||
>
|
|
||||||
@for (car of cars; track car.name) {
|
|
||||||
<div
|
|
||||||
class="flex-none w-80 md:w-96 flex flex-col h-full bg-muted/10 border rounded-xl overflow-hidden snap-start bg-card shadow-sm"
|
|
||||||
>
|
|
||||||
<div class="flex-none p-3 border-b bg-muted/40 backdrop-blur-sm">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<div
|
|
||||||
class="size-8 rounded bg-primary/10 flex items-center justify-center text-primary"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideAmbulance" size="sm" />
|
|
||||||
</div>
|
|
||||||
<h3 class="font-bold text-lg leading-none">{{ car.name }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
|
||||||
@for (driver of car.drivers; track driver.name) {
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-background border text-muted-foreground"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
name="lucideUser"
|
|
||||||
size="xs"
|
|
||||||
class="mr-1 opacity-70"
|
|
||||||
/>
|
|
||||||
{{ driver.name }}
|
|
||||||
</span>
|
|
||||||
} @empty {
|
|
||||||
<span class="text-xs text-destructive italic"
|
|
||||||
>Keine Besatzung</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-3 space-y-3">
|
|
||||||
@for (tour of car.tours; track tour.id) {
|
|
||||||
<div
|
|
||||||
class="bg-background border rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow group"
|
|
||||||
[class.bg-amber-100!]="hasOptimizationPotential(tour)"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-baseline border-b border-border/50 pb-2 mb-2"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider"
|
|
||||||
>Op-ID</span
|
|
||||||
>
|
|
||||||
<span class="font-mono text-xs text-muted-foreground"
|
|
||||||
>#{{ tour.operationId }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span
|
|
||||||
class="text-[10px] uppercase text-muted-foreground font-semibold tracking-wider"
|
|
||||||
>Start</span
|
|
||||||
>
|
|
||||||
<span class="font-bold font-mono text-sm text-primary">
|
|
||||||
{{ tour.startDate | date: 'HH:mm' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-5 gap-1 text-center">
|
|
||||||
<div
|
|
||||||
class="col-span-5 flex justify-between px-1 mb-1 text-[9px] text-muted-foreground uppercase opacity-50"
|
|
||||||
>
|
|
||||||
<span>Anf</span> <span>Ort</span> <span>Trans</span>
|
|
||||||
<span>Ziel</span> <span>Frei</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
[date]="tour.departedForPickup"
|
|
||||||
color="text-yellow-600"
|
|
||||||
bg="bg-yellow-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
[date]="tour.arrivedAtPickup"
|
|
||||||
color="text-blue-600"
|
|
||||||
bg="bg-blue-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
[date]="tour.departedPickup"
|
|
||||||
color="text-indigo-600"
|
|
||||||
bg="bg-indigo-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
[date]="tour.arrivedAtTarget"
|
|
||||||
color="text-purple-600"
|
|
||||||
bg="bg-purple-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
[date]="tour.completed"
|
|
||||||
color="text-green-600"
|
|
||||||
bg="bg-green-500/10"
|
|
||||||
/>
|
|
||||||
@if (hasOptimizationPotential(tour)) {
|
|
||||||
<app-time-cell
|
|
||||||
date="--:--"
|
|
||||||
color="text-gray-600"
|
|
||||||
bg="bg-gray-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
[date]="
|
|
||||||
calcOptimizedTime(
|
|
||||||
tour.arrivedAtPickup!,
|
|
||||||
tour.optimizationInMinutes
|
|
||||||
)
|
|
||||||
"
|
|
||||||
[color]="
|
|
||||||
hasOptimizationPotential(tour)
|
|
||||||
? 'text-red-600'
|
|
||||||
: 'text-gray-600'
|
|
||||||
"
|
|
||||||
[bg]="
|
|
||||||
hasOptimizationPotential(tour)
|
|
||||||
? 'bg-red-500/10'
|
|
||||||
: 'bg-gray-500/10'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
date="--:--"
|
|
||||||
color="text-gray-600"
|
|
||||||
bg="bg-gray-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
date="--:--"
|
|
||||||
color="text-gray-600"
|
|
||||||
bg="bg-gray-500/10"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<app-time-cell
|
|
||||||
date="--:--"
|
|
||||||
color="text-gray-600"
|
|
||||||
bg="bg-gray-500/10"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div
|
|
||||||
class="h-32 flex flex-col items-center justify-center text-muted-foreground/50 border-2 border-dashed rounded-lg"
|
|
||||||
>
|
|
||||||
<span class="text-sm">Keine Touren</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>`,
|
|
||||||
})
|
|
||||||
export class PlaygroundComponent {
|
|
||||||
private readonly http = inject(HttpClient);
|
|
||||||
|
|
||||||
selectedDate = signal<Date>(new Date());
|
|
||||||
|
|
||||||
carsQuery = injectQuery(() => ({
|
|
||||||
queryKey: [
|
|
||||||
'playground',
|
|
||||||
'lists',
|
|
||||||
{ day: format(this.selectedDate(), 'yyyy-MM-dd') },
|
|
||||||
],
|
|
||||||
queryFn: async () =>
|
|
||||||
lastValueFrom(
|
|
||||||
this.http.get<Car[]>(
|
|
||||||
`${environment.api.baseUrl}/playground/operation-day?day=${format(this.selectedDate(), 'yyyy-MM-dd')}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
prevDay(): void {
|
|
||||||
this.selectedDate.update((val) => subDays(val, 1));
|
|
||||||
}
|
|
||||||
nextDay(): void {
|
|
||||||
this.selectedDate.update((val) => addDays(val, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
hasOptimizationPotential(tour: Tour): boolean {
|
|
||||||
return tour.optimizationInMinutes !== 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
calcOptimizedTime(time: string, timeOffset: number): string {
|
|
||||||
if (timeOffset === 0 || !time) return '--:--';
|
|
||||||
|
|
||||||
const parsedTime = parse(time, 'HH:mm', new Date());
|
|
||||||
const updatedDate = addMinutes(parsedTime, timeOffset);
|
|
||||||
return format(updatedDate, 'HH:mm');
|
|
||||||
}
|
|
||||||
|
|
||||||
private isTimeInRange(time: string, start: string, end: string): boolean {
|
|
||||||
if (start <= end) {
|
|
||||||
return time >= start && time <= end;
|
|
||||||
} else {
|
|
||||||
return time >= start || time <= end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,7 @@ export class TicketsService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
updateTickets$ = new Subject<void>();
|
updateTickets$ = new Subject<void>();
|
||||||
@@ -55,7 +55,7 @@ export class TicketsService {
|
|||||||
private readonly setDocumentInfoGql: SetDocumentInfoGQL,
|
private readonly setDocumentInfoGql: SetDocumentInfoGQL,
|
||||||
private readonly ticketStateKpiGql: TicketStateKpiGQL,
|
private readonly ticketStateKpiGql: TicketStateKpiGQL,
|
||||||
private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
|
private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
|
||||||
private readonly toast: HotToastService,
|
private readonly toast: HotToastService
|
||||||
) {
|
) {
|
||||||
this.updateTickets$.next();
|
this.updateTickets$.next();
|
||||||
}
|
}
|
||||||
@@ -63,21 +63,21 @@ export class TicketsService {
|
|||||||
sendApprovalRequest(ticketId: string, email: string) {
|
sendApprovalRequest(ticketId: string, email: string) {
|
||||||
return this.sendApprovalRequestGql
|
return this.sendApprovalRequestGql
|
||||||
.mutate({
|
.mutate({
|
||||||
variables: {
|
|
||||||
email,
|
email,
|
||||||
ticketId,
|
ticketId,
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((mutationResult) => {
|
tap((mutationResult) => {
|
||||||
if (mutationResult.error) {
|
if (mutationResult.errors) {
|
||||||
this.toast.error(
|
this.toast.error(
|
||||||
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal',
|
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.toast.success('Die Genehmigungsanfrage wird versendet.');
|
this.toast.success(
|
||||||
|
'Die Genehmigungsanfrage wird versendet.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +85,13 @@ export class TicketsService {
|
|||||||
state: TicketValidationState,
|
state: TicketValidationState,
|
||||||
cursor: string,
|
cursor: string,
|
||||||
filters: TicketFilterArgs,
|
filters: TicketFilterArgs,
|
||||||
take = 20,
|
take = 20
|
||||||
): Observable<GetTicketsQuery['tickets']> {
|
): Observable<GetTicketsQuery['tickets']> {
|
||||||
const ticketsQuery = this.getTicketsGql.watch({
|
const ticketsQuery = this.getTicketsGql.watch({
|
||||||
variables: {
|
|
||||||
state,
|
state,
|
||||||
cursor,
|
cursor,
|
||||||
filters,
|
filters,
|
||||||
take,
|
take,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ticketsUpdate = combineLatest([
|
const ticketsUpdate = combineLatest([
|
||||||
@@ -101,7 +99,7 @@ export class TicketsService {
|
|||||||
this.updateTickets$,
|
this.updateTickets$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(() => ticketsQuery.refetch()),
|
switchMap(() => ticketsQuery.refetch()),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
|
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
|
||||||
@@ -111,19 +109,17 @@ export class TicketsService {
|
|||||||
}),
|
}),
|
||||||
retry({ delay: 1000, count: 3 }),
|
retry({ delay: 1000, count: 3 }),
|
||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData,
|
updatedData ? updatedData : firstData
|
||||||
),
|
),
|
||||||
map((response) => response.data?.tickets as GetTicketsQuery['tickets']),
|
map((response) => response.data.tickets)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchTicketStateMeta(
|
fetchTicketStateMeta(
|
||||||
state: TicketValidationState,
|
state: TicketValidationState
|
||||||
): Observable<GetTicketStateMetaQuery['ticketStateMeta']> {
|
): Observable<GetTicketStateMetaQuery['ticketStateMeta']> {
|
||||||
const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({
|
const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({
|
||||||
variables: {
|
|
||||||
state,
|
state,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ticketsUpdate = combineLatest([
|
const ticketsUpdate = combineLatest([
|
||||||
@@ -131,7 +127,7 @@ export class TicketsService {
|
|||||||
this.updateTickets$,
|
this.updateTickets$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(() => ticketStateMetaQuery.refetch()),
|
switchMap(() => ticketStateMetaQuery.refetch()),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
@@ -144,21 +140,17 @@ export class TicketsService {
|
|||||||
}),
|
}),
|
||||||
retry({ delay: 1000, count: 3 }),
|
retry({ delay: 1000, count: 3 }),
|
||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData,
|
updatedData ? updatedData : firstData
|
||||||
),
|
|
||||||
map(
|
|
||||||
(response) =>
|
|
||||||
response.data
|
|
||||||
?.ticketStateMeta as GetTicketStateMetaQuery['ticketStateMeta'],
|
|
||||||
),
|
),
|
||||||
|
map((response) => response.data.ticketStateMeta)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchTicketStateKpi(
|
fetchTicketStateKpi(
|
||||||
state: TicketValidationState,
|
state: TicketValidationState
|
||||||
): Observable<TicketStateKpiQuery['ticketStateKpi']> {
|
): Observable<TicketStateKpiQuery['ticketStateKpi']> {
|
||||||
const ticketStateKpiQuery = this.ticketStateKpiGql.watch({
|
const ticketStateKpiQuery = this.ticketStateKpiGql.watch({
|
||||||
variables: { state },
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ticketsUpdate = combineLatest([
|
const ticketsUpdate = combineLatest([
|
||||||
@@ -166,7 +158,7 @@ export class TicketsService {
|
|||||||
this.updateTickets$,
|
this.updateTickets$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
switchMap(() => ticketStateKpiQuery.refetch()),
|
switchMap(() => ticketStateKpiQuery.refetch()),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
@@ -179,45 +171,36 @@ export class TicketsService {
|
|||||||
}),
|
}),
|
||||||
retry({ delay: 1000, count: 3 }),
|
retry({ delay: 1000, count: 3 }),
|
||||||
map(([updatedData, firstData]) =>
|
map(([updatedData, firstData]) =>
|
||||||
updatedData ? updatedData : firstData,
|
updatedData ? updatedData : firstData
|
||||||
),
|
|
||||||
map(
|
|
||||||
(response) =>
|
|
||||||
response.data
|
|
||||||
?.ticketStateKpi as TicketStateKpiQuery['ticketStateKpi'],
|
|
||||||
),
|
),
|
||||||
|
map((response) => response.data.ticketStateKpi)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTickets(
|
searchTickets(
|
||||||
query: string,
|
query: string
|
||||||
): Observable<SearchTicketsQuery['searchTickets'] | undefined> {
|
): Observable<SearchTicketsQuery['searchTickets']> {
|
||||||
return this.searchTicketsGql
|
return this.searchTicketsGql
|
||||||
.fetch({ variables: { query } })
|
.fetch({ query })
|
||||||
.pipe(map((response) => response.data?.searchTickets));
|
.pipe(map((response) => response.data.searchTickets));
|
||||||
}
|
}
|
||||||
|
|
||||||
setDocumentInfo(
|
setDocumentInfo(
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
{
|
{ approval, tdLocation }: Omit<SetDocumentInfoMutationVariables, 'ticketId'>
|
||||||
approval,
|
|
||||||
tdLocation,
|
|
||||||
}: Omit<SetDocumentInfoMutationVariables, 'ticketId'>,
|
|
||||||
) {
|
) {
|
||||||
if ((approval as any)?.['__typename'])
|
if ((approval as any)?.['__typename'])
|
||||||
delete (approval as any)['__typename'];
|
delete (approval as any)['__typename'];
|
||||||
|
|
||||||
return this.setDocumentInfoGql
|
return this.setDocumentInfoGql
|
||||||
.mutate({
|
.mutate({
|
||||||
variables: {
|
|
||||||
ticketId,
|
ticketId,
|
||||||
approval,
|
approval,
|
||||||
tdLocation,
|
tdLocation,
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
map((res) => res.data?.setDocumentInfo),
|
map((res) => res.data?.setDocumentInfo),
|
||||||
tap(() => this.updateTickets$.next()),
|
tap(() => this.updateTickets$.next())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-51
@@ -15,54 +15,3 @@
|
|||||||
<span>{{ ticket.tourCount }} Tour{{ ticket.tourCount > 1 ? "en" : "" }}</span>
|
<span>{{ ticket.tourCount }} Tour{{ ticket.tourCount > 1 ? "en" : "" }}</span>
|
||||||
<span class="text-xs self-end">{{ getDistance(ticket.createdAt) }}</span>
|
<span class="text-xs self-end">{{ getDistance(ticket.createdAt) }}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- <div
|
|
||||||
class="group relative flex flex-col gap-1.5 rounded-lg border bg-card p-3 shadow-sm transition-all hover:bg-accent/5 hover:shadow-md cursor-pointer select-none"
|
|
||||||
[class.border-destructive]="ticket().isOverdue"
|
|
||||||
[class.bg-destructive\/5]="ticket().isOverdue"
|
|
||||||
[class.border-border]="!ticket().isOverdue"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold leading-tight text-foreground line-clamp-2"
|
|
||||||
[class.text-destructive]="ticket().isOverdue"
|
|
||||||
>
|
|
||||||
{{ ticket().patientName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-1">
|
|
||||||
|
|
||||||
<div class="flex items-center gap-1.5 text-xs">
|
|
||||||
@if (ticket().nextStart) {
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 transition-colors"
|
|
||||||
[class.text-destructive]="ticket().isOverdue"
|
|
||||||
[class.font-medium]="ticket().isOverdue"
|
|
||||||
[class.text-muted-foreground]="!ticket().isOverdue"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
[name]="ticket().isOverdue ? 'lucideAlertCircle' : 'lucideCalendarClock'"
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<span>{{ ticket().nextStart | date:'dd.MM. HH:mm' }}</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<span class="text-muted-foreground/50 italic text-[10px]">Bereits gefahren</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors"
|
|
||||||
[class.bg-destructive\/10]="ticket().isOverdue"
|
|
||||||
[class.text-destructive]="ticket().isOverdue"
|
|
||||||
[class.bg-secondary]="!ticket().isOverdue"
|
|
||||||
[class.text-secondary-foreground]="!ticket().isOverdue"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideCar" size="xs" />
|
|
||||||
<span>{{ ticket().tourCount }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div> -->
|
|
||||||
+3
-6
@@ -20,8 +20,7 @@ import CategoryColors from './category-colors';
|
|||||||
import { TicketListItemDto } from '@avicenna/shared-dtos';
|
import { TicketListItemDto } from '@avicenna/shared-dtos';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
import { HlmIcon } from '@spartan-ng/helm/icon';
|
||||||
import { lucideCalendarClock, lucideCar, lucideCircleAlert } from '@ng-icons/lucide';
|
import { lucideCircleAlert } from '@ng-icons/lucide';
|
||||||
import { DatePipe } from '@angular/common';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -39,11 +38,9 @@ import { DatePipe } from '@angular/common';
|
|||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
providers: [provideIcons({
|
providers: [provideIcons({
|
||||||
lucideCircleAlert,
|
lucideCircleAlert
|
||||||
lucideCalendarClock,
|
|
||||||
lucideCar
|
|
||||||
})],
|
})],
|
||||||
imports: [NgIcon, HlmIcon, DatePipe],
|
imports: [NgIcon, HlmIcon],
|
||||||
})
|
})
|
||||||
export class TicketItemComponent implements OnInit {
|
export class TicketItemComponent implements OnInit {
|
||||||
hasActivity = input(false);
|
hasActivity = input(false);
|
||||||
|
|||||||
+7
-12
@@ -2,20 +2,17 @@
|
|||||||
class="shadow"
|
class="shadow"
|
||||||
[state]="ticketState"
|
[state]="ticketState"
|
||||||
></dks-ticket-lane-header>
|
></dks-ticket-lane-header>
|
||||||
<div class="overflow-auto p-2 space-y-2">
|
<div class="flex-1 overflow-auto p-2 space-y-2">
|
||||||
@if (ticketsQuery.isPending()) {
|
@if (ticketsQuery.isPending()) {
|
||||||
<p>Lade Tickets...</p>
|
<p>Lade Tickets...</p>
|
||||||
}
|
} @if (ticketsQuery.data(); as result) { @for (ticket of result.data; track
|
||||||
@if (ticketsQuery.data(); as result) {
|
ticket.id) {
|
||||||
@for (ticket of result.data; track ticket.id) {
|
<dks-ticket-item [ticket]="ticket" (click)="openTicketView(ticket)" />
|
||||||
<dks-ticket-item [ticket]="ticket" (click)="openTicketView(ticket)" class="mb-2"/>
|
} @if (result.data.length === 0) {
|
||||||
}
|
|
||||||
@if (result.data.length === 0) {
|
|
||||||
<div class="text-center p-4 text-muted-foreground">
|
<div class="text-center p-4 text-muted-foreground">
|
||||||
Keine Tickets in dieser Phase.
|
Keine Tickets in dieser Phase.
|
||||||
</div>
|
</div>
|
||||||
}
|
} }
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav hlmPagination class="mt-auto border-t p-2">
|
<nav hlmPagination class="mt-auto border-t p-2">
|
||||||
@@ -29,9 +26,7 @@
|
|||||||
|
|
||||||
<li hlmPaginationItem>
|
<li hlmPaginationItem>
|
||||||
<span class="text-sm px-4">
|
<span class="text-sm px-4">
|
||||||
Seite {{ page() }}
|
Seite {{ page() }} @if (ticketsQuery.data(); as res) { von
|
||||||
@if (ticketsQuery.data(); as res) {
|
|
||||||
von
|
|
||||||
{{ res.meta.totalPages }}
|
{{ res.meta.totalPages }}
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+20
-9
@@ -1,31 +1,39 @@
|
|||||||
import {
|
import {
|
||||||
ScrollingModule
|
CdkVirtualScrollViewport,
|
||||||
|
ScrollingModule,
|
||||||
} from '@angular/cdk/scrolling';
|
} from '@angular/cdk/scrolling';
|
||||||
|
import { AsyncPipe } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Input,
|
Input,
|
||||||
|
ViewChild,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PaginatedResult, TicketListItemDto } from '@avicenna/shared-dtos';
|
import { DialogService, DialogCloseDirective } from '@ngneat/dialog';
|
||||||
import { DialogService } from '@ngneat/dialog';
|
|
||||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
|
||||||
import { BehaviorSubject, tap } from 'rxjs';
|
import { BehaviorSubject, tap } from 'rxjs';
|
||||||
import { HlmPaginationImports } from 'src/app/core/base-components/pagination/src';
|
import { HlmPaginationImports } from 'src/app/core/base-components/pagination/src';
|
||||||
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
|
import { HlmScrollArea } from 'src/app/core/base-components/scroll-area/src';
|
||||||
import { useTicketLane } from 'src/app/core/tickets/tickets.queries';
|
import { useTicketLane } from 'src/app/core/tickets/tickets.queries';
|
||||||
import {
|
import {
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketValidationState
|
TicketFilterArgs,
|
||||||
|
TicketValidationState,
|
||||||
|
Tour,
|
||||||
} from '../../../core/data-access/graphql/generated/generated';
|
} from '../../../core/data-access/graphql/generated/generated';
|
||||||
import { ActivityService } from '../../../core/services/activity.service';
|
import { ActivityService } from '../../../core/services/activity.service';
|
||||||
import { TicketItemComponent } from '../ticket-item/ticket-item.component';
|
import { TicketItemComponent } from '../ticket-item/ticket-item.component';
|
||||||
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
|
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
|
||||||
|
import { TicketsService } from '../tickets.service';
|
||||||
import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
|
import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
|
||||||
|
import { PaginatedResult, TicketListItemDto } from '@avicenna/shared-dtos';
|
||||||
|
import { QueryClient } from '@tanstack/angular-query-experimental';
|
||||||
|
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -39,15 +47,18 @@ import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
|
|||||||
:host {
|
:host {
|
||||||
box-shadow: var(--mat-sys-level1);
|
box-shadow: var(--mat-sys-level1);
|
||||||
background-color: var(--mat-sys-surface-container-low);
|
background-color: var(--mat-sys-surface-container-low);
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr auto;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
TicketLaneHeaderComponent,
|
TicketLaneHeaderComponent,
|
||||||
TicketItemComponent,
|
TicketItemComponent,
|
||||||
|
AsyncPipe,
|
||||||
ScrollingModule,
|
ScrollingModule,
|
||||||
|
HlmScrollArea,
|
||||||
HlmPaginationImports,
|
HlmPaginationImports,
|
||||||
|
DialogCloseDirective,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TicketLaneComponent {
|
export class TicketLaneComponent {
|
||||||
@@ -60,7 +71,7 @@ export class TicketLaneComponent {
|
|||||||
ticketsQuery = useTicketLane(this.stage, this.page);
|
ticketsQuery = useTicketLane(this.stage, this.page);
|
||||||
|
|
||||||
hasNextPage = computed(() => {
|
hasNextPage = computed(() => {
|
||||||
const data: PaginatedResult<TicketListItemDto> | undefined = this.ticketsQuery.data();
|
const data: PaginatedResult<TicketListItemDto> = this.ticketsQuery.data();
|
||||||
if (!data) return false;
|
if (!data) return false;
|
||||||
return this.page() < data.meta.totalPages;
|
return this.page() < data.meta.totalPages;
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-8
@@ -26,14 +26,14 @@ export class TicketStateInfoCardComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.isLoading.set(true);
|
this.isLoading.set(true);
|
||||||
// this.service
|
this.service
|
||||||
// .fetchTicketStateKpi(this.ticketState())
|
.fetchTicketStateKpi(this.ticketState())
|
||||||
// .pipe(
|
.pipe(
|
||||||
// tap((res) => this.metrics.set(res.additional)),
|
tap((res) => this.metrics.set(res.additional)),
|
||||||
// tap((res) => this.kpi.set(res)),
|
tap((res) => this.kpi.set(res)),
|
||||||
// tap(() => this.isLoading.set(false))
|
tap(() => this.isLoading.set(false))
|
||||||
// )
|
)
|
||||||
// .subscribe();
|
.subscribe();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
-51
@@ -6,7 +6,6 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { TicketDetailDto } from '@avicenna/shared-dtos';
|
import { TicketDetailDto } from '@avicenna/shared-dtos';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
@@ -24,8 +23,6 @@ import {
|
|||||||
lucideMapPin,
|
lucideMapPin,
|
||||||
lucideRepeat,
|
lucideRepeat,
|
||||||
lucideShieldCheck,
|
lucideShieldCheck,
|
||||||
lucideSquare,
|
|
||||||
lucideSquareCheck,
|
|
||||||
lucideStethoscope,
|
lucideStethoscope,
|
||||||
lucideTriangleAlert,
|
lucideTriangleAlert,
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
@@ -44,10 +41,6 @@ import { environment } from 'src/environments/environment';
|
|||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { TicketApprovalComponent } from 'src/app/core/tickets/components/ticket-approval-component/ticket-approval.component';
|
import { TicketApprovalComponent } from 'src/app/core/tickets/components/ticket-approval-component/ticket-approval.component';
|
||||||
import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components/ticket-validation-errors/ticket-validation-errors.component';
|
import { TicketValidationErrorsComponent } from 'src/app/core/tickets/components/ticket-validation-errors/ticket-validation-errors.component';
|
||||||
import { TicketActionsService } from 'src/app/core/tickets/ticket-actions.service';
|
|
||||||
import { HotToastService } from '@ngxpert/hot-toast';
|
|
||||||
import { ticketKeys } from 'src/app/core/tickets/ticket.keys';
|
|
||||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -176,9 +169,9 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
|
|||||||
<section
|
<section
|
||||||
hlmCard
|
hlmCard
|
||||||
class="p-4 flex flex-col justify-between"
|
class="p-4 flex flex-col justify-between"
|
||||||
[class.border-l-4]="!ticket().transportDocument.isDocumentPresent"
|
[class.border-l-4]="!ticket().transportDocument.isUploaded"
|
||||||
[class.border-l-destructive]="
|
[class.border-l-destructive]="
|
||||||
!ticket().transportDocument.isDocumentPresent
|
!ticket().transportDocument.isUploaded
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
@@ -198,23 +191,27 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
|
|||||||
<p
|
<p
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
[class.text-destructive]="
|
[class.text-destructive]="
|
||||||
!ticket().transportDocument.isDocumentPresent
|
!ticket().transportDocument.isUploaded
|
||||||
"
|
"
|
||||||
[class.text-muted-foreground]="
|
[class.text-muted-foreground]="
|
||||||
ticket().transportDocument.isDocumentPresent
|
ticket().transportDocument.isUploaded
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ ticket().transportDocument.label }}
|
@if (ticket().transportDocument.isUploaded) {
|
||||||
|
Bereits hochgeladen
|
||||||
|
} @else {
|
||||||
|
Dokument erforderlich
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (ticket().transportDocument.digitalDownloadLink) {
|
@if (ticket().transportDocument.isUploaded) {
|
||||||
<a
|
<a
|
||||||
hlmBtn
|
hlmBtn
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
[href]="ticket().transportDocument.digitalDownloadLink"
|
[href]="ticket().transportDocument.link"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-green-600 hover:bg-green-50"
|
class="text-green-600 hover:bg-green-50"
|
||||||
>
|
>
|
||||||
@@ -223,26 +220,14 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (!ticket().transportDocument.isUploaded) {
|
||||||
<div class="mt-2 pt-2 border-t border-border/50">
|
<div class="mt-2 pt-2 border-t border-border/50">
|
||||||
@if (ticket().transportDocument.actions.receivePhysical.isPossible) {
|
|
||||||
<button
|
|
||||||
hlmBtn
|
|
||||||
variant="outline"
|
|
||||||
class="w-full gap-2 text-green-700 hover:text-green-800 hover:bg-green-50 border-green-200 mb-4"
|
|
||||||
(click)="receivePhysical()"
|
|
||||||
[disabled]="isSubmitting()"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideSquareCheck" size="xs" />
|
|
||||||
{{ ticket().transportDocument.actions.receivePhysical.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
@if (ticket().transportDocument.actions.upload.isPossible) {
|
|
||||||
<app-transport-document-upload
|
<app-transport-document-upload
|
||||||
[ticketId]="ticket().id"
|
[ticketId]="ticket().id"
|
||||||
(uploaded)="uploadedAction.emit()"
|
(uploaded)="uploadedAction.emit()"
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
<app-ticket-approval
|
<app-ticket-approval
|
||||||
[ticketId]="ticket().id"
|
[ticketId]="ticket().id"
|
||||||
@@ -328,7 +313,7 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
|
|||||||
lucideShieldCheck,
|
lucideShieldCheck,
|
||||||
lucideCalendarClock,
|
lucideCalendarClock,
|
||||||
lucideBiohazard,
|
lucideBiohazard,
|
||||||
lucideSquareCheck,
|
|
||||||
lucideArrowRight,
|
lucideArrowRight,
|
||||||
lucideCalendar,
|
lucideCalendar,
|
||||||
lucideMapPin,
|
lucideMapPin,
|
||||||
@@ -356,13 +341,9 @@ import { QueryClient } from '@tanstack/angular-query-experimental';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TicketOverviewComponent {
|
export class TicketOverviewComponent {
|
||||||
private toast = inject(HotToastService);
|
http = inject(HttpClient);
|
||||||
private queryClient = inject(QueryClient);
|
|
||||||
private actions = inject(TicketActionsService);
|
|
||||||
|
|
||||||
ticket = input.required<TicketDetailDto>();
|
ticket = input.required<TicketDetailDto>();
|
||||||
uploadedAction = output<void>({ alias: 'uploaded' });
|
uploadedAction = output<void>({ alias: 'uploaded' });
|
||||||
isSubmitting = signal(false);
|
|
||||||
|
|
||||||
ApprovalState = ApprovalState;
|
ApprovalState = ApprovalState;
|
||||||
stageVariant = computed(() => {
|
stageVariant = computed(() => {
|
||||||
@@ -376,24 +357,18 @@ export class TicketOverviewComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async receivePhysical() {
|
sendApprovalRequest() {
|
||||||
this.isSubmitting.set(true);
|
console.log(`approval request not implemented`);
|
||||||
try {
|
|
||||||
await this.actions.receivePhysicalTransportDocument(this.ticket().id);
|
|
||||||
this.toast.success('Eingang des Transportscheins erfasst');
|
|
||||||
this.refreshData();
|
|
||||||
} catch (error) {
|
|
||||||
this.toast.error('Fehler beim Sendern der Anfrage');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
this.isSubmitting.set(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshData(): void {
|
overrideApprovalRequirement() {
|
||||||
this.uploadedAction.emit();
|
console.log(`request not implemented`);
|
||||||
this.queryClient.invalidateQueries({
|
|
||||||
queryKey: ticketKeys.lists(),
|
const url = `${environment.api.baseUrl}/tickets/${
|
||||||
});
|
this.ticket().id
|
||||||
|
}/approval/override`;
|
||||||
|
lastValueFrom(this.http.post(url, {})).then(() =>
|
||||||
|
this.uploadedAction.emit(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
@if (ticketQuery.data(); as ticket) {
|
@if (ticketQuery.data(); as ticket) {
|
||||||
<h3 class="py-2 px-3 mb-0!">
|
<h3 class="py-2 px-3 !mb-0">
|
||||||
{{ ticket.patient.name}}
|
{{ ticket.patient.name}}
|
||||||
</h3>
|
</h3>
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<button hlmTabsTrigger="history">Verlauf</button>
|
<button hlmTabsTrigger="history">Verlauf</button>
|
||||||
</hlm-tabs-list> -->
|
</hlm-tabs-list> -->
|
||||||
|
|
||||||
<div hlmTabsContent="overview" class="overflow-y-auto max-h-[calc(85vh-8rem)] pr-1">
|
<div hlmTabsContent="overview">
|
||||||
@if (ticketQuery.data(); as ticket) {
|
@if (ticketQuery.data(); as ticket) {
|
||||||
<dks-ticket-overview [ticket]="ticket" (uploaded)="refreshTicket()"/>
|
<dks-ticket-overview [ticket]="ticket" (uploaded)="refreshTicket()"/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { MatIconModule } from '@angular/material/icon';
|
|||||||
import { DialogService } from '@ngneat/dialog';
|
import { DialogService } from '@ngneat/dialog';
|
||||||
import { TicketValidationState } from '../../core/data-access/graphql/generated/generated';
|
import { TicketValidationState } from '../../core/data-access/graphql/generated/generated';
|
||||||
import { TicketLaneComponent } from './ticket-lane/ticket-lane.component';
|
import { TicketLaneComponent } from './ticket-lane/ticket-lane.component';
|
||||||
|
import { TicketStateInfoCardComponent } from './ticket-state-info-card/ticket-state-info-card.component';
|
||||||
import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-search-dialog.component';
|
import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-search-dialog.component';
|
||||||
|
import { BrnAlertDialogImports } from '@spartan-ng/brain/alert-dialog';
|
||||||
|
import { HlmAlertDialogImports } from '@spartan-ng/helm/alert-dialog';
|
||||||
|
import { HlmButton } from '@spartan-ng/helm/button';
|
||||||
|
import { BrnDialogImports } from '@spartan-ng/brain/dialog';
|
||||||
|
import { HlmDialogImports } from '@spartan-ng/helm/dialog';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
selector: 'dks-tickets-page',
|
selector: 'dks-tickets-page',
|
||||||
template: `<section class="h-full flex flex-col p-4 md:p-6 gap-4 md:gap-6">
|
template: `<section class="h-full p-6 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div class="col-span-1 md:col-span-4 flex justify-end items-center">
|
<div class="col-span-1 md:col-span-4 flex justify-end items-center">
|
||||||
<button mat-icon-button (click)="showSearchDialog()">
|
<button mat-icon-button (click)="showSearchDialog()">
|
||||||
<mat-icon>search</mat-icon>
|
<mat-icon>search</mat-icon>
|
||||||
@@ -27,45 +33,26 @@ import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-se
|
|||||||
<app-ticket-state-info-card
|
<app-ticket-state-info-card
|
||||||
[ticketState]="TicketState.DocumentsMissing"
|
[ticketState]="TicketState.DocumentsMissing"
|
||||||
></app-ticket-state-info-card> -->
|
></app-ticket-state-info-card> -->
|
||||||
<div
|
|
||||||
class="
|
|
||||||
flex-1
|
|
||||||
min-h-0
|
|
||||||
flex
|
|
||||||
flex-row
|
|
||||||
overflow-x-auto
|
|
||||||
snap-x
|
|
||||||
snap-mandatory
|
|
||||||
gap-4
|
|
||||||
pb-4
|
|
||||||
md:grid
|
|
||||||
md:grid-cols-4
|
|
||||||
md:overflow-visible
|
|
||||||
md:pb-0
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<dks-ticket-lane
|
<dks-ticket-lane
|
||||||
[stage]="'DATA_INCOMPLETE'"
|
[stage]="'DATA_INCOMPLETE'"
|
||||||
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
|
class="overflow-y-auto mat-elevation-z2"
|
||||||
[ticketState]="TicketState.Created"
|
[ticketState]="TicketState.Created"
|
||||||
></dks-ticket-lane>
|
></dks-ticket-lane>
|
||||||
<dks-ticket-lane
|
<dks-ticket-lane
|
||||||
[stage]="'ASSESSMENT'"
|
[stage]="'ASSESSMENT'"
|
||||||
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
|
class="overflow-y-auto mat-elevation-z2"
|
||||||
[ticketState]="TicketState.UserInteractionRequired"
|
[ticketState]="TicketState.UserInteractionRequired"
|
||||||
></dks-ticket-lane>
|
></dks-ticket-lane>
|
||||||
<dks-ticket-lane
|
<dks-ticket-lane
|
||||||
[stage]="'DOCS_PENDING'"
|
[stage]="'DOCS_PENDING'"
|
||||||
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
|
class="overflow-y-auto mat-elevation-z2"
|
||||||
[ticketState]="TicketState.TdUncertain"
|
[ticketState]="TicketState.TdUncertain"
|
||||||
></dks-ticket-lane>
|
></dks-ticket-lane>
|
||||||
<dks-ticket-lane
|
<dks-ticket-lane
|
||||||
[stage]="'APPROVAL'"
|
[stage]="'APPROVAL'"
|
||||||
class="flex-none w-[85vw] h-full snap-center overflow-y-auto mat-elevation-z2 rounded-lg md:w-auto"
|
class="overflow-y-auto mat-elevation-z2"
|
||||||
[ticketState]="TicketState.DocumentsMissing"
|
[ticketState]="TicketState.DocumentsMissing"
|
||||||
></dks-ticket-lane>
|
></dks-ticket-lane>
|
||||||
<div class="w-1 flex-none md:hidden"></div>
|
|
||||||
</div>
|
|
||||||
</section>`,
|
</section>`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
@@ -78,7 +65,13 @@ import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-se
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
imports: [TicketLaneComponent, MatButtonModule, MatIconModule],
|
imports: [
|
||||||
|
TicketStateInfoCardComponent,
|
||||||
|
TicketLaneComponent,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
HlmButton,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TicketsPageComponent {
|
export class TicketsPageComponent {
|
||||||
private dialog = inject(DialogService);
|
private dialog = inject(DialogService);
|
||||||
|
|||||||
+17
-108
@@ -1,11 +1,6 @@
|
|||||||
import { AsyncPipe, DatePipe } from '@angular/common';
|
import { AsyncPipe } from '@angular/common';
|
||||||
import {
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
ChangeDetectionStrategy,
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { DialogService } from '@ngneat/dialog';
|
import { DialogService } from '@ngneat/dialog';
|
||||||
@@ -23,14 +18,6 @@ import {
|
|||||||
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
|
import { TicketViewComponent } from '../ticket-view/ticket-view.component';
|
||||||
import { TicketsService } from '../tickets.service';
|
import { TicketsService } from '../tickets.service';
|
||||||
import { format, isAfter } from 'date-fns';
|
import { format, isAfter } from 'date-fns';
|
||||||
import { useTicketSearch } from 'src/app/core/tickets/tickets.queries';
|
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
||||||
import { HlmIcon } from '@spartan-ng/helm/icon';
|
|
||||||
import {
|
|
||||||
lucideCircleAlert,
|
|
||||||
lucideCalendarClock,
|
|
||||||
lucideCar,
|
|
||||||
} from '@ng-icons/lucide';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `<div class="flex flex-col items-center justify-start p-4">
|
template: `<div class="flex flex-col items-center justify-start p-4">
|
||||||
@@ -38,71 +25,12 @@ import {
|
|||||||
<hr />
|
<hr />
|
||||||
<mat-form-field class="w-full">
|
<mat-form-field class="w-full">
|
||||||
<mat-label>Einsatznummer oder Patient</mat-label>
|
<mat-label>Einsatznummer oder Patient</mat-label>
|
||||||
<input class="w-full" matInput [(ngModel)]="search" name="search" />
|
<input class="w-full" matInput [formControl]="searchCtrl" name="search" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<hr />
|
<hr />
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
@if (searchQuery.isLoading()) {
|
@for (ticket of tickets$ | async; track ticket) {
|
||||||
<p>Suche Tickets ...</p>
|
|
||||||
}
|
|
||||||
@if (searchQuery.data(); as result) {
|
|
||||||
@for (ticket of result; track ticket.id) {
|
|
||||||
<div
|
<div
|
||||||
class="group relative flex flex-col gap-1.5 rounded-lg border bg-card p-3 shadow-sm transition-all hover:bg-accent/5 hover:shadow-md cursor-pointer select-none mb-2"
|
|
||||||
[class.border-destructive]="ticket.isOverdue"
|
|
||||||
[class.bg-destructive/5]="ticket.isOverdue"
|
|
||||||
[class.border-border]="!ticket.isOverdue"
|
|
||||||
(click)="showTicketView(ticket)"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<span
|
|
||||||
class="text-sm font-semibold leading-tight text-foreground line-clamp-2"
|
|
||||||
[class.text-destructive]="ticket.isOverdue"
|
|
||||||
>
|
|
||||||
{{ ticket.patientName }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-1">
|
|
||||||
<div class="flex items-center gap-1.5 text-xs">
|
|
||||||
@if (ticket.nextStart) {
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 transition-colors"
|
|
||||||
[class.text-destructive]="ticket.isOverdue"
|
|
||||||
[class.font-medium]="ticket.isOverdue"
|
|
||||||
[class.text-muted-foreground]="!ticket.isOverdue"
|
|
||||||
>
|
|
||||||
<ng-icon
|
|
||||||
hlmIcon
|
|
||||||
[name]="
|
|
||||||
ticket.isOverdue
|
|
||||||
? 'lucideAlertCircle'
|
|
||||||
: 'lucideCalendarClock'
|
|
||||||
"
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
<span>{{ ticket.nextStart | date: 'dd.MM. HH:mm' }}</span>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<span class="text-muted-foreground/50 italic text-[10px]"
|
|
||||||
>Bereits gefahren</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors"
|
|
||||||
[class.bg-destructive/10]="ticket.isOverdue"
|
|
||||||
[class.text-destructive]="ticket.isOverdue"
|
|
||||||
[class.bg-secondary]="!ticket.isOverdue"
|
|
||||||
[class.text-secondary-foreground]="!ticket.isOverdue"
|
|
||||||
>
|
|
||||||
<ng-icon hlmIcon name="lucideCar" size="xs" />
|
|
||||||
<span>{{ ticket.tourCount }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div
|
|
||||||
class="flex flex-col justify-between items-center py-2 border-b border-gray-200 cursor-pointer"
|
class="flex flex-col justify-between items-center py-2 border-b border-gray-200 cursor-pointer"
|
||||||
(click)="showTicketView(ticket)"
|
(click)="showTicketView(ticket)"
|
||||||
>
|
>
|
||||||
@@ -115,6 +43,7 @@ import {
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- <span class="text-sm text-gray-600">{{ getNextStart(ticket.tours) }}</span> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full flex justify-between">
|
<div class="w-full flex justify-between">
|
||||||
<span>
|
<span>
|
||||||
@@ -127,32 +56,14 @@ import {
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
imports: [
|
imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, AsyncPipe],
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
FormsModule,
|
|
||||||
NgIcon,
|
|
||||||
HlmIcon,
|
|
||||||
DatePipe,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
provideIcons({
|
|
||||||
lucideCircleAlert,
|
|
||||||
lucideCalendarClock,
|
|
||||||
lucideCar,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class TicketsSearchDialogComponent {
|
export class TicketsSearchDialogComponent {
|
||||||
search = signal<string>('');
|
|
||||||
searchQuery = useTicketSearch(this.search);
|
|
||||||
|
|
||||||
private ticketsService = inject(TicketsService);
|
private ticketsService = inject(TicketsService);
|
||||||
private dialog = inject(DialogService);
|
private dialog = inject(DialogService);
|
||||||
|
|
||||||
@@ -161,13 +72,13 @@ export class TicketsSearchDialogComponent {
|
|||||||
debounceTime(200),
|
debounceTime(200),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
filter((val) => !!val && val?.length > 2),
|
filter((val) => !!val && val?.length > 2),
|
||||||
switchMap((query) => this.ticketsService.searchTickets(query as string)),
|
switchMap((query) => this.ticketsService.searchTickets(query as string))
|
||||||
);
|
);
|
||||||
|
|
||||||
showTicketView(ticket: unknown): void {
|
showTicketView(ticket: unknown): void {
|
||||||
const dialogRef = this.dialog.open(TicketViewComponent, {
|
const dialogRef = this.dialog.open(TicketViewComponent, {
|
||||||
data: (ticket as Ticket)?.id as string,
|
data: (ticket as Ticket)?.id as string,
|
||||||
width: '60vw',
|
width: '40vw',
|
||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,21 +89,19 @@ export class TicketsSearchDialogComponent {
|
|||||||
|
|
||||||
getToursLength(ticket: unknown) {
|
getToursLength(ticket: unknown) {
|
||||||
return (ticket as Ticket & { tours: Tour[] }).tours.filter(
|
return (ticket as Ticket & { tours: Tour[] }).tours.filter(
|
||||||
({ check }) => check === 0,
|
({ check }) => check === 0
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
getNextStart(tours: Partial<Tour>[]) {
|
getNextStart(tours: Partial<Tour>[]) {
|
||||||
const sortedStartDates = [
|
const sortedStartDates = [...tours.map(({startDate}) => new Date(startDate))].sort((a, b) => a.getTime() - b.getTime())
|
||||||
...tours.map(({ startDate }) => new Date(startDate)),
|
for(let i = 0; i < sortedStartDates.length; i++) {
|
||||||
].sort((a, b) => a.getTime() - b.getTime());
|
const currentDate = sortedStartDates.at(i)
|
||||||
for (let i = 0; i < sortedStartDates.length; i++) {
|
if(currentDate && isAfter(currentDate, new Date())) {
|
||||||
const currentDate = sortedStartDates.at(i);
|
return format(currentDate, 'HH:mm | dd.MM.yy')
|
||||||
if (currentDate && isAfter(currentDate, new Date())) {
|
|
||||||
return format(currentDate, 'HH:mm | dd.MM.yy');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const latestDate = sortedStartDates.at(-1);
|
const latestDate = sortedStartDates.at(-1);
|
||||||
return latestDate ? format(latestDate, 'HH:mm | dd.MM.yy') : 'No Tours';
|
return latestDate ? format(latestDate, 'HH:mm | dd.MM.yy') : 'No Tours'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HotToastService } from '@ngxpert/hot-toast';
|
import { HotToastService } from '@ngxpert/hot-toast';
|
||||||
import {
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
|
retry,
|
||||||
shareReplay,
|
shareReplay,
|
||||||
startWith,
|
startWith,
|
||||||
Subject,
|
Subject,
|
||||||
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
|
throwError,
|
||||||
timer,
|
timer,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {
|
import {
|
||||||
|
GetTicketsGQL,
|
||||||
|
GetTicketsQuery,
|
||||||
|
GetTicketStateMetaGQL,
|
||||||
|
GetTicketStateMetaQuery,
|
||||||
SearchTicketsGQL,
|
SearchTicketsGQL,
|
||||||
SearchTicketsQuery,
|
SearchTicketsQuery,
|
||||||
|
SendApprovalRequestGQL,
|
||||||
|
SetDocumentInfoGQL,
|
||||||
|
SetDocumentInfoMutationVariables,
|
||||||
|
TicketFilterArgs,
|
||||||
|
TicketStateKpiGQL,
|
||||||
|
TicketStateKpiQuery,
|
||||||
|
TicketValidationState,
|
||||||
} from '../../core/data-access/graphql/generated/generated';
|
} from '../../core/data-access/graphql/generated/generated';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -27,25 +43,164 @@ export class TicketsService {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
shareReplay(1),
|
shareReplay(1),
|
||||||
startWith(undefined),
|
startWith(undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
updateTickets$ = new Subject<void>();
|
updateTickets$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly getTicketsGql: GetTicketsGQL,
|
||||||
|
private readonly getTicketStateMetaGql: GetTicketStateMetaGQL,
|
||||||
private readonly searchTicketsGql: SearchTicketsGQL,
|
private readonly searchTicketsGql: SearchTicketsGQL,
|
||||||
private readonly toast: HotToastService,
|
private readonly setDocumentInfoGql: SetDocumentInfoGQL,
|
||||||
) {}
|
private readonly ticketStateKpiGql: TicketStateKpiGQL,
|
||||||
|
private readonly sendApprovalRequestGql: SendApprovalRequestGQL,
|
||||||
|
private readonly toast: HotToastService
|
||||||
|
) {
|
||||||
|
this.updateTickets$.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
sendApprovalRequest(ticketId: string, email: string) {
|
||||||
|
return this.sendApprovalRequestGql
|
||||||
|
.mutate({
|
||||||
|
email,
|
||||||
|
ticketId,
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
tap((mutationResult) => {
|
||||||
|
if (mutationResult.errors) {
|
||||||
|
this.toast.error(
|
||||||
|
'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.toast.success(
|
||||||
|
'Die Genehmigungsanfrage wird versendet.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTicketsByState(
|
||||||
|
state: TicketValidationState,
|
||||||
|
cursor: string,
|
||||||
|
filters: TicketFilterArgs,
|
||||||
|
take = 20
|
||||||
|
): Observable<GetTicketsQuery['tickets']> {
|
||||||
|
const ticketsQuery = this.getTicketsGql.watch({
|
||||||
|
state,
|
||||||
|
cursor,
|
||||||
|
filters,
|
||||||
|
take,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketsUpdate = combineLatest([
|
||||||
|
this.ticketsUpdated$,
|
||||||
|
this.updateTickets$,
|
||||||
|
]).pipe(
|
||||||
|
switchMap(() => ticketsQuery.refetch()),
|
||||||
|
startWith(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
retry({ delay: 1000, count: 3 }),
|
||||||
|
map(([updatedData, firstData]) =>
|
||||||
|
updatedData ? updatedData : firstData
|
||||||
|
),
|
||||||
|
map((response) => response.data.tickets)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTicketStateMeta(
|
||||||
|
state: TicketValidationState
|
||||||
|
): Observable<GetTicketStateMetaQuery['ticketStateMeta']> {
|
||||||
|
const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketsUpdate = combineLatest([
|
||||||
|
this.ticketsUpdated$,
|
||||||
|
this.updateTickets$,
|
||||||
|
]).pipe(
|
||||||
|
switchMap(() => ticketStateMetaQuery.refetch()),
|
||||||
|
startWith(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
return combineLatest([
|
||||||
|
ticketsUpdate,
|
||||||
|
ticketStateMetaQuery.valueChanges,
|
||||||
|
]).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
retry({ delay: 1000, count: 3 }),
|
||||||
|
map(([updatedData, firstData]) =>
|
||||||
|
updatedData ? updatedData : firstData
|
||||||
|
),
|
||||||
|
map((response) => response.data.ticketStateMeta)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTicketStateKpi(
|
||||||
|
state: TicketValidationState
|
||||||
|
): Observable<TicketStateKpiQuery['ticketStateKpi']> {
|
||||||
|
const ticketStateKpiQuery = this.ticketStateKpiGql.watch({
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketsUpdate = combineLatest([
|
||||||
|
this.ticketsUpdated$,
|
||||||
|
this.updateTickets$,
|
||||||
|
]).pipe(
|
||||||
|
switchMap(() => ticketStateKpiQuery.refetch()),
|
||||||
|
startWith(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
return combineLatest([
|
||||||
|
ticketsUpdate,
|
||||||
|
ticketStateKpiQuery.valueChanges,
|
||||||
|
]).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(error);
|
||||||
|
return throwError(() => error);
|
||||||
|
}),
|
||||||
|
retry({ delay: 1000, count: 3 }),
|
||||||
|
map(([updatedData, firstData]) =>
|
||||||
|
updatedData ? updatedData : firstData
|
||||||
|
),
|
||||||
|
map((response) => response.data.ticketStateKpi)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
searchTickets(
|
searchTickets(
|
||||||
query: string,
|
query: string
|
||||||
): Observable<SearchTicketsQuery['searchTickets'] | undefined> {
|
): Observable<SearchTicketsQuery['searchTickets']> {
|
||||||
return this.searchTicketsGql
|
return this.searchTicketsGql
|
||||||
.fetch({
|
.fetch({ query })
|
||||||
variables: {
|
.pipe(map((response) => response.data.searchTickets));
|
||||||
query,
|
}
|
||||||
},
|
|
||||||
|
setDocumentInfo(
|
||||||
|
ticketId: string,
|
||||||
|
{ approval, tdLocation }: Omit<SetDocumentInfoMutationVariables, 'ticketId'>
|
||||||
|
) {
|
||||||
|
if ((approval as any)?.['__typename'])
|
||||||
|
delete (approval as any)['__typename'];
|
||||||
|
|
||||||
|
return this.setDocumentInfoGql
|
||||||
|
.mutate({
|
||||||
|
ticketId,
|
||||||
|
approval,
|
||||||
|
tdLocation,
|
||||||
})
|
})
|
||||||
.pipe(map((response) => response.data?.searchTickets));
|
.pipe(
|
||||||
|
map((res) => res.data?.setDocumentInfo),
|
||||||
|
tap(() => this.updateTickets$.next())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -17,8 +16,16 @@
|
|||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "ES2022",
|
"module": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"dom",
|
||||||
|
"esnext.asynciterable"
|
||||||
|
],
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"@angular/*": [
|
||||||
|
"../../node_modules/@angular/*"
|
||||||
|
],
|
||||||
"@tanstack/*": [
|
"@tanstack/*": [
|
||||||
"../../node_modules/@tanstack/*"
|
"../../node_modules/@tanstack/*"
|
||||||
],
|
],
|
||||||
@@ -81,9 +88,6 @@
|
|||||||
],
|
],
|
||||||
"@spartan-ng/helm/textarea": [
|
"@spartan-ng/helm/textarea": [
|
||||||
"./src/app/core/base-components/textarea/src/index.ts"
|
"./src/app/core/base-components/textarea/src/index.ts"
|
||||||
],
|
|
||||||
"@spartan-ng/helm/skeleton": [
|
|
||||||
"./src/app/core/base-components/skeleton/src/index.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ COPY package*.json .
|
|||||||
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
|
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
|
||||||
COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
|
COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
|
||||||
|
|
||||||
RUN npm ci --verbose
|
RUN npm ci
|
||||||
|
|
||||||
COPY packages/shared-dtos ./packages/shared-dtos/
|
COPY packages/shared-dtos ./packages/shared-dtos/
|
||||||
COPY data-hub/data-connector ./data-hub/data-connector/
|
COPY data-hub/data-connector ./data-hub/data-connector/
|
||||||
@@ -33,7 +33,6 @@ FROM node:20.15.1-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/builder/node_modules ./node_modules
|
COPY --from=builder /app/builder/node_modules ./node_modules
|
||||||
COPY --from=builder /app/builder/packages ./packages
|
|
||||||
COPY --from=builder /app/builder/data-hub/data-connector/prisma ./prisma
|
COPY --from=builder /app/builder/data-hub/data-connector/prisma ./prisma
|
||||||
COPY --from=builder /app/builder/data-hub/data-connector/dist ./dist
|
COPY --from=builder /app/builder/data-hub/data-connector/dist ./dist
|
||||||
COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh .
|
COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh .
|
||||||
|
|||||||
@@ -24,8 +24,7 @@
|
|||||||
"seed": "ts-node prisma/seed/seed.ts"
|
"seed": "ts-node prisma/seed/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^5.0.0",
|
"@apollo/server": "^4.12.0",
|
||||||
"@as-integrations/express5": "^1.1.2",
|
|
||||||
"@avicenna/shared-dtos": "^1.0.0",
|
"@avicenna/shared-dtos": "^1.0.0",
|
||||||
"@faker-js/faker": "^9.6.0",
|
"@faker-js/faker": "^9.6.0",
|
||||||
"@hyperdx/node-logger": "^0.4.0",
|
"@hyperdx/node-logger": "^0.4.0",
|
||||||
@@ -41,7 +40,6 @@
|
|||||||
"@nestjs/graphql": "^13.0.4",
|
"@nestjs/graphql": "^13.0.4",
|
||||||
"@nestjs/platform-express": "^11.0.15",
|
"@nestjs/platform-express": "^11.0.15",
|
||||||
"@nestjs/schedule": "^5.0.1",
|
"@nestjs/schedule": "^5.0.1",
|
||||||
"@nestjs/swagger": "^11.2.5",
|
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.60.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.60.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
||||||
|
|||||||
+7
-4
@@ -2,6 +2,9 @@
|
|||||||
Warnings:
|
Warnings:
|
||||||
|
|
||||||
- A unique constraint covering the columns `[tourId,ticketId]` on the table `TourFile` will be added. If there are existing duplicate values, this will fail.
|
- A unique constraint covering the columns `[tourId,ticketId]` on the table `TourFile` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `approvalRequirement` to the `Ticket` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `approvalStatus` to the `Ticket` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `stage` to the `Ticket` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
*/
|
*/
|
||||||
-- DropIndex
|
-- DropIndex
|
||||||
@@ -22,14 +25,14 @@ ADD COLUMN "approvalIsOverriden" BOOLEAN NOT NULL DEFAULT false,
|
|||||||
ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3),
|
ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3),
|
||||||
ADD COLUMN "approvalPreInquiryMessageId" TEXT,
|
ADD COLUMN "approvalPreInquiryMessageId" TEXT,
|
||||||
ADD COLUMN "approvalRecipient" TEXT,
|
ADD COLUMN "approvalRecipient" TEXT,
|
||||||
ADD COLUMN "approvalReminderCount" INTEGER DEFAULT 0,
|
ADD COLUMN "approvalReminderCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
ADD COLUMN "approvalRequestedAt" TIMESTAMP(3),
|
ADD COLUMN "approvalRequestedAt" TIMESTAMP(3),
|
||||||
ADD COLUMN "approvalRequirement" TEXT DEFAULT 'UNKNOWN',
|
ADD COLUMN "approvalRequirement" TEXT NOT NULL,
|
||||||
ADD COLUMN "approvalStatus" TEXT DEFAULT 'NOT_STARTED',
|
ADD COLUMN "approvalStatus" TEXT NOT NULL,
|
||||||
ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
|
ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
|
||||||
ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
|
ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
|
||||||
ADD COLUMN "patientId" UUID,
|
ADD COLUMN "patientId" UUID,
|
||||||
ADD COLUMN "stage" TEXT DEFAULT 'DRAFT',
|
ADD COLUMN "stage" TEXT NOT NULL,
|
||||||
ADD COLUMN "updatedAt" TIMESTAMP(3),
|
ADD COLUMN "updatedAt" TIMESTAMP(3),
|
||||||
ALTER COLUMN "id" DROP DEFAULT,
|
ALTER COLUMN "id" DROP DEFAULT,
|
||||||
ALTER COLUMN "currentState" DROP NOT NULL;
|
ALTER COLUMN "currentState" DROP NOT NULL;
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Ticket" ADD COLUMN "lastStageBeforeClosed" TEXT;
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Tour" ADD COLUMN "optimizationInMinutes" INTEGER NOT NULL DEFAULT 0;
|
|
||||||
@@ -422,7 +422,6 @@ model Tour {
|
|||||||
createdAt DateTime?
|
createdAt DateTime?
|
||||||
updatedAt DateTime?
|
updatedAt DateTime?
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
optimizationInMinutes Int @default(0)
|
|
||||||
Ticket Ticket? @relation(fields: [ticketId], references: [id])
|
Ticket Ticket? @relation(fields: [ticketId], references: [id])
|
||||||
ticketId String? @db.Uuid
|
ticketId String? @db.Uuid
|
||||||
}
|
}
|
||||||
@@ -489,13 +488,13 @@ enum ApprovalState {
|
|||||||
model Ticket {
|
model Ticket {
|
||||||
id String @id @db.Uuid
|
id String @id @db.Uuid
|
||||||
patientId String? @db.Uuid
|
patientId String? @db.Uuid
|
||||||
stage String? @default("DRAFT")
|
stage String
|
||||||
|
|
||||||
approvalRequirement String? @default("UNKNOWN")
|
approvalRequirement String
|
||||||
approvalStatus String? @default("NOT_STARTED")
|
approvalStatus String
|
||||||
approvalRequestedAt DateTime?
|
approvalRequestedAt DateTime?
|
||||||
approvalLastInteractionAt DateTime?
|
approvalLastInteractionAt DateTime?
|
||||||
approvalReminderCount Int? @default(0)
|
approvalReminderCount Int @default(0)
|
||||||
approvalRecipient String?
|
approvalRecipient String?
|
||||||
approvalPreInquiryMessageId String?
|
approvalPreInquiryMessageId String?
|
||||||
approvalFullRequestMessageId String?
|
approvalFullRequestMessageId String?
|
||||||
@@ -503,7 +502,6 @@ model Ticket {
|
|||||||
|
|
||||||
hasPhysicalTransportDocument Boolean @default(false)
|
hasPhysicalTransportDocument Boolean @default(false)
|
||||||
hasDigitalTransportDocument Boolean @default(false)
|
hasDigitalTransportDocument Boolean @default(false)
|
||||||
lastStageBeforeClosed String?
|
|
||||||
|
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { FeatCalculationModule } from './modules/feat-calculation/feat-calculati
|
|||||||
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
|
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
|
||||||
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
||||||
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
||||||
import { AppPlaygroundModule } from './modules/app-playground/app-playground.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -60,7 +59,7 @@ import { AppPlaygroundModule } from './modules/app-playground/app-playground.mod
|
|||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
GraphQLModule.forRoot<ApolloDriverConfig>({
|
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||||
driver: ApolloDriver,
|
driver: ApolloDriver,
|
||||||
graphiql: true,
|
playground: true,
|
||||||
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
|
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
|
||||||
sortSchema: true,
|
sortSchema: true,
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
@@ -78,7 +77,6 @@ import { AppPlaygroundModule } from './modules/app-playground/app-playground.mod
|
|||||||
ApplicationControlCenterModule,
|
ApplicationControlCenterModule,
|
||||||
ApplicationTicketSystemModule,
|
ApplicationTicketSystemModule,
|
||||||
AppHomeModule,
|
AppHomeModule,
|
||||||
AppPlaygroundModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService, Logger],
|
providers: [AppService, Logger],
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export interface PrismaServiceOptions {
|
|||||||
* See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management
|
* See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management
|
||||||
*/
|
*/
|
||||||
explicitConnect?: boolean;
|
explicitConnect?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Prisma middlewares to perform actions before or after db queries.
|
||||||
|
*
|
||||||
|
* See: https://www.prisma.io/docs/concepts/components/prisma-client/middleware
|
||||||
|
*/
|
||||||
|
middlewares?: Array<Prisma.Middleware>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrismaOptionsFactory {
|
export interface PrismaOptionsFactory {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.prismaServiceOptions.middlewares) {
|
||||||
|
this.prismaServiceOptions.middlewares.forEach((middleware) =>
|
||||||
|
this.$use(middleware),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Sadly typescript does not recognize the Prisma Client configuration passed in the super call.
|
Sadly typescript does not recognize the Prisma Client configuration passed in the super call.
|
||||||
As Prisma defines the events generically, based on the configuration, the event type, typescript inferred, is wrong.
|
As Prisma defines the events generically, based on the configuration, the event type, typescript inferred, is wrong.
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ type PlannedTime {
|
|||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
accountingStateMeta(state: AccountingState!): TourStateMeta!
|
accountingStateMeta(state: AccountingState!): TourStateMeta!
|
||||||
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int): PaginatedTour!
|
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
|
||||||
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
|
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
|
||||||
anomaliesProto: [Anomaly!]!
|
anomaliesProto: [Anomaly!]!
|
||||||
anomaly(id: String!): Anomaly!
|
anomaly(id: String!): Anomaly!
|
||||||
|
|||||||
@@ -303,19 +303,18 @@ export type TariffGroup = {
|
|||||||
export type Ticket = {
|
export type Ticket = {
|
||||||
id: string;
|
id: string;
|
||||||
patientId: string | null;
|
patientId: string | null;
|
||||||
stage: Generated<string | null>;
|
stage: string;
|
||||||
approvalRequirement: Generated<string | null>;
|
approvalRequirement: string;
|
||||||
approvalStatus: Generated<string | null>;
|
approvalStatus: string;
|
||||||
approvalRequestedAt: Timestamp | null;
|
approvalRequestedAt: Timestamp | null;
|
||||||
approvalLastInteractionAt: Timestamp | null;
|
approvalLastInteractionAt: Timestamp | null;
|
||||||
approvalReminderCount: Generated<number | null>;
|
approvalReminderCount: Generated<number>;
|
||||||
approvalRecipient: string | null;
|
approvalRecipient: string | null;
|
||||||
approvalPreInquiryMessageId: string | null;
|
approvalPreInquiryMessageId: string | null;
|
||||||
approvalFullRequestMessageId: string | null;
|
approvalFullRequestMessageId: string | null;
|
||||||
approvalIsOverriden: Generated<boolean>;
|
approvalIsOverriden: Generated<boolean>;
|
||||||
hasPhysicalTransportDocument: Generated<boolean>;
|
hasPhysicalTransportDocument: Generated<boolean>;
|
||||||
hasDigitalTransportDocument: Generated<boolean>;
|
hasDigitalTransportDocument: Generated<boolean>;
|
||||||
lastStageBeforeClosed: string | null;
|
|
||||||
updatedAt: Timestamp | null;
|
updatedAt: Timestamp | null;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
currentState: TicketValidationState | null;
|
currentState: TicketValidationState | null;
|
||||||
@@ -387,7 +386,6 @@ export type Tour = {
|
|||||||
createdAt: Timestamp | null;
|
createdAt: Timestamp | null;
|
||||||
updatedAt: Timestamp | null;
|
updatedAt: Timestamp | null;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
optimizationInMinutes: Generated<number>;
|
|
||||||
ticketId: string | null;
|
ticketId: string | null;
|
||||||
};
|
};
|
||||||
export type TourFile = {
|
export type TourFile = {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class MailService {
|
|||||||
|
|
||||||
return await this.mailerService.sendMail({
|
return await this.mailerService.sendMail({
|
||||||
to: recipient,
|
to: recipient,
|
||||||
bcc: 'info@avicenna.hamburg',
|
// bcc: 'info@avicenna.hamburg',
|
||||||
subject: `Antrag auf Kostenübernahme`,
|
subject: `Antrag auf Kostenübernahme`,
|
||||||
template: 'genehmigungsanfrage',
|
template: 'genehmigungsanfrage',
|
||||||
headers: this.getThreadingHeaders(replyToId),
|
headers: this.getThreadingHeaders(replyToId),
|
||||||
@@ -62,7 +62,7 @@ export class MailService {
|
|||||||
|
|
||||||
return await this.mailerService.sendMail({
|
return await this.mailerService.sendMail({
|
||||||
to: recipient,
|
to: recipient,
|
||||||
bcc: 'info@avicenna.hamburg',
|
// bcc: 'info@avicenna.hamburg',
|
||||||
headers: this.getThreadingHeaders(replyToId),
|
headers: this.getThreadingHeaders(replyToId),
|
||||||
subject: `Antrag auf Kostenübernahme – Transport am ${params.transportDate} für ${params.patientName}`,
|
subject: `Antrag auf Kostenübernahme – Transport am ${params.transportDate} für ${params.patientName}`,
|
||||||
template: 'genehmigung-vorab-anfrage',
|
template: 'genehmigung-vorab-anfrage',
|
||||||
@@ -79,7 +79,6 @@ export class MailService {
|
|||||||
|
|
||||||
return await this.mailerService.sendMail({
|
return await this.mailerService.sendMail({
|
||||||
to: recipient,
|
to: recipient,
|
||||||
bcc: 'info@avicenna.hamburg',
|
|
||||||
subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading
|
subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading
|
||||||
template: 'genehmigung-reminder',
|
template: 'genehmigung-reminder',
|
||||||
headers: this.getThreadingHeaders(replyToId),
|
headers: this.getThreadingHeaders(replyToId),
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { ArgsType, Field, InputType } from '@nestjs/graphql';
|
import {
|
||||||
|
ArgsType,
|
||||||
|
Field,
|
||||||
|
Float,
|
||||||
|
InputType,
|
||||||
|
Int,
|
||||||
|
ObjectType,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
import { PaginationArgs } from 'src/core/base/pagination.input';
|
import { PaginationArgs } from 'src/core/base/pagination.input';
|
||||||
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
|
|
||||||
import { AccountingState } from './accounting-state';
|
import { AccountingState } from './accounting-state';
|
||||||
|
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class AccountingFilterArgs implements TourAccountingFilterDto {
|
export class AccountingFilterArgs implements TourAccountingFilterDto {
|
||||||
@@ -20,3 +27,15 @@ export class AccountingTourArgs extends PaginationArgs {
|
|||||||
@Field(() => AccountingFilterArgs, { nullable: true })
|
@Field(() => AccountingFilterArgs, { nullable: true })
|
||||||
filters!: AccountingFilterArgs;
|
filters!: AccountingFilterArgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('TourStateMeta')
|
||||||
|
export class TourStateMetaObjectType {
|
||||||
|
@Field(() => Int)
|
||||||
|
count!: number;
|
||||||
|
@Field(() => Float, { nullable: true })
|
||||||
|
revenueSum?: number;
|
||||||
|
@Field(() => Float, { nullable: true })
|
||||||
|
positiveRevenueDeviation?: number;
|
||||||
|
@Field(() => Float, { nullable: true })
|
||||||
|
negativeRevenueDeviation?: number;
|
||||||
|
}
|
||||||
|
|||||||
-64
@@ -1,64 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
|
||||||
import { DB } from 'src/core/database/types';
|
|
||||||
import { AccountingState } from './accounting-state';
|
|
||||||
import {
|
|
||||||
AccountingListDto,
|
|
||||||
PaginatedResult,
|
|
||||||
TicketStage,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
|
||||||
import { Tour } from '@prisma/client';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AccountingQueryService {
|
|
||||||
constructor(private readonly tourService: TourService) {}
|
|
||||||
|
|
||||||
async toursByState(
|
|
||||||
state: AccountingState,
|
|
||||||
page: number,
|
|
||||||
limit: number = 10,
|
|
||||||
): Promise<PaginatedResult<AccountingListDto>> {
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
const { tours, total } = await this.tourService.findByAccountingState(
|
|
||||||
state,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: tours.map((tour) => this.toAccountingListDto(tour)),
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private toAccountingListDto(
|
|
||||||
tour: Tour & { stage: string },
|
|
||||||
): AccountingListDto {
|
|
||||||
return {
|
|
||||||
tourId: tour.id,
|
|
||||||
operationId: tour.operationId,
|
|
||||||
ordinanceType: tour.ordinanceType,
|
|
||||||
startDate: tour.startDate,
|
|
||||||
revenue: tour.revenue,
|
|
||||||
|
|
||||||
isTicketOpen: [
|
|
||||||
TicketStage.DRAFT,
|
|
||||||
TicketStage.DATA_INCOMPLETE,
|
|
||||||
TicketStage.ASSESSMENT_PENDING,
|
|
||||||
TicketStage.APPROVAL_IN_PROGRESS,
|
|
||||||
TicketStage.DOCUMENTS_PENDING,
|
|
||||||
].includes(tour.stage as TicketStage),
|
|
||||||
hasRevenueDeviation:
|
|
||||||
Math.abs(tour.revenue - tour.revenueDispoLive) > 0.01,
|
|
||||||
hasOptimizationPotential: tour.optimizationInMinutes !== 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-21
@@ -1,22 +1,12 @@
|
|||||||
import {
|
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||||
Args,
|
|
||||||
Int,
|
|
||||||
Parent,
|
|
||||||
Query,
|
|
||||||
ResolveField,
|
|
||||||
Resolver,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
|
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
|
||||||
import {
|
import {
|
||||||
AccountingFilterArgs
|
AccountingTourArgs,
|
||||||
|
TourStateMetaObjectType,
|
||||||
} from './accounting-arg.types';
|
} from './accounting-arg.types';
|
||||||
import { AccountingState } from './accounting-state';
|
import { AccountingState } from './accounting-state';
|
||||||
import { AccountingValidationService } from './accounting-validation.service';
|
import { AccountingValidationService } from './accounting-validation.service';
|
||||||
import {
|
import { PaginatedTourObjectType, TourCostBreakdownObjectType } from './accounting.object-type';
|
||||||
PaginatedTourObjectType,
|
|
||||||
TourCostBreakdownObjectType,
|
|
||||||
TourStateMetaObjectType,
|
|
||||||
} from './accounting.object-type';
|
|
||||||
import { TourObjectType } from './tour.object-type';
|
import { TourObjectType } from './tour.object-type';
|
||||||
|
|
||||||
@Resolver(() => TourObjectType)
|
@Resolver(() => TourObjectType)
|
||||||
@@ -25,13 +15,9 @@ export class AccountingValidationResolver {
|
|||||||
|
|
||||||
@Query(() => PaginatedTourObjectType)
|
@Query(() => PaginatedTourObjectType)
|
||||||
accountingTours(
|
accountingTours(
|
||||||
@Args('state', { type: () => AccountingState }) state: AccountingState,
|
@Args() { state, filters, cursor, take }: AccountingTourArgs,
|
||||||
@Args('filters', { type: () => AccountingFilterArgs, nullable: true })
|
|
||||||
filters: AccountingFilterArgs,
|
|
||||||
@Args('cursor', { nullable: true }) cursor: string,
|
|
||||||
@Args('take', { type: () => Int, nullable: true }) take: number = 10,
|
|
||||||
) {
|
) {
|
||||||
return this.service.paginateToursByState(state, take, cursor);
|
return this.service.paginateToursByState(state, filters, take, cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => TourStateMetaObjectType)
|
@Query(() => TourStateMetaObjectType)
|
||||||
@@ -44,7 +30,7 @@ export class AccountingValidationResolver {
|
|||||||
|
|
||||||
@Query(() => TourCostBreakdownObjectType)
|
@Query(() => TourCostBreakdownObjectType)
|
||||||
tourCostBreakdown(@Args('operationId') operationId: string) {
|
tourCostBreakdown(@Args('operationId') operationId: string) {
|
||||||
return this.service.getTourCostBreakdown(operationId);
|
return this.service.getTourCostBreakdown(operationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => TicketObjectType, { nullable: true })
|
@ResolveField(() => TicketObjectType, { nullable: true })
|
||||||
|
|||||||
+9
-35
@@ -1,18 +1,15 @@
|
|||||||
import {
|
|
||||||
PaginatedResult,
|
|
||||||
PaginatedTourDto,
|
|
||||||
TourDto,
|
|
||||||
TourStateMetaDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decodeOffsetCursor,
|
decodeOffsetCursor,
|
||||||
encodeCursor,
|
encodeCursor,
|
||||||
} from 'src/core/base/pagination.util';
|
} from 'src/core/base/pagination.util';
|
||||||
|
import { TourStateMetaObjectType } from './accounting-arg.types';
|
||||||
|
import { AccountingState } from './accounting-state';
|
||||||
|
import { PaginatedTourObjectType } from './accounting.object-type';
|
||||||
|
import { AccountingService } from './accounting.service';
|
||||||
import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service';
|
import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service';
|
||||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
||||||
import { AccountingState } from './accounting-state';
|
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
|
||||||
import { AccountingService } from './accounting.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountingValidationService {
|
export class AccountingValidationService {
|
||||||
@@ -22,39 +19,16 @@ export class AccountingValidationService {
|
|||||||
private readonly tourService: TourService,
|
private readonly tourService: TourService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async toursByState(
|
|
||||||
state: AccountingState,
|
|
||||||
page: number,
|
|
||||||
limit: number = 10,
|
|
||||||
): Promise<PaginatedResult<TourDto>> {
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
const { tours, total } = await this.tourService.findByAccountingState(
|
|
||||||
state,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: tours,
|
|
||||||
meta: {
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async paginateToursByState(
|
async paginateToursByState(
|
||||||
state: AccountingState,
|
state: AccountingState,
|
||||||
|
filter: TourAccountingFilterDto,
|
||||||
take: number = 10,
|
take: number = 10,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
): Promise<PaginatedTourDto> {
|
): Promise<PaginatedTourObjectType> {
|
||||||
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||||
|
|
||||||
const { tours, total } = await this.tourService.findByAccountingState(
|
const { tours, total } = await this.tourService.findByAccountingState(
|
||||||
state,
|
state,
|
||||||
|
filter,
|
||||||
take,
|
take,
|
||||||
skip,
|
skip,
|
||||||
);
|
);
|
||||||
@@ -73,7 +47,7 @@ export class AccountingValidationService {
|
|||||||
|
|
||||||
async getAccountingStateMeta(
|
async getAccountingStateMeta(
|
||||||
accountingState: AccountingState,
|
accountingState: AccountingState,
|
||||||
): Promise<TourStateMetaDto> {
|
): Promise<TourStateMetaObjectType> {
|
||||||
return this.tourService.calculateAccountingStateMeta(accountingState);
|
return this.tourService.calculateAccountingStateMeta(accountingState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import {
|
|
||||||
OptimizationCarDto,
|
|
||||||
PaginatedTourDto,
|
|
||||||
TourCostBreakdownDto,
|
|
||||||
TourDto,
|
|
||||||
TourStateMetaDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
|
||||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
|
|
||||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
|
||||||
import { AccountingQueryService } from './accounting-query.service';
|
|
||||||
import { AccountingState } from './accounting-state';
|
|
||||||
import { AccountingValidationService } from './accounting-validation.service';
|
|
||||||
import { RevenueOptimizationService } from './revenue-optimization.service';
|
|
||||||
|
|
||||||
@ApiTags('Accounting')
|
|
||||||
@Controller('api/accounting')
|
|
||||||
export class AccountingController {
|
|
||||||
constructor(
|
|
||||||
private readonly service: AccountingValidationService,
|
|
||||||
private readonly queryService: AccountingQueryService,
|
|
||||||
private readonly optimizationService: RevenueOptimizationService,
|
|
||||||
private readonly tourService: TourService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('cars/:name')
|
|
||||||
async getCarForOptimization(
|
|
||||||
@Param('name') name: string,
|
|
||||||
@Query('operation-day') operationDay: string,
|
|
||||||
): Promise<OptimizationCarDto> {
|
|
||||||
const date = new Date(operationDay);
|
|
||||||
|
|
||||||
const tours = await this.tourService.getOptimizationToursForCar(name, date);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
tours: tours.map((tour) => ({
|
|
||||||
id: tour.id,
|
|
||||||
operationId: tour.operationId,
|
|
||||||
ordinanceType: tour.ordinanceType,
|
|
||||||
startDate: tour.startDate.toISOString(),
|
|
||||||
optimizationInMinutes: tour.optimizationInMinutes ?? 0,
|
|
||||||
|
|
||||||
departedForPickup: tour.startBegin
|
|
||||||
? resolveDateFromTime(tour.startDate, tour.startBegin).toISOString()
|
|
||||||
: null,
|
|
||||||
arrivedAtPickup: tour.startEnd
|
|
||||||
? resolveDateFromTime(tour.startDate, tour.startEnd).toISOString()
|
|
||||||
: null,
|
|
||||||
departedPickup: tour.target
|
|
||||||
? resolveDateFromTime(tour.startDate, tour.target).toISOString()
|
|
||||||
: null,
|
|
||||||
arrivedAtTarget: tour.targetBegin
|
|
||||||
? resolveDateFromTime(tour.startDate, tour.targetBegin).toISOString()
|
|
||||||
: null,
|
|
||||||
completed: tour.empty
|
|
||||||
? resolveDateFromTime(tour.startDate, tour.empty).toISOString()
|
|
||||||
: null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tours')
|
|
||||||
@ApiOkResponse({ type: PaginatedTourDto })
|
|
||||||
async getAccountingTours(
|
|
||||||
@Query('state') state: AccountingState,
|
|
||||||
@Query('page') page: number,
|
|
||||||
@Query('take') take: number = 10,
|
|
||||||
) {
|
|
||||||
return this.queryService.toursByState(state, page, take);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tours/:id')
|
|
||||||
@ApiOkResponse({ type: TourDto })
|
|
||||||
getTourDetail(@Param('id') tourId: string): Promise<TourDto> {
|
|
||||||
return this.tourService.findById(tourId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('state-meta')
|
|
||||||
@ApiOkResponse({ type: TourStateMetaDto })
|
|
||||||
async getAccountingStateMeta(@Query('state') state: AccountingState) {
|
|
||||||
return this.service.getAccountingStateMeta(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('cost-breakdown/:operationId')
|
|
||||||
@ApiOkResponse({ type: TourCostBreakdownDto })
|
|
||||||
async getTourCostBreakdown(@Param('operationId') operationId: string) {
|
|
||||||
return this.service.getTourCostBreakdown(operationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('optimizations')
|
|
||||||
async getOptimizations(@Query('day') day: Date) {
|
|
||||||
return this.optimizationService.findOptimizationsForOperationDay(day);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { eachDayOfInterval, startOfDay, subWeeks } from 'date-fns';
|
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
|
||||||
import { DB } from 'src/core/database/types';
|
|
||||||
import { LoadingDoneEvent } from '../feat-etl/feat-importers/events/loading-done.event';
|
|
||||||
import {
|
|
||||||
RevenueOptimizationDto,
|
|
||||||
RevenueOptimizationService,
|
|
||||||
} from './revenue-optimization.service';
|
|
||||||
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
|
|
||||||
import { ETLEventTypes } from '../feat-etl/feat-importers/events/event-types.enum';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AccountingListener {
|
|
||||||
private readonly logger = new Logger(AccountingListener.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly service: RevenueOptimizationService,
|
|
||||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
|
||||||
private readonly tourService: TourService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@OnEvent(ETLEventTypes.LoadingDone)
|
|
||||||
async handleRevenueOptimizationCheck() {
|
|
||||||
this.logger.log(`looking for revenue optimizations`);
|
|
||||||
const batchSize = 10;
|
|
||||||
const days = eachDayOfInterval({
|
|
||||||
start: subWeeks(startOfDay(new Date()), 4),
|
|
||||||
end: startOfDay(new Date()),
|
|
||||||
});
|
|
||||||
|
|
||||||
const optimizationsInPeriod: RevenueOptimizationDto[] = [];
|
|
||||||
for (let i = 0; i < days.length; i += batchSize) {
|
|
||||||
const batch = days.slice(i, i + batchSize);
|
|
||||||
|
|
||||||
const optimizations = await Promise.all(
|
|
||||||
batch.map((day) => this.service.findOptimizationsForOperationDay(day)),
|
|
||||||
);
|
|
||||||
optimizationsInPeriod.push(...optimizations.flat(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
optimizationsInPeriod.map((optimization) =>
|
|
||||||
this.tourService.updateTour(optimization.tourId, {
|
|
||||||
optimizationInMinutes: optimization.timeModificationInMinutes,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return optimizationsInPeriod;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-22
@@ -1,14 +1,8 @@
|
|||||||
import {
|
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
Field,
|
|
||||||
Float,
|
|
||||||
Int,
|
|
||||||
ObjectType,
|
|
||||||
registerEnumType,
|
|
||||||
} from '@nestjs/graphql';
|
|
||||||
import { CostTypeVariant } from '@prisma/client';
|
|
||||||
import { Paginated } from 'src/core/base/pagination.input';
|
|
||||||
import { AccountingState } from './accounting-state';
|
import { AccountingState } from './accounting-state';
|
||||||
|
import { Paginated } from 'src/core/base/pagination.input';
|
||||||
import { TourObjectType } from './tour.object-type';
|
import { TourObjectType } from './tour.object-type';
|
||||||
|
import { CostTypeVariant } from '@prisma/client';
|
||||||
|
|
||||||
registerEnumType(AccountingState, {
|
registerEnumType(AccountingState, {
|
||||||
name: 'AccountingState',
|
name: 'AccountingState',
|
||||||
@@ -19,7 +13,7 @@ export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
|
|||||||
|
|
||||||
@ObjectType('ServicePosition')
|
@ObjectType('ServicePosition')
|
||||||
export class ServicePositionObjectType {
|
export class ServicePositionObjectType {
|
||||||
@Field(() => String)
|
@Field()
|
||||||
variant: CostTypeVariant;
|
variant: CostTypeVariant;
|
||||||
@Field()
|
@Field()
|
||||||
description: string;
|
description: string;
|
||||||
@@ -37,15 +31,3 @@ export class TourCostBreakdownObjectType {
|
|||||||
@Field(() => [ServicePositionObjectType])
|
@Field(() => [ServicePositionObjectType])
|
||||||
servicePositions: ServicePositionObjectType[];
|
servicePositions: ServicePositionObjectType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObjectType('TourStateMeta')
|
|
||||||
export class TourStateMetaObjectType {
|
|
||||||
@Field(() => Int)
|
|
||||||
count!: number;
|
|
||||||
@Field(() => Float, { nullable: true })
|
|
||||||
revenueSum?: number;
|
|
||||||
@Field(() => Float, { nullable: true })
|
|
||||||
positiveRevenueDeviation?: number;
|
|
||||||
@Field(() => Float, { nullable: true })
|
|
||||||
negativeRevenueDeviation?: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,22 +4,14 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
|
|||||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||||
import { AccountingValidationResolver } from './accounting-validation.resolver';
|
import { AccountingValidationResolver } from './accounting-validation.resolver';
|
||||||
import { AccountingValidationService } from './accounting-validation.service';
|
import { AccountingValidationService } from './accounting-validation.service';
|
||||||
import { AccountingController } from './accounting.controller';
|
|
||||||
import { AccountingListener } from './accounting.listener';
|
|
||||||
import { AccountingService } from './accounting.service';
|
import { AccountingService } from './accounting.service';
|
||||||
import { RevenueOptimizationService } from './revenue-optimization.service';
|
|
||||||
import { AccountingQueryService } from './accounting-query.service';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
||||||
controllers: [AccountingController],
|
|
||||||
providers: [
|
providers: [
|
||||||
AccountingValidationResolver,
|
AccountingValidationResolver,
|
||||||
AccountingValidationService,
|
AccountingValidationService,
|
||||||
AccountingService,
|
AccountingService,
|
||||||
AccountingListener,
|
|
||||||
RevenueOptimizationService,
|
|
||||||
AccountingQueryService
|
|
||||||
],
|
],
|
||||||
exports: [],
|
exports: [],
|
||||||
})
|
})
|
||||||
|
|||||||
-208
@@ -1,208 +0,0 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { CostTypeVariant, Tour } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
differenceInMinutes,
|
|
||||||
isAfter,
|
|
||||||
isBefore,
|
|
||||||
isWithinInterval,
|
|
||||||
} from 'date-fns';
|
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
|
||||||
import { DB } from 'src/core/database/types';
|
|
||||||
import { generateIntervalForTimeRange } from 'src/utils';
|
|
||||||
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
|
|
||||||
import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service';
|
|
||||||
|
|
||||||
export interface RevenueOptimizationDto {
|
|
||||||
tourId: string;
|
|
||||||
timeModificationInMinutes: number;
|
|
||||||
costDifference: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RevenueOptimizationService {
|
|
||||||
private readonly logger = new Logger(RevenueOptimizationService.name);
|
|
||||||
|
|
||||||
private healthInsurancesWithNoTenant = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
|
||||||
private readonly tariffService: TariffService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async findOptimizationsForOperationDay(
|
|
||||||
day: Date = new Date('2026-01-15'),
|
|
||||||
): Promise<RevenueOptimizationDto[]> {
|
|
||||||
const tours = await this.loadToursForOperationDay(day);
|
|
||||||
const groupedByCar = tours.reduce(
|
|
||||||
(acc, tour) => {
|
|
||||||
(acc[tour.carName] ??= []).push(tour);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof tours>,
|
|
||||||
);
|
|
||||||
const result = [];
|
|
||||||
for (const groupName of Object.keys(groupedByCar)) {
|
|
||||||
const optimizations = await this.findOptimizationPotential(
|
|
||||||
groupedByCar[groupName],
|
|
||||||
);
|
|
||||||
if (optimizations.length > 0) {
|
|
||||||
result.push(optimizations);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOptimizationPotential(
|
|
||||||
tours: Tour[],
|
|
||||||
): Promise<RevenueOptimizationDto[]> {
|
|
||||||
const result: RevenueOptimizationDto[] = [];
|
|
||||||
for (let i = 0; i < tours.length; i++) {
|
|
||||||
const currentTour = tours[i];
|
|
||||||
const bonusPeriods = await this.getBonusPeriods(currentTour);
|
|
||||||
|
|
||||||
// TODO: Make sure startEnd is the correct timestamp
|
|
||||||
const tourInvoiceTime = resolveDateFromTime(
|
|
||||||
currentTour.startDate,
|
|
||||||
currentTour.startEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
const bonusPeriod = bonusPeriods
|
|
||||||
.filter(({ interval }) => isWithinInterval(tourInvoiceTime, interval))
|
|
||||||
.at(0);
|
|
||||||
|
|
||||||
const possiblePeriod = bonusPeriods
|
|
||||||
.filter(
|
|
||||||
({ interval }) =>
|
|
||||||
(Math.abs(differenceInMinutes(tourInvoiceTime, interval.start)) <=
|
|
||||||
15 ||
|
|
||||||
Math.abs(differenceInMinutes(tourInvoiceTime, interval.end)) <
|
|
||||||
15) &&
|
|
||||||
!isWithinInterval(tourInvoiceTime, interval),
|
|
||||||
)
|
|
||||||
.at(0);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!bonusPeriod && possiblePeriod) ||
|
|
||||||
bonusPeriod?.costAmount < possiblePeriod?.costAmount
|
|
||||||
) {
|
|
||||||
let timeModification = 0;
|
|
||||||
if (isBefore(tourInvoiceTime, possiblePeriod.interval.start)) {
|
|
||||||
timeModification = differenceInMinutes(
|
|
||||||
possiblePeriod.interval.start,
|
|
||||||
tourInvoiceTime,
|
|
||||||
);
|
|
||||||
} else if (isAfter(tourInvoiceTime, possiblePeriod.interval.end)) {
|
|
||||||
timeModification = differenceInMinutes(
|
|
||||||
possiblePeriod.interval.end,
|
|
||||||
tourInvoiceTime,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let costDifference = 0;
|
|
||||||
if (bonusPeriod) {
|
|
||||||
costDifference = possiblePeriod.costAmount - bonusPeriod.costAmount;
|
|
||||||
} else {
|
|
||||||
costDifference = possiblePeriod.costAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
tourId: currentTour.id,
|
|
||||||
timeModificationInMinutes: timeModification,
|
|
||||||
costDifference,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getBonusPeriods(tour: Tour) {
|
|
||||||
if (!tour.ordinanceType) {
|
|
||||||
this.logger.warn(`Tour ${tour.operationId} has no ordinanceType`, {
|
|
||||||
operationId: tour.operationId,
|
|
||||||
healthInsurance: tour.healthInsurance,
|
|
||||||
ordinanceType: tour.ordinanceType,
|
|
||||||
startDate: tour.startDate,
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const tariff = await this.findTariff(
|
|
||||||
tour.healthInsurance,
|
|
||||||
tour.ordinanceType.split(' ').at(1),
|
|
||||||
tour.startDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tariff) {
|
|
||||||
this.logger.warn(`no Tariff found for Tour ${tour.operationId}`, {
|
|
||||||
operationId: tour.operationId,
|
|
||||||
healthInsurance: tour.healthInsurance,
|
|
||||||
ordinanceType: tour.ordinanceType,
|
|
||||||
startDate: tour.startDate,
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const bonusPeriods = tariff.costTypes
|
|
||||||
.filter(
|
|
||||||
({ variant }) =>
|
|
||||||
variant === CostTypeVariant.NACHTZUSCHLAG ||
|
|
||||||
variant === CostTypeVariant.GRUNDPREIS_ZEIT,
|
|
||||||
)
|
|
||||||
.map(({ timeRange, costAmount, description }) => ({
|
|
||||||
description,
|
|
||||||
interval: generateIntervalForTimeRange(
|
|
||||||
timeRange,
|
|
||||||
'Europe/Berlin',
|
|
||||||
tour.startDate,
|
|
||||||
),
|
|
||||||
costAmount,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return bonusPeriods;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadToursForOperationDay(day: Date) {
|
|
||||||
const operationDayInterval = generateIntervalForTimeRange(
|
|
||||||
'05:00-04:59',
|
|
||||||
'Europe/Berlin',
|
|
||||||
day,
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.selectFrom('Tour')
|
|
||||||
.selectAll()
|
|
||||||
.where('Tour.startDate', '>=', new Date(operationDayInterval.start))
|
|
||||||
.where('Tour.startDate', '<=', new Date(operationDayInterval.end))
|
|
||||||
.where('Tour.healthInsurance', 'is not', null)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findTariff(
|
|
||||||
healthInsurance: string,
|
|
||||||
ordinanceType: string,
|
|
||||||
tourDate: Date,
|
|
||||||
) {
|
|
||||||
if (!healthInsurance || healthInsurance === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tenant = await this.tariffService.findTenant(
|
|
||||||
healthInsurance,
|
|
||||||
ordinanceType,
|
|
||||||
);
|
|
||||||
if (!tenant) {
|
|
||||||
this.healthInsurancesWithNoTenant.push({
|
|
||||||
healthInsurance,
|
|
||||||
ordinanceType,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return this.tariffService.findByTenantId(
|
|
||||||
tenant.id,
|
|
||||||
ordinanceType,
|
|
||||||
tourDate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { Tour } from '@prisma/client';
|
|||||||
|
|
||||||
@ObjectType('Tour')
|
@ObjectType('Tour')
|
||||||
export class TourObjectType implements Tour {
|
export class TourObjectType implements Tour {
|
||||||
optimizationInMinutes: number;
|
|
||||||
insuranceNo: string;
|
insuranceNo: string;
|
||||||
overRTW: boolean;
|
overRTW: boolean;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||||
import { ManagerController } from './manager.controller';
|
|
||||||
import { ManagerResolver } from './manager.resolver';
|
import { ManagerResolver } from './manager.resolver';
|
||||||
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
||||||
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
||||||
@@ -10,7 +9,6 @@ import { TourAmountForecastService } from './tour-amount-forecast.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
||||||
controllers: [ManagerController],
|
|
||||||
providers: [
|
providers: [
|
||||||
TourAmountForecastService,
|
TourAmountForecastService,
|
||||||
TimePerTourKpiService,
|
TimePerTourKpiService,
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
TimePerTourKPISummaryDto,
|
|
||||||
TimePerTourMetricDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Logger,
|
|
||||||
Query,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiOkResponse } from '@nestjs/swagger';
|
|
||||||
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
|
||||||
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
|
||||||
|
|
||||||
@Controller('api/manager')
|
|
||||||
export class ManagerController {
|
|
||||||
private readonly logger = new Logger(ManagerController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly timePerTourKpiService: TimePerTourKpiService,
|
|
||||||
private readonly timePerTourMetricService: TimePerTourMetricsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('overview')
|
|
||||||
@ApiOkResponse({ type: [TimePerTourKPISummaryDto] })
|
|
||||||
async overview(
|
|
||||||
@Query('ordinanceType') ordinanceType: string,
|
|
||||||
): Promise<TimePerTourKPISummaryDto[]> {
|
|
||||||
if (!ordinanceType) {
|
|
||||||
throw new BadRequestException(`no ordinanceType provided`);
|
|
||||||
}
|
|
||||||
return this.timePerTourKpiService.toursPerTimeKPI(ordinanceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('dayview')
|
|
||||||
@ApiOkResponse({ type: [TimePerTourMetricDto] })
|
|
||||||
async dayview(
|
|
||||||
@Query('day') day: Date,
|
|
||||||
@Query('ordinanceType') ordinanceType: string,
|
|
||||||
): Promise<TimePerTourMetricDto[]> {
|
|
||||||
if (!day || !ordinanceType) {
|
|
||||||
throw new BadRequestException(`no day or ordinanceType provided`);
|
|
||||||
}
|
|
||||||
return this.timePerTourMetricService.forDay(day, ordinanceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('monthview')
|
|
||||||
@ApiOkResponse({ type: [TimePerTourMetricDto] })
|
|
||||||
async monthview(
|
|
||||||
@Query('day') day: Date,
|
|
||||||
@Query('ordinanceType') ordinanceType: string,
|
|
||||||
): Promise<TimePerTourMetricDto[]> {
|
|
||||||
if (!day || !ordinanceType) {
|
|
||||||
throw new BadRequestException(`no day or ordinanceType provided`);
|
|
||||||
}
|
|
||||||
return this.timePerTourMetricService.forMonth(day, ordinanceType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-5
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { addMilliseconds, Interval } from 'date-fns';
|
import { addMilliseconds } from 'date-fns';
|
||||||
import { PubSub } from 'graphql-subscriptions';
|
import { PubSub } from 'graphql-subscriptions';
|
||||||
import { generateIntervalForTimeRange } from 'src/utils';
|
import { generateIntervalForTimeRange } from 'src/utils';
|
||||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||||
@@ -115,10 +115,7 @@ export class TimePerTourKpiService {
|
|||||||
tourAmount,
|
tourAmount,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
interval: {
|
interval,
|
||||||
start: new Date(interval.start),
|
|
||||||
end: new Date(interval.end)
|
|
||||||
},
|
|
||||||
kpi: {
|
kpi: {
|
||||||
value: baseValue,
|
value: baseValue,
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
+5
-8
@@ -1,4 +1,3 @@
|
|||||||
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@@ -16,10 +15,11 @@ import {
|
|||||||
startOfDay,
|
startOfDay,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
|
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||||
|
import { TimePerTourMetricObjectType } from './manager.object-type';
|
||||||
|
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||||
import { generateIntervalForTimeRange, groupBy } from 'src/utils';
|
import { generateIntervalForTimeRange, groupBy } from 'src/utils';
|
||||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
||||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
|
||||||
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TimePerTourMetricsService {
|
export class TimePerTourMetricsService {
|
||||||
@@ -84,7 +84,7 @@ export class TimePerTourMetricsService {
|
|||||||
intervalClamp: Interval,
|
intervalClamp: Interval,
|
||||||
subIntervals: Interval[],
|
subIntervals: Interval[],
|
||||||
ordinanceType: string,
|
ordinanceType: string,
|
||||||
): Promise<TimePerTourMetricDto[]> {
|
): Promise<TimePerTourMetricObjectType[]> {
|
||||||
const carEntities = await this.carEntityService.getCarsInInterval(
|
const carEntities = await this.carEntityService.getCarsInInterval(
|
||||||
intervalClamp,
|
intervalClamp,
|
||||||
ordinanceType,
|
ordinanceType,
|
||||||
@@ -180,10 +180,7 @@ export class TimePerTourMetricsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
interval: {
|
interval,
|
||||||
start: new Date(interval.start),
|
|
||||||
end: new Date(interval.end),
|
|
||||||
},
|
|
||||||
tourAmount: Math.round(tourAmount * 100) / 100,
|
tourAmount: Math.round(tourAmount * 100) / 100,
|
||||||
availableTime: Math.round(availableTime * 100) / 100,
|
availableTime: Math.round(availableTime * 100) / 100,
|
||||||
breakEven:
|
breakEven:
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
|
||||||
import { PlaygroundController } from './playground.controller';
|
|
||||||
import { PlaygroundService } from './playground.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [FeatBusinessObjectsModule],
|
|
||||||
controllers: [PlaygroundController],
|
|
||||||
providers: [PlaygroundService],
|
|
||||||
})
|
|
||||||
export class AppPlaygroundModule {}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
|
||||||
import { PlaygroundService } from './playground.service';
|
|
||||||
|
|
||||||
@Controller('api/playground')
|
|
||||||
export class PlaygroundController {
|
|
||||||
constructor(private readonly service: PlaygroundService) {}
|
|
||||||
|
|
||||||
@Get('operation-day')
|
|
||||||
async getOperationDay(@Query('day') day: Date) {
|
|
||||||
return this.service.getCarsByOperationDay(new Date(day));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
|
||||||
import { DB } from 'src/core/database/types';
|
|
||||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
|
||||||
import { CarEntity } from '../feat-business-objects/car-entity/car.entity';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PlaygroundService {
|
|
||||||
constructor(
|
|
||||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
|
||||||
private readonly carService: CarEntityService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getCarsByOperationDay(day: Date) {
|
|
||||||
const ordinanceTypes = ['KTW', 'TSW', 'BTW'];
|
|
||||||
|
|
||||||
const cars = await Promise.all(
|
|
||||||
ordinanceTypes.map((ot) =>
|
|
||||||
this.carService.getCarsForOperationDay(day, ot),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return cars
|
|
||||||
.flat(2)
|
|
||||||
.filter(({ carName }) => !!carName)
|
|
||||||
.map((car) => this.carMapper(car))
|
|
||||||
.filter(
|
|
||||||
({ tours }) =>
|
|
||||||
tours.filter(
|
|
||||||
({ optimizationInMinutes }) => optimizationInMinutes !== 0,
|
|
||||||
).length > 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private carMapper(car: CarEntity) {
|
|
||||||
return {
|
|
||||||
name: car.carName,
|
|
||||||
drivers: car['plannedTimes'].map((pt) => ({
|
|
||||||
name: `${pt.firstname} ${pt.surname}`,
|
|
||||||
})),
|
|
||||||
tours: car['tours'].map((tour) => ({
|
|
||||||
id: tour.id,
|
|
||||||
operationId: tour.operationId,
|
|
||||||
ordinanceType: tour.ordinanceType,
|
|
||||||
startDate: tour.startDate,
|
|
||||||
optimizationInMinutes: tour.optimizationInMinutes,
|
|
||||||
|
|
||||||
departedForPickup: tour.startBegin,
|
|
||||||
arrivedAtPickup: tour.startEnd,
|
|
||||||
departedPickup: tour.target,
|
|
||||||
arrivedAtTarget: tour.targetBegin,
|
|
||||||
completed: tour.empty,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-35
@@ -3,7 +3,6 @@ import {
|
|||||||
ApprovalRequirement,
|
ApprovalRequirement,
|
||||||
TicketApprovalViewDto,
|
TicketApprovalViewDto,
|
||||||
TicketDetailDto,
|
TicketDetailDto,
|
||||||
TicketTransportDocumentView,
|
|
||||||
} from '@avicenna/shared-dtos';
|
} from '@avicenna/shared-dtos';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate } from 'date-fns';
|
||||||
@@ -12,6 +11,7 @@ import { Ticket } from 'src/modules/feat-tickets/domain/ticket.entity';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class TicketReadMapper {
|
export class TicketReadMapper {
|
||||||
toDetailDto(ticket: Ticket): TicketDetailDto {
|
toDetailDto(ticket: Ticket): TicketDetailDto {
|
||||||
|
const transportDocument = ticket.transportDocument;
|
||||||
const tour = ticket.tours
|
const tour = ticket.tours
|
||||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||||
.at(0);
|
.at(0);
|
||||||
@@ -45,7 +45,10 @@ export class TicketReadMapper {
|
|||||||
|
|
||||||
approval: this.toApprovalView(ticket),
|
approval: this.toApprovalView(ticket),
|
||||||
|
|
||||||
transportDocument: this.toTransportDocumentView(ticket),
|
transportDocument: {
|
||||||
|
isUploaded: !!transportDocument,
|
||||||
|
link: transportDocument?.storagePath,
|
||||||
|
},
|
||||||
|
|
||||||
tours: ticket.tours
|
tours: ticket.tours
|
||||||
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
|
||||||
@@ -89,15 +92,6 @@ export class TicketReadMapper {
|
|||||||
'Sind Sie sicher, dass sie die Genehmigungsanforderung des Tickets überschreiben wollen?',
|
'Sind Sie sicher, dass sie die Genehmigungsanforderung des Tickets überschreiben wollen?',
|
||||||
},
|
},
|
||||||
|
|
||||||
registerExternalRequest: {
|
|
||||||
isPossible:
|
|
||||||
approval.status !== ApprovalRequestStatus.FULL_REQUEST_SENT &&
|
|
||||||
approval.status !== ApprovalRequestStatus.EXTERNAL_REQUEST &&
|
|
||||||
approval.status !== ApprovalRequestStatus.GRANTED &&
|
|
||||||
approval.requirement === ApprovalRequirement.REQUIRED,
|
|
||||||
label: 'Bereits außerhalb angefragt',
|
|
||||||
},
|
|
||||||
|
|
||||||
sendRequest: this.canSendApprovalRequest(ticket),
|
sendRequest: this.canSendApprovalRequest(ticket),
|
||||||
|
|
||||||
sendReminder: this.canSendApprovalReminder(ticket),
|
sendReminder: this.canSendApprovalReminder(ticket),
|
||||||
@@ -111,28 +105,6 @@ export class TicketReadMapper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toTransportDocumentView(ticket: Ticket): TicketTransportDocumentView {
|
|
||||||
const td = ticket.transportDocument;
|
|
||||||
const isPresent =
|
|
||||||
ticket.hasDigitalTransportDocument || ticket.hasPhysicalTransportDocument;
|
|
||||||
|
|
||||||
return {
|
|
||||||
isDocumentPresent: isPresent,
|
|
||||||
label: isPresent ? 'Transportschein liegt vor' : 'Dokument erforderlich',
|
|
||||||
digitalDownloadLink: td?.storagePath,
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
upload: {
|
|
||||||
isPossible: !ticket.hasDigitalTransportDocument,
|
|
||||||
},
|
|
||||||
receivePhysical: {
|
|
||||||
isPossible: !ticket.hasPhysicalTransportDocument,
|
|
||||||
label: 'Transportschein liegt vor (Erfassen)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private translateApprovalStatus(status: ApprovalRequestStatus): string {
|
private translateApprovalStatus(status: ApprovalRequestStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ApprovalRequestStatus.NOT_STARTED:
|
case ApprovalRequestStatus.NOT_STARTED:
|
||||||
@@ -141,8 +113,6 @@ export class TicketReadMapper {
|
|||||||
return 'Vorab-Anfrage versendet';
|
return 'Vorab-Anfrage versendet';
|
||||||
case ApprovalRequestStatus.FULL_REQUEST_SENT:
|
case ApprovalRequestStatus.FULL_REQUEST_SENT:
|
||||||
return 'Anfrage versendet';
|
return 'Anfrage versendet';
|
||||||
case ApprovalRequestStatus.EXTERNAL_REQUEST:
|
|
||||||
return 'Anfrage von Extern versendet';
|
|
||||||
case ApprovalRequestStatus.GRANTED:
|
case ApprovalRequestStatus.GRANTED:
|
||||||
return 'Genehmigung erteilt';
|
return 'Genehmigung erteilt';
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-108
@@ -1,11 +1,7 @@
|
|||||||
import {
|
import { TicketListItemDto, PaginatedResult } from '@avicenna/shared-dtos';
|
||||||
PaginatedResult,
|
|
||||||
TicketListItemDto,
|
|
||||||
TicketStage,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||||
import { DB } from 'src/core/database/types';
|
import { DB } from 'src/core/database/types';
|
||||||
|
|
||||||
@@ -17,94 +13,6 @@ export class TicketQueryService {
|
|||||||
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async searchTicket(searchTerm: string): Promise<TicketListItemDto[]> {
|
|
||||||
const searchTerms = searchTerm
|
|
||||||
.split(' ')
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((str) => `%${str}%`);
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.selectFrom('Ticket')
|
|
||||||
.distinctOn('Ticket.id')
|
|
||||||
.where('stage', 'not in', [TicketStage.ARCHIVED, TicketStage.CLOSED])
|
|
||||||
.leftJoin('Patient', (join) =>
|
|
||||||
join.onRef(sql`"Patient"."id"::uuid`, '=', 'Ticket.patientId'),
|
|
||||||
)
|
|
||||||
.innerJoin('Tour', 'Ticket.id', 'Tour.ticketId')
|
|
||||||
.where((eb) =>
|
|
||||||
eb.and(
|
|
||||||
searchTerms.map((term) =>
|
|
||||||
eb.or([
|
|
||||||
eb('Patient.name', 'ilike', term),
|
|
||||||
eb('Patient.surname', 'ilike', term),
|
|
||||||
eb('Tour.patientName', 'ilike', term),
|
|
||||||
eb('Tour.patientSurname', 'ilike', term),
|
|
||||||
eb('Tour.operationId', 'ilike', term),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.select((eb) => [
|
|
||||||
'Ticket.id',
|
|
||||||
'Ticket.stage',
|
|
||||||
'Ticket.approvalRequirement',
|
|
||||||
'Ticket.approvalStatus',
|
|
||||||
'Ticket.approvalRequestedAt',
|
|
||||||
'Ticket.createdAt',
|
|
||||||
'Ticket.updatedAt',
|
|
||||||
sql<string>`concat("Tour"."patientName", ' ', "Tour"."patientSurname")`.as(
|
|
||||||
'tourPatientName',
|
|
||||||
),
|
|
||||||
sql<string>`concat("Patient".name, ' ', "Patient".surname)`.as(
|
|
||||||
'patientName',
|
|
||||||
),
|
|
||||||
eb
|
|
||||||
.selectFrom('Tour')
|
|
||||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
|
||||||
.select(eb.fn.countAll<number>().as('count'))
|
|
||||||
.as('tourCount'),
|
|
||||||
eb
|
|
||||||
.selectFrom('Tour')
|
|
||||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
|
||||||
.select('ordinanceType')
|
|
||||||
.limit(1)
|
|
||||||
.as('ordinanceType'),
|
|
||||||
eb
|
|
||||||
.selectFrom('Tour')
|
|
||||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
|
||||||
.select('Tour.startDate')
|
|
||||||
.where('Tour.startDate', '>=', sql<Date>`now()`)
|
|
||||||
.orderBy('Tour.startDate', 'asc')
|
|
||||||
.limit(1)
|
|
||||||
.as('nextStartDate'),
|
|
||||||
])
|
|
||||||
.limit(5)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return rows.map(
|
|
||||||
(row): TicketListItemDto => ({
|
|
||||||
id: row.id,
|
|
||||||
ordinanceType: row.ordinanceType,
|
|
||||||
patientName:
|
|
||||||
`${row.patientName}`.length > `${row.tourPatientName}`.length
|
|
||||||
? `${row.patientName}`
|
|
||||||
: row.tourPatientName,
|
|
||||||
stage: row.stage,
|
|
||||||
nextStart: row.nextStartDate,
|
|
||||||
tourCount: row.tourCount,
|
|
||||||
approvalRequirement: row.approvalRequirement,
|
|
||||||
approvalStatus: row.approvalStatus,
|
|
||||||
isOverdue: row.approvalRequestedAt
|
|
||||||
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
|
|
||||||
: false,
|
|
||||||
errorCount: 0,
|
|
||||||
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
updatedAt: row.updatedAt,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTicketsForLane(
|
async getTicketsForLane(
|
||||||
stage: string,
|
stage: string,
|
||||||
page: number,
|
page: number,
|
||||||
@@ -126,7 +34,6 @@ export class TicketQueryService {
|
|||||||
.select((eb) => [
|
.select((eb) => [
|
||||||
'Ticket.id',
|
'Ticket.id',
|
||||||
'Ticket.stage',
|
'Ticket.stage',
|
||||||
'Ticket.approvalRequirement',
|
|
||||||
'Ticket.approvalStatus',
|
'Ticket.approvalStatus',
|
||||||
'Ticket.approvalRequestedAt',
|
'Ticket.approvalRequestedAt',
|
||||||
'Ticket.createdAt',
|
'Ticket.createdAt',
|
||||||
@@ -154,14 +61,6 @@ export class TicketQueryService {
|
|||||||
.select('ordinanceType')
|
.select('ordinanceType')
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.as('ordinanceType'),
|
.as('ordinanceType'),
|
||||||
eb
|
|
||||||
.selectFrom('Tour')
|
|
||||||
.select('Tour.startDate')
|
|
||||||
.whereRef('Tour.ticketId', '=', 'Ticket.id')
|
|
||||||
.where('Tour.startDate', '>=', sql<Date>`now()`)
|
|
||||||
.orderBy('Tour.startDate', 'asc')
|
|
||||||
.limit(1)
|
|
||||||
.as('nextStartDate'),
|
|
||||||
])
|
])
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
@@ -173,13 +72,9 @@ export class TicketQueryService {
|
|||||||
ordinanceType: row.ordinanceType,
|
ordinanceType: row.ordinanceType,
|
||||||
patientName: `${row.patientName} ${row.patientSurname}`,
|
patientName: `${row.patientName} ${row.patientSurname}`,
|
||||||
stage: row.stage,
|
stage: row.stage,
|
||||||
nextStart: row.nextStartDate,
|
|
||||||
tourCount: row.tourCount,
|
tourCount: row.tourCount,
|
||||||
approvalRequirement: row.approvalRequirement,
|
|
||||||
approvalStatus: row.approvalStatus,
|
approvalStatus: row.approvalStatus,
|
||||||
isOverdue: row.approvalRequestedAt
|
isOverdue: row.approvalRequestedAt ? differenceInDays(new Date(), row.approvalRequestedAt) >= 3 : false,
|
||||||
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
|
|
||||||
: false,
|
|
||||||
errorCount: 0,
|
errorCount: 0,
|
||||||
|
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export class DocumentInfoObjectType {
|
|||||||
|
|
||||||
@ObjectType('Ticket')
|
@ObjectType('Ticket')
|
||||||
export class TicketObjectType implements Ticket {
|
export class TicketObjectType implements Ticket {
|
||||||
lastStageBeforeClosed: string;
|
|
||||||
approvalPreInquiryMessageId: string;
|
approvalPreInquiryMessageId: string;
|
||||||
approvalFullRequestMessageId: string;
|
approvalFullRequestMessageId: string;
|
||||||
approvalRecipient: string;
|
approvalRecipient: string;
|
||||||
|
|||||||
+16
-54
@@ -1,10 +1,3 @@
|
|||||||
import {
|
|
||||||
GetBoardQueryDto,
|
|
||||||
PaginatedResult,
|
|
||||||
TicketApprovalViewDto,
|
|
||||||
TicketDetailDto,
|
|
||||||
TicketListItemDto,
|
|
||||||
} from '@avicenna/shared-dtos';
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
@@ -22,20 +15,25 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { MarkAsApprovedUseCase } from '../feat-tickets/application/mark-as-approved.usecase';
|
|
||||||
import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.usecase';
|
|
||||||
import { ReceivePhysicalTransportDocumentUseCase } from '../feat-tickets/application/receive-physical-transport-document.usecase';
|
|
||||||
import { RequestTicketApprovalUseCase } from '../feat-tickets/application/request-ticket-approval.usecase';
|
|
||||||
import { SendApprovalReminderUseCase } from '../feat-tickets/application/send-approval-reminder.usecase';
|
|
||||||
import { UploadTransportDocumentUseCase } from '../feat-tickets/application/upload-transport-document.usecase';
|
import { UploadTransportDocumentUseCase } from '../feat-tickets/application/upload-transport-document.usecase';
|
||||||
import { TicketId } from '../feat-tickets/domain/ticket.entity';
|
import { TicketQueryService } from './queries/ticket-query.service';
|
||||||
|
import {
|
||||||
|
PaginatedResult,
|
||||||
|
TicketListItemDto,
|
||||||
|
GetBoardQueryDto,
|
||||||
|
TicketDetailDto,
|
||||||
|
TicketApprovalViewDto,
|
||||||
|
} from '@avicenna/shared-dtos';
|
||||||
import {
|
import {
|
||||||
TICKET_REPOSITORY,
|
TICKET_REPOSITORY,
|
||||||
TicketRepository,
|
TicketRepository,
|
||||||
} from '../feat-tickets/domain/ticket.repository.interface';
|
} from '../feat-tickets/domain/ticket.repository.interface';
|
||||||
|
import { TicketId } from '../feat-tickets/domain/ticket.entity';
|
||||||
import { TicketReadMapper } from './mappers/ticket-read.mapper';
|
import { TicketReadMapper } from './mappers/ticket-read.mapper';
|
||||||
import { TicketQueryService } from './queries/ticket-query.service';
|
import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.usecase';
|
||||||
import { RegisterExternalApprovalRequestUseCase } from '../feat-tickets/application/register-external-approval-request.usecase';
|
import { RequestTicketApprovalUseCase } from '../feat-tickets/application/request-ticket-approval.usecase';
|
||||||
|
import { SendApprovalReminderUseCase } from '../feat-tickets/application/send-approval-reminder.usecase';
|
||||||
|
import { MarkAsApprovedUseCase } from '../feat-tickets/application/mark-as-approved.usecase';
|
||||||
|
|
||||||
@Controller('api/tickets')
|
@Controller('api/tickets')
|
||||||
export class TicketsController {
|
export class TicketsController {
|
||||||
@@ -50,22 +48,9 @@ export class TicketsController {
|
|||||||
private readonly overrideUseCase: OverrideApprovalRequirementUseCase,
|
private readonly overrideUseCase: OverrideApprovalRequirementUseCase,
|
||||||
private readonly requestApprovalUseCase: RequestTicketApprovalUseCase,
|
private readonly requestApprovalUseCase: RequestTicketApprovalUseCase,
|
||||||
private readonly sendReminderUseCase: SendApprovalReminderUseCase,
|
private readonly sendReminderUseCase: SendApprovalReminderUseCase,
|
||||||
private readonly approveUseCase: MarkAsApprovedUseCase,
|
private readonly approveUseCase: MarkAsApprovedUseCase
|
||||||
private readonly receiveUseCase: ReceivePhysicalTransportDocumentUseCase,
|
|
||||||
private readonly registerUseCase: RegisterExternalApprovalRequestUseCase,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('search')
|
|
||||||
async searchTickets(
|
|
||||||
@Query('q') searchTerm: string,
|
|
||||||
): Promise<TicketListItemDto[]> {
|
|
||||||
if (!searchTerm) {
|
|
||||||
throw new BadRequestException("no 'q' provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.queryService.searchTicket(searchTerm);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getBoard(
|
async getBoard(
|
||||||
@Query() query: GetBoardQueryDto,
|
@Query() query: GetBoardQueryDto,
|
||||||
@@ -110,30 +95,17 @@ export class TicketsController {
|
|||||||
|
|
||||||
@Post(':id/approval/request')
|
@Post(':id/approval/request')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async requestApproval(
|
async requestApproval(@Param('id') id: string, @Body('recipient') recipient: string) {
|
||||||
@Param('id') id: string,
|
|
||||||
@Body('recipient') recipient: string,
|
|
||||||
) {
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new BadRequestException(`no id provided`);
|
throw new BadRequestException(`no id provided`);
|
||||||
}
|
}
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
throw new BadRequestException(`no recipient provided`);
|
throw new BadRequestException(`no recipient provided`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.requestApprovalUseCase.execute(id, recipient);
|
await this.requestApprovalUseCase.execute(id, recipient);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval/register-request')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async registerExternalApprovalRequest(@Param('id') id: string) {
|
|
||||||
if (!id) {
|
|
||||||
throw new BadRequestException(`no id provided`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.registerUseCase.execute(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':id/approval/remind')
|
@Post(':id/approval/remind')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async sendReminder(@Param('id') id: string) {
|
async sendReminder(@Param('id') id: string) {
|
||||||
@@ -164,16 +136,6 @@ export class TicketsController {
|
|||||||
await this.overrideUseCase.execute(id);
|
await this.overrideUseCase.execute(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/transport-document/receive')
|
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
|
||||||
async receiveTD(@Param('id') id: string) {
|
|
||||||
if (!id) {
|
|
||||||
throw new BadRequestException(`no id provided`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.receiveUseCase.execute(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(':ticketId/files')
|
@Post(':ticketId/files')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
|||||||
+30
-41
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Prisma, TicketValidationState, Tour } from '@prisma/client';
|
import { Prisma, TicketValidationState, Tour } from '@prisma/client';
|
||||||
import { endOfDay, Interval, startOfDay } from 'date-fns';
|
import { endOfDay, Interval, startOfDay } from 'date-fns';
|
||||||
import { ExpressionBuilder, ExpressionWrapper, Kysely, sql, SqlBool } from 'kysely';
|
import { ExpressionBuilder, ExpressionWrapper, Kysely, SqlBool } from 'kysely';
|
||||||
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
|
||||||
import { PrismaService } from 'src/core/database/prisma.service';
|
import { PrismaService } from 'src/core/database/prisma.service';
|
||||||
import { DB } from 'src/core/database/types';
|
import { DB } from 'src/core/database/types';
|
||||||
@@ -26,8 +26,8 @@ function onlyRealToursKysely(eb: TourEb) {
|
|||||||
eb('deletedAt', 'is', null),
|
eb('deletedAt', 'is', null),
|
||||||
|
|
||||||
eb.or([
|
eb.or([
|
||||||
eb('Tour.patientId', 'is', null),
|
eb('patientId', 'is', null),
|
||||||
eb('Tour.patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
|
eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -77,26 +77,6 @@ export class TourRepository {
|
|||||||
.orderBy('Tour.startDate asc')
|
.orderBy('Tour.startDate asc')
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
// tour.repository.ts
|
|
||||||
|
|
||||||
async findForCarInInterval(
|
|
||||||
carName: string,
|
|
||||||
interval: Interval,
|
|
||||||
): Promise<Tour[]> {
|
|
||||||
return await this.db
|
|
||||||
.selectFrom('Tour')
|
|
||||||
.selectAll('Tour')
|
|
||||||
.where((eb) =>
|
|
||||||
eb.and([
|
|
||||||
eb('Tour.carName', 'ilike', `%${carName}%`),
|
|
||||||
eb('Tour.startDate', '>=', new Date(interval.start)),
|
|
||||||
eb('Tour.startDate', '<', new Date(interval.end)),
|
|
||||||
onlyRealToursKysely(eb),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.orderBy('Tour.startDate', 'asc')
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findWithoutTicket(): Promise<Tour[]> {
|
async findWithoutTicket(): Promise<Tour[]> {
|
||||||
return this.db
|
return this.db
|
||||||
@@ -327,39 +307,26 @@ export class TourRepository {
|
|||||||
// --------------------- Accounting --------------------- //
|
// --------------------- Accounting --------------------- //
|
||||||
async findByAccountingState(
|
async findByAccountingState(
|
||||||
state: AccountingState,
|
state: AccountingState,
|
||||||
|
filter: TourAccountingFilterDto,
|
||||||
take?: number,
|
take?: number,
|
||||||
skip?: number,
|
skip?: number,
|
||||||
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> {
|
): Promise<{ tours: Tour[]; total: number }> {
|
||||||
return await this.db.transaction().execute(async (trx) => {
|
return await this.db.transaction().execute(async (trx) => {
|
||||||
const tours = (await trx
|
const tours = await trx
|
||||||
.selectFrom('Tour')
|
.selectFrom('Tour')
|
||||||
// .selectAll('Tour')
|
.selectAll()
|
||||||
.select([
|
|
||||||
'Tour.id',
|
|
||||||
'Tour.operationId',
|
|
||||||
'Tour.startDate',
|
|
||||||
'Tour.revenue',
|
|
||||||
'Tour.revenueDispoLive',
|
|
||||||
'Tour.optimizationInMinutes',
|
|
||||||
'Tour.ordinanceType',
|
|
||||||
])
|
|
||||||
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
|
|
||||||
.select(['Ticket.stage', 'Ticket.patientId as ticketPatientId'])
|
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.and([
|
eb.and([
|
||||||
this.getFilterForAccountingState(eb, state),
|
this.getFilterForAccountingState(eb, state),
|
||||||
onlyRealToursKysely(eb),
|
onlyRealToursKysely(eb),
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.orderBy(sql`abs("Tour"."optimizationInMinutes")`, 'desc')
|
|
||||||
.orderBy('Tour.revenueDeviation desc')
|
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(take)
|
.limit(take)
|
||||||
.execute()) as unknown as (Tour & { stage: string })[];
|
.execute();
|
||||||
|
|
||||||
const total = await trx
|
const total = await trx
|
||||||
.selectFrom('Tour')
|
.selectFrom('Tour')
|
||||||
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
|
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.and([
|
eb.and([
|
||||||
this.getFilterForAccountingState(eb, state),
|
this.getFilterForAccountingState(eb, state),
|
||||||
@@ -436,11 +403,33 @@ export class TourRepository {
|
|||||||
return eb.and([
|
return eb.and([
|
||||||
eb('Tour.check', '=', 0),
|
eb('Tour.check', '=', 0),
|
||||||
eb('Tour.revenueDeviation', '<>', 0),
|
eb('Tour.revenueDeviation', '<>', 0),
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('Ticket')
|
||||||
|
.select('Ticket.id')
|
||||||
|
.whereRef('Ticket.id', '=', 'Tour.ticketId')
|
||||||
|
.where(
|
||||||
|
'Ticket.currentState',
|
||||||
|
'<>',
|
||||||
|
TicketValidationState.ARCHIVED,
|
||||||
|
),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
case AccountingState.Billable:
|
case AccountingState.Billable:
|
||||||
return eb.and([
|
return eb.and([
|
||||||
eb('Tour.check', '=', 0),
|
eb('Tour.check', '=', 0),
|
||||||
eb('Tour.revenueDeviation', '=', 0),
|
eb('Tour.revenueDeviation', '=', 0),
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('Ticket')
|
||||||
|
.select('Ticket.id')
|
||||||
|
.whereRef('Ticket.id', '=', 'Tour.ticketId')
|
||||||
|
.where(
|
||||||
|
'Ticket.currentState',
|
||||||
|
'=',
|
||||||
|
TicketValidationState.ARCHIVED,
|
||||||
|
),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
case AccountingState.PreparedForBilling:
|
case AccountingState.PreparedForBilling:
|
||||||
return eb.and([eb('Tour.check', '=', 1)]);
|
return eb.and([eb('Tour.check', '=', 1)]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user