diff --git a/src/app/core-logic/bookings/booking-workflow.service.ts b/src/app/core-logic/bookings/booking-workflow.service.ts new file mode 100644 index 0000000..3bcbbe8 --- /dev/null +++ b/src/app/core-logic/bookings/booking-workflow.service.ts @@ -0,0 +1,245 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { Observable, catchError, finalize, map, switchMap, tap, throwError } from 'rxjs'; +import { + BookingService, + BookingVerificationStatus, + CheckinBookingRequest, + CreateContractRequest, + CreateRentalRequest, + EsignProvider, + GuidApiResponse, + PartyRole, + ReceiveInspectionRequest, + ReceiveVehicleRequest, + RentalService, + SignContractRequest, + SignatureEvent, + SignatureType, +} from '../../../contract'; + +export type BookingWorkflowStep = + | 'idle' + | 'checkingIn' + | 'creatingRental' + | 'creatingContract' + | 'recordingInspection' + | 'signingContractRenter' + | 'signingContractStaff' + | 'receivingVehicle' + | 'completed'; + +export interface SignatureESignPayload { + readonly signerIp?: string; + readonly userAgent?: string; + readonly providerSignatureId?: string; + readonly signatureImageUrl?: string; + readonly certSubject?: string; + readonly certIssuer?: string; + readonly certSerial?: string; + readonly certFingerprintSha256?: string; + readonly signatureHash?: string; + readonly evidenceUrl?: string; +} + +export interface ContractSignaturePayload { + readonly role: PartyRole; + readonly signatureEvent: SignatureEvent; + readonly type: SignatureType; + readonly signedAt: string; + readonly documentUrl?: string; + readonly documentHash?: string; + readonly eSignPayload?: SignatureESignPayload; +} + +export interface BookingWorkflowPayload { + readonly bookingId: string; + readonly verificationStatus: BookingVerificationStatus; + readonly verifiedByStaffId: string; + readonly rental: { + readonly startTime?: string; + readonly endTime?: string; + }; + readonly contract: { + readonly provider: EsignProvider; + }; + readonly inspection: { + readonly currentBatteryCapacityKwh: number; + readonly inspectedAt: string; + readonly inspectorStaffId: string; + readonly url?: string; + }; + readonly signatures: readonly [ContractSignaturePayload, ContractSignaturePayload]; + readonly receive: { + readonly receivedAt: string; + readonly receivedByStaffId: string; + }; +} + +export interface BookingWorkflowResult { + readonly bookingId: string; + readonly rentalId: string; + readonly contractId: string; + readonly inspectionId: string; + readonly signatureIds: readonly [string, string]; +} + +@Injectable({ providedIn: 'root' }) +export class BookingWorkflowService { + private readonly _bookingService = inject(BookingService); + private readonly _rentalService = inject(RentalService); + + private readonly _loading = signal(false); + private readonly _error = signal(null); + private readonly _step = signal('idle'); + private readonly _result = signal(null); + + readonly loading = this._loading.asReadonly(); + readonly error = this._error.asReadonly(); + readonly step = this._step.asReadonly(); + readonly result = this._result.asReadonly(); + + process(payload: BookingWorkflowPayload): Observable { + this._loading.set(true); + this._error.set(null); + this._result.set(null); + this._step.set('checkingIn'); + + const checkinRequest: CheckinBookingRequest = { + bookingId: payload.bookingId, + verifiedByStaffId: payload.verifiedByStaffId, + bookingVerificationStatus: payload.verificationStatus, + }; + + return this._bookingService.apiBookingCheckinPost(checkinRequest).pipe( + switchMap(() => { + this._step.set('creatingRental'); + const rentalRequest: CreateRentalRequest = { + bookingId: payload.bookingId, + startTime: payload.rental.startTime, + endTime: payload.rental.endTime, + }; + return this._rentalService.apiRentalPost(rentalRequest); + }), + map((rentalResponse: GuidApiResponse) => this._extractGuid(rentalResponse, 'rental')), + switchMap((rentalId) => { + this._step.set('creatingContract'); + const contractRequest: CreateContractRequest = { + rentalId, + provider: payload.contract.provider, + }; + return this._rentalService.apiRentalContractPost(contractRequest).pipe( + map((contractResponse: GuidApiResponse) => ({ + rentalId, + contractId: this._extractGuid(contractResponse, 'contract'), + })), + ); + }), + switchMap(({ rentalId, contractId }) => { + this._step.set('recordingInspection'); + const inspectionRequest: ReceiveInspectionRequest = { + rentalId, + currentBatteryCapacityKwh: payload.inspection.currentBatteryCapacityKwh, + inspectedAt: payload.inspection.inspectedAt, + inspectorStaffId: payload.inspection.inspectorStaffId, + url: payload.inspection.url ?? null, + }; + return this._rentalService.apiRentalInspectionPost(inspectionRequest).pipe( + map((inspectionResponse: GuidApiResponse) => ({ + rentalId, + contractId, + inspectionId: this._extractGuid(inspectionResponse, 'inspection'), + })), + ); + }), + switchMap(({ rentalId, contractId, inspectionId }) => { + this._step.set('signingContractRenter'); + const [renterSignature, staffSignature] = payload.signatures; + return this._signContract(contractId, renterSignature).pipe( + switchMap((firstSignatureId) => { + this._step.set('signingContractStaff'); + return this._signContract(contractId, staffSignature).pipe( + map((secondSignatureId) => ({ + rentalId, + contractId, + inspectionId, + signatureIds: [firstSignatureId, secondSignatureId] as const, + })), + ); + }), + ); + }), + switchMap(({ rentalId, contractId, inspectionId, signatureIds }) => { + this._step.set('receivingVehicle'); + const receiveRequest: ReceiveVehicleRequest = { + rentalId, + receivedAt: payload.receive.receivedAt, + receivedByStaffId: payload.receive.receivedByStaffId, + }; + return ( + this._rentalService.apiRentalVehicleReceivePost(receiveRequest) as Observable + ).pipe( + map(() => ({ + bookingId: payload.bookingId, + rentalId, + contractId, + inspectionId, + signatureIds, + })), + ); + }), + tap((result) => { + this._result.set(result); + this._step.set('completed'); + }), + catchError((error: unknown) => { + this._error.set(this._resolveErrorMessage(error)); + return throwError(() => error); + }), + finalize(() => { + this._loading.set(false); + if (this._step() !== 'completed') { + this._step.set('idle'); + } + }), + ); + } + + private _signContract(contractId: string, payload: ContractSignaturePayload): Observable { + const request: SignContractRequest = { + createSignaturePayloadDto: { + contractId, + documentUrl: payload.documentUrl ?? null, + documentHash: payload.documentHash ?? null, + role: payload.role, + signatureEvent: payload.signatureEvent, + type: payload.type, + signedAt: payload.signedAt, + }, + eSignPayload: payload.eSignPayload, + }; + + return this._rentalService + .apiRentalContractSignPost(request) + .pipe(map((response: GuidApiResponse) => this._extractGuid(response, 'signature'))); + } + + private _extractGuid(response: GuidApiResponse, label: string): string { + const value = response.data?.trim(); + if (!value) { + throw new Error(`Missing ${label} identifier in response.`); + } + return value; + } + + private _resolveErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === 'string' && error.length > 0) { + return error; + } + + return 'Unable to complete booking workflow. Please try again.'; + } +} diff --git a/src/app/features/staff/booking-detail/booking-detail.html b/src/app/features/staff/booking-detail/booking-detail.html new file mode 100644 index 0000000..eaf21d0 --- /dev/null +++ b/src/app/features/staff/booking-detail/booking-detail.html @@ -0,0 +1,306 @@ +
+
+
+

