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