Files
avicenna/service/app-hub/dashboard/src/app/pages/tickets/tickets-search-dialog/tickets-search-dialog.component.ts
T
Marcel Arndt ce676f20a4 commit working state
Too many changes to split them up. This commit contains roughly two months worth of work.

This commit introduces the first aggregate and rewrites the ticket system to follow DDD patterns.
It adds Spartan NG as the new component library. TanStack Query to replace GraphQL. Oh and Prisma is
almost over as well.
2026-02-11 16:50:09 +01:00

199 lines
6.6 KiB
TypeScript

import { AsyncPipe, DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { DialogService } from '@ngneat/dialog';
import {
debounceTime,
distinctUntilChanged,
filter,
switchMap,
tap,
} from 'rxjs';
import {
Ticket,
Tour,
} from '../../../core/data-access/graphql/generated/generated';
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">
<div class="text-lg font-bold pb-2">Ticket suchen</div>
<hr />
<mat-form-field class="w-full">
<mat-label>Einsatznummer oder Patient</mat-label>
<input class="w-full" matInput [(ngModel)]="search" 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) {
<div
class="group relative flex flex-col gap-1.5 rounded-lg border bg-card p-3 shadow-sm transition-all hover:bg-accent/5 hover:shadow-md cursor-pointer select-none mb-2"
[class.border-destructive]="ticket.isOverdue"
[class.bg-destructive/5]="ticket.isOverdue"
[class.border-border]="!ticket.isOverdue"
(click)="showTicketView(ticket)"
>
<div class="flex items-start justify-between gap-3">
<span
class="text-sm font-semibold leading-tight text-foreground line-clamp-2"
[class.text-destructive]="ticket.isOverdue"
>
{{ ticket.patientName }}
</span>
</div>
<div class="flex items-center justify-between mt-1">
<div class="flex items-center gap-1.5 text-xs">
@if (ticket.nextStart) {
<div
class="flex items-center gap-1.5 transition-colors"
[class.text-destructive]="ticket.isOverdue"
[class.font-medium]="ticket.isOverdue"
[class.text-muted-foreground]="!ticket.isOverdue"
>
<ng-icon
hlmIcon
[name]="
ticket.isOverdue
? 'lucideAlertCircle'
: 'lucideCalendarClock'
"
size="xs"
/>
<span>{{ ticket.nextStart | date: 'dd.MM. HH:mm' }}</span>
</div>
} @else {
<span class="text-muted-foreground/50 italic text-[10px]"
>Bereits gefahren</span
>
}
</div>
<div
class="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors"
[class.bg-destructive/10]="ticket.isOverdue"
[class.text-destructive]="ticket.isOverdue"
[class.bg-secondary]="!ticket.isOverdue"
[class.text-secondary-foreground]="!ticket.isOverdue"
>
<ng-icon hlmIcon name="lucideCar" size="xs" />
<span>{{ ticket.tourCount }}</span>
</div>
</div>
</div>
<!-- <div
class="flex flex-col justify-between items-center py-2 border-b border-gray-200 cursor-pointer"
(click)="showTicketView(ticket)"
>
<div class="w-full flex justify-between">
<span>
{{ ticket.tours.at(0)?.ordinanceType }}
@if (ticket.tours.at(0)?.category) {
<span class="text-sm text-gray-600">
({{ ticket.tours.at(0)?.category }})
</span>
}
</span>
</div>
<div class="w-full flex justify-between">
<span>
{{ ticket.tours.at(0)?.patientName }}
{{ ticket.tours.at(0)?.patientSurname }}
</span>
<span>
{{ getToursLength(ticket) }} Tour{{
getToursLength(ticket) > 1 ? 'en' : ''
}}
</span>
</div>
</div> -->
}
}
</div>
</div>`,
imports: [
MatFormFieldModule,
MatInputModule,
FormsModule,
NgIcon,
HlmIcon,
DatePipe,
],
providers: [
provideIcons({
lucideCircleAlert,
lucideCalendarClock,
lucideCar,
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TicketsSearchDialogComponent {
search = signal<string>('');
searchQuery = useTicketSearch(this.search);
private ticketsService = inject(TicketsService);
private dialog = inject(DialogService);
searchCtrl = new FormControl('');
tickets$ = this.searchCtrl.valueChanges.pipe(
debounceTime(200),
distinctUntilChanged(),
filter((val) => !!val && val?.length > 2),
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',
minHeight: '400px',
});
dialogRef.afterClosed$
.pipe(tap((result) => result && this.showTicketView(result)))
.subscribe();
}
getToursLength(ticket: unknown) {
return (ticket as Ticket & { tours: Tour[] }).tours.filter(
({ 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 latestDate = sortedStartDates.at(-1);
return latestDate ? format(latestDate, 'HH:mm | dd.MM.yy') : 'No Tours';
}
}