Booking Workflow

+

+ Complete the rental lifecycle for booking {{ bookingIdValue() ?? '--' }}. +

+
+ + + Back to bookings + +
+ + @if (hasMissingBooking()) { + + } @else { + @if (booking(); as record) { +
+

Booking summary

+
+
+ Booking ID + {{ record.bookingId }} +
+
+ Customer + {{ record.renterId ?? '—' }} +
+
+ Start Time + {{ record.startTime ?? '—' }} +
+
+ End Time + {{ record.endTime ?? '—' }} +
+
+ Verification Status + {{ record.verificationStatus ?? '—' }} +
+
+ Rental Status + {{ record.rental?.status ?? 'Not created' }} +
+
+
+ } + +
+
+ Booking check-in +
+ + +
+
+ +
+ Rental creation +
+ + +
+
+ +
+ Contract + +
+ +
+ Vehicle inspection +
+ + + + +
+
+ +
+ Renter signature +
+ + + + + +
+
+ +
+ Staff signature +
+ + + + + +
+
+ +
+ Vehicle receive +
+ + +
+
+ +
+ + @if (processingState()) { + Processing: {{ workflowStepLabel() }} + } +
+
+ } + +
+

Status

+

+ Current step: {{ workflowStepLabel() }} +

+ @if (processingState()) { +

The workflow is running. Please keep this page open.

+ } + @if (errorState(); as message) { + + } + @if (resultState(); as outcome) { +
+ +
+

Workflow completed successfully.

+
    +
  • Rental ID: {{ outcome.rentalId }}
  • +
  • Contract ID: {{ outcome.contractId }}
  • +
  • Inspection ID: {{ outcome.inspectionId }}
  • +
  • Signature IDs: {{ outcome.signatureIds[0] }}, {{ outcome.signatureIds[1] }}
  • +
+
+
+ } +
+
diff --git a/src/app/features/staff/booking-detail/booking-detail.scss b/src/app/features/staff/booking-detail/booking-detail.scss new file mode 100644 index 0000000..e604b07 --- /dev/null +++ b/src/app/features/staff/booking-detail/booking-detail.scss @@ -0,0 +1,241 @@ +:host { + display: block; + padding: 2.5rem 0; + color: var(--mat-sys-on-surface, #0f172a); +} + +.booking-workflow { + display: grid; + gap: 2rem; +} + +.booking-workflow__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.5rem; +} + +.booking-workflow__header h1 { + margin: 0; + font-size: clamp(1.75rem, 2.5vw, 2.25rem); + font-weight: 600; +} + +.booking-workflow__header p { + margin: 0.25rem 0 0; + color: var(--mat-sys-on-surface-variant, #475569); +} + +.booking-workflow__back { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + border-radius: 999px; + border: 1px solid var(--mat-sys-outline-variant, #cbd5f5); + color: inherit; + text-decoration: none; + transition: background 0.2s ease, border 0.2s ease; +} + +.booking-workflow__back:hover { + background: rgb(37 99 235 / 8%); + border-color: transparent; +} + +.booking-workflow__alert { + display: flex; + gap: 1rem; + align-items: center; + padding: 1rem 1.25rem; + border-radius: 1rem; + background: rgb(220 38 38 / 12%); + color: #991b1b; +} + +.booking-summary { + padding: 1.5rem; + border-radius: 1.25rem; + background: var(--mat-sys-surface, #fff); + box-shadow: 0 16px 32px rgb(15 23 42 / 14%); + display: grid; + gap: 1.5rem; +} + +.booking-summary__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.booking-summary__label { + display: block; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--mat-sys-on-surface-variant, #64748b); +} + +.booking-summary__value { + font-size: 1.05rem; + font-weight: 600; +} + +.workflow-form { + display: grid; + gap: 1.5rem; +} + +.workflow-form__section { + border: 1px solid rgb(226 232 240 / 65%); + border-radius: 1.25rem; + padding: 1.5rem; + background: var(--mat-sys-surface, #fff); + display: grid; + gap: 1.25rem; +} + +.workflow-form__section legend { + font-weight: 600; + font-size: 1.1rem; +} + +.workflow-form__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.workflow-form__field { + display: grid; + gap: 0.5rem; +} + +.workflow-form__field span { + font-size: 0.95rem; + color: var(--mat-sys-on-surface, #1f2937); +} + +.workflow-form__field input, +.workflow-form__field select { + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 0.75rem; + border: 1px solid var(--mat-sys-outline-variant, #cbd5f5); + background: var(--mat-sys-surface, #fff); + font: inherit; + color: inherit; +} + +.workflow-form__field input:focus, +.workflow-form__field select:focus { + outline: 3px solid rgb(37 99 235 / 35%); + outline-offset: 1px; +} + +.workflow-form__field--single { + max-width: 340px; +} + +.workflow-form__error { + color: #b91c1c; + font-size: 0.85rem; +} + +.workflow-form__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; +} + +.workflow-form__actions button { + padding: 0.75rem 1.75rem; + border: 0; + border-radius: 999px; + background: var(--mat-sys-primary, #2563eb); + color: var(--mat-sys-on-primary, #fff); + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.workflow-form__actions button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.workflow-form__actions button:hover:enabled { + background: color-mix(in srgb, var(--mat-sys-primary, #2563eb) 90%, #fff); + box-shadow: 0 12px 24px rgb(37 99 235 / 28%); +} + +.workflow-form__status { + font-size: 0.95rem; + color: var(--mat-sys-on-surface-variant, #475569); +} + +.workflow-status { + padding: 1.5rem; + border-radius: 1.25rem; + background: var(--mat-sys-surface, #fff); + box-shadow: 0 16px 32px rgb(15 23 42 / 12%); + display: grid; + gap: 0.75rem; +} + +.workflow-status h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.workflow-status__note { + margin: 0; + color: var(--mat-sys-on-surface-variant, #475569); +} + +.workflow-status__error, +.workflow-status__success { + display: flex; + gap: 0.75rem; + align-items: flex-start; + padding: 1rem 1.2rem; + border-radius: 1rem; +} + +.workflow-status__error { + background: rgb(239 68 68 / 12%); + color: #b91c1c; +} + +.workflow-status__success { + background: rgb(34 197 94 / 12%); + color: #15803d; +} + +.workflow-status__success ul { + margin: 0.5rem 0 0; + padding-left: 1.25rem; +} + +.workflow-status__success li { + margin: 0.25rem 0; +} + +@media (width <= 768px) { + :host { + padding: 1.5rem 0; + } + + .booking-workflow__header { + flex-direction: column; + align-items: flex-start; + } + + .booking-workflow__back { + align-self: stretch; + justify-content: center; + } +} diff --git a/src/app/features/staff/booking-detail/booking-detail.ts b/src/app/features/staff/booking-detail/booking-detail.ts new file mode 100644 index 0000000..b773509 --- /dev/null +++ b/src/app/features/staff/booking-detail/booking-detail.ts @@ -0,0 +1,348 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatIconModule } from '@angular/material/icon'; +import { take } from 'rxjs'; +import { + BookingVerificationStatus as BookingVerificationStatusEnum, + EsignProvider, + PartyRole, + SignatureEvent, + SignatureType, +} from '../../../../contract'; +import { BookingsService, StaffBookingRecord } from '../../../core-logic/bookings/bookings.service'; +import { + BookingWorkflowPayload, + BookingWorkflowService, + BookingWorkflowStep, + ContractSignaturePayload, +} from '../../../core-logic/bookings/booking-workflow.service'; +import { ToastService } from '../../../lib/common-ui/services/toast/toast.service'; + +interface BookingWorkflowFormValue { + readonly booking: { + readonly verifiedByStaffId: string; + readonly verificationStatus: BookingVerificationStatusEnum; + }; + readonly rental: { + readonly startTime: string; + readonly endTime: string; + }; + readonly contract: { + readonly provider: EsignProvider; + }; + readonly inspection: { + readonly currentBatteryCapacityKwh: number; + readonly inspectedAt: string; + readonly inspectorStaffId: string; + readonly url: string; + }; + readonly renterSignature: SignatureFormValue; + readonly staffSignature: SignatureFormValue; + readonly receive: { + readonly receivedAt: string; + readonly receivedByStaffId: string; + }; +} + +interface SignatureFormValue { + readonly documentUrl: string; + readonly documentHash: string; + readonly signatureEvent: SignatureEvent; + readonly type: SignatureType; + readonly signedAt: string; + readonly role: PartyRole; +} + +const STEP_LABELS: Record = { + idle: 'Ready', + checkingIn: 'Checking in booking', + creatingRental: 'Creating rental', + creatingContract: 'Creating contract', + recordingInspection: 'Recording inspection', + signingContractRenter: 'Collecting renter signature', + signingContractStaff: 'Collecting staff signature', + receivingVehicle: 'Receiving vehicle', + completed: 'Workflow completed', +}; + +@Component({ + selector: 'app-staff-booking-detail', + templateUrl: './booking-detail.html', + styleUrl: './booking-detail.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, RouterLink, MatIconModule], +}) +export class StaffBookingDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly bookingsService = inject(BookingsService); + private readonly workflowService = inject(BookingWorkflowService); + private readonly toastService = inject(ToastService); + private readonly formBuilder = inject(NonNullableFormBuilder); + + readonly BookingVerificationStatusEnum = BookingVerificationStatusEnum; + readonly EsignProvider = EsignProvider; + readonly SignatureEvent = SignatureEvent; + readonly SignatureType = SignatureType; + + readonly bookingId = toSignal(this.route.paramMap, { + initialValue: this.route.snapshot.paramMap, + } as const); + + readonly bookingIdValue = computed(() => this._currentBookingId()); + + readonly form = this.formBuilder.group({ + booking: this.formBuilder.group({ + verifiedByStaffId: this.formBuilder.control('', { + validators: [Validators.required], + }), + verificationStatus: this.formBuilder.control( + BookingVerificationStatusEnum.Approved, + { validators: [Validators.required] }, + ), + }), + rental: this.formBuilder.group({ + startTime: this.formBuilder.control('', { validators: [Validators.required] }), + endTime: this.formBuilder.control('', { validators: [Validators.required] }), + }), + contract: this.formBuilder.group({ + provider: this.formBuilder.control(EsignProvider.Native, { + validators: [Validators.required], + }), + }), + inspection: this.formBuilder.group({ + currentBatteryCapacityKwh: this.formBuilder.control(60, { + validators: [Validators.required, Validators.min(0)], + }), + inspectedAt: this.formBuilder.control(this._formatLocalDateTime(new Date().toISOString()), { + validators: [Validators.required], + }), + inspectorStaffId: this.formBuilder.control('', { + validators: [Validators.required], + }), + url: this.formBuilder.control('', []), + }), + renterSignature: this._createSignatureGroup(), + staffSignature: this._createSignatureGroup(PartyRole.Staff), + receive: this.formBuilder.group({ + receivedAt: this.formBuilder.control(this._formatLocalDateTime(new Date().toISOString()), { + validators: [Validators.required], + }), + receivedByStaffId: this.formBuilder.control('', { + validators: [Validators.required], + }), + }), + }); + + readonly booking = computed(() => { + const id = this._currentBookingId(); + if (!id) { + return null; + } + return this.bookingsService.staffBookings().find((record) => record.bookingId === id) ?? null; + }); + + readonly workflowStepLabel = computed(() => STEP_LABELS[this.workflowService.step()]); + + readonly hasMissingBooking = computed(() => { + const currentId = this._currentBookingId(); + return Boolean(currentId && !this.booking()); + }); + + readonly processingState = computed(() => this.workflowService.loading()); + readonly errorState = computed(() => this.workflowService.error()); + readonly resultState = computed(() => this.workflowService.result()); + + constructor() { + if (this.bookingsService.staffBookings().length === 0) { + this.bookingsService.loadStaffBookings().pipe(take(1)).subscribe(); + } + + effect(() => { + const record = this.booking(); + if (record) { + this._prefillForm(record); + } + }); + } + + submitWorkflow(): void { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + + const bookingId = this._currentBookingId(); + if (!bookingId) { + return; + } + + const payload = this._buildPayload(bookingId); + + this.workflowService + .process(payload) + .pipe(take(1)) + .subscribe({ + next: () => { + this.toastService.success('Booking workflow completed successfully.'); + }, + error: (error) => { + console.error('Booking workflow failed', error); + const message = this.workflowService.error() ?? 'Unable to complete booking workflow.'; + this.toastService.error(message); + }, + }); + } + + private _createSignatureGroup(defaultRole: PartyRole = PartyRole.Renter) { + return this.formBuilder.group({ + documentUrl: this.formBuilder.control('', []), + documentHash: this.formBuilder.control('', []), + signatureEvent: this.formBuilder.control(SignatureEvent.Pickup, { + validators: [Validators.required], + }), + type: this.formBuilder.control(SignatureType.Drawn, { + validators: [Validators.required], + }), + signedAt: this.formBuilder.control(this._formatLocalDateTime(new Date().toISOString()), { + validators: [Validators.required], + }), + role: this.formBuilder.control(defaultRole), + }); + } + + private _prefillForm(record: StaffBookingRecord): void { + const start = this._formatLocalDateTime(record.startTime); + const end = this._formatLocalDateTime(record.endTime); + const inspectorId = record.verifiedByStaffId ?? ''; + const staffId = record.verifiedByStaffId ?? ''; + + this.form.patchValue( + { + booking: { + verifiedByStaffId: staffId, + verificationStatus: record.verificationStatus ?? BookingVerificationStatusEnum.Approved, + }, + rental: { + startTime: start, + endTime: end, + }, + inspection: { + inspectorStaffId: inspectorId, + }, + receive: { + receivedByStaffId: staffId, + }, + }, + { emitEvent: false }, + ); + } + + private _buildPayload(bookingId: string): BookingWorkflowPayload { + const raw = this.form.getRawValue() as BookingWorkflowFormValue; + + const rentalStartIso = this._toIso(raw.rental.startTime); + const rentalEndIso = this._toIso(raw.rental.endTime); + const inspectionIso = this._toIso(raw.inspection.inspectedAt); + const renterSignedIso = this._toIso(raw.renterSignature.signedAt); + const staffSignedIso = this._toIso(raw.staffSignature.signedAt); + const receivedAtIso = this._toIso(raw.receive.receivedAt); + + if ( + !rentalStartIso || + !rentalEndIso || + !inspectionIso || + !renterSignedIso || + !staffSignedIso || + !receivedAtIso + ) { + throw new Error('Invalid date/time input detected.'); + } + + const batteryCapacity = Number(raw.inspection.currentBatteryCapacityKwh); + if (!Number.isFinite(batteryCapacity) || batteryCapacity < 0) { + throw new Error('Invalid battery capacity provided.'); + } + + return { + bookingId, + verificationStatus: raw.booking.verificationStatus, + verifiedByStaffId: raw.booking.verifiedByStaffId, + rental: { + startTime: rentalStartIso, + endTime: rentalEndIso, + }, + contract: { + provider: raw.contract.provider, + }, + inspection: { + currentBatteryCapacityKwh: batteryCapacity, + inspectedAt: inspectionIso, + inspectorStaffId: raw.inspection.inspectorStaffId, + url: raw.inspection.url.trim() ? raw.inspection.url.trim() : undefined, + }, + signatures: [ + this._buildSignaturePayload(raw.renterSignature), + this._buildSignaturePayload(raw.staffSignature), + ], + receive: { + receivedAt: receivedAtIso, + receivedByStaffId: raw.receive.receivedByStaffId, + }, + } satisfies BookingWorkflowPayload; + } + + private _buildSignaturePayload(value: SignatureFormValue): ContractSignaturePayload { + const signedIso = this._toIso(value.signedAt); + if (!signedIso) { + throw new Error('Invalid signature date.'); + } + + return { + role: value.role, + signatureEvent: value.signatureEvent, + type: value.type, + signedAt: signedIso, + documentUrl: value.documentUrl.trim() ? value.documentUrl.trim() : undefined, + documentHash: value.documentHash.trim() ? value.documentHash.trim() : undefined, + } satisfies ContractSignaturePayload; + } + + private _formatLocalDateTime(value?: string | null): string { + if (!value) { + return ''; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return ''; + } + const year = parsed.getFullYear(); + const month = this._pad(parsed.getMonth() + 1); + const day = this._pad(parsed.getDate()); + const hour = this._pad(parsed.getHours()); + const minute = this._pad(parsed.getMinutes()); + return `${year}-${month}-${day}T${hour}:${minute}`; + } + + private _pad(value: number): string { + return value.toString().padStart(2, '0'); + } + + private _toIso(value: string): string | undefined { + if (!value) { + return undefined; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return undefined; + } + return parsed.toISOString(); + } + + private _currentBookingId(): string | null { + const params = this.bookingId(); + const value = params?.get('bookingId'); + return value ?? null; + } +} diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.html b/src/app/features/staff/staff-dashboard/staff-dashboard.html index 3f1c98f..9714af5 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.html +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.html @@ -297,6 +297,17 @@

Customer Profile

+ +
+ +
} diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.scss b/src/app/features/staff/staff-dashboard/staff-dashboard.scss index 0b5b0c4..6e8eaac 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.scss +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.scss @@ -545,6 +545,30 @@ line-height: 1.45; } +.details-panel__actions { + display: flex; + justify-content: flex-end; +} + +.details-panel__primary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: 0; + border-radius: 999px; + background: var(--mat-sys-primary, #2563eb); + color: var(--mat-sys-on-primary, #fff); + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.details-panel__primary:hover { + background: color-mix(in srgb, var(--mat-sys-primary, #2563eb) 90%, #fff); + box-shadow: 0 12px 24px rgb(37 99 235 / 25%); +} + @keyframes spin { to { transform: rotate(360deg); diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.ts b/src/app/features/staff/staff-dashboard/staff-dashboard.ts index 48da3ad..df901e8 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.ts +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.ts @@ -17,6 +17,7 @@ import { RentalStatus as RentalStatusEnum, } from '../../../../contract'; import type { BookingStatus, BookingVerificationStatus, RentalStatus } from '../../../../contract'; +import { Router } from '@angular/router'; type BookingTabKey = 'all' | 'pendingVerification' | 'verified' | 'cancelled'; @@ -126,6 +127,7 @@ const RENTAL_LINKED_BADGE: StatusBadge = { }) export class StaffDashboard { private readonly bookingsService = inject(BookingsService); + private readonly router = inject(Router); @ViewChild('detailPanel') private detailPanel?: ElementRef; private activeDetailTrigger: HTMLElement | null = null; @@ -240,6 +242,14 @@ export class StaffDashboard { this.refresh(); } + goToBookingDetail(bookingId: string): void { + if (!bookingId) { + return; + } + this.closeDetails(); + this.router.navigate(['/staff/bookings', bookingId]); + } + refresh(): void { this.bookingsService.loadStaffBookings().pipe(take(1)).subscribe(); } diff --git a/src/app/features/staff/staff.routes.ts b/src/app/features/staff/staff.routes.ts index 8e55e2e..37d0b47 100644 --- a/src/app/features/staff/staff.routes.ts +++ b/src/app/features/staff/staff.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; import { StaffDashboard } from './staff-dashboard/staff-dashboard'; +import { StaffBookingDetailComponent } from './booking-detail/booking-detail'; import { RentalManagement } from './rental-management/rental-management'; import { VehicleManagement } from './vehicle-management/vehicle-management'; @@ -13,6 +14,10 @@ export default [ path: 'bookings', component: StaffDashboard, }, + { + path: 'bookings/:bookingId', + component: StaffBookingDetailComponent, + }, { path: 'vehicles', component: VehicleManagement,