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..ef0b570 --- /dev/null +++ b/src/app/core-logic/bookings/booking-workflow.service.ts @@ -0,0 +1,403 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { + BookingService, + BookingVerificationStatus, + CheckinBookingRequest, + CreateContractRequest, + CreateRentalRequest, + EsignProvider, + ESignPayloadDto, + GuidApiResponse, + PartyRole, + ReceiveInspectionRequest, + ReceiveVehicleRequest, + RentalService, + SignatureEvent, + SignatureType, + SignContractRequest, +} from '../../../contract'; +import { catchError, concatMap, defer, finalize, map, Observable, throwError } from 'rxjs'; + +export type BookingWorkflowStepKey = + | 'checkin' + | 'createRental' + | 'createContract' + | 'inspection' + | 'signRenter' + | 'signStaff' + | 'vehicleReceive'; + +export type BookingWorkflowStepStatus = 'idle' | 'running' | 'success' | 'error'; + +interface BookingWorkflowStepDefinition { + readonly key: BookingWorkflowStepKey; + readonly label: string; +} + +export interface BookingWorkflowStepState extends BookingWorkflowStepDefinition { + readonly status: BookingWorkflowStepStatus; + readonly message?: string; +} + +export interface BookingWorkflowPayload { + readonly bookingId: string; + readonly staffId?: string; + readonly rentalStart?: string; + readonly rentalEnd?: string; + readonly inspection: { + readonly currentBatteryCapacityKwh: number; + readonly inspectedAt: string; + readonly url?: string; + }; + readonly renterSignature: SignatureInput; + readonly staffSignature: SignatureInput; + readonly vehicleReceive: { + readonly receivedAt: string; + }; +} + +export interface SignatureInput { + readonly documentUrl: string; + readonly documentHash: string; + readonly signatureType: SignatureType; + readonly signedAt: string; + readonly signerIp?: string; + readonly userAgent?: string; + readonly signatureImageUrl?: string; + readonly providerSignatureId?: string; + readonly signatureHash?: string; + readonly evidenceUrl?: string; +} + +export interface BookingWorkflowSummary { + readonly bookingId: string; + readonly rentalId: string; + readonly contractId: string; + readonly inspectionId: string; + readonly renterSignatureId: string; + readonly staffSignatureId: string; +} + +interface StepExecutionResult { + readonly message?: string; +} + +interface WorkflowContext { + readonly payload: BookingWorkflowPayload; + rentalId?: string; + contractId?: string; + inspectionId?: string; + renterSignatureId?: string; + staffSignatureId?: string; +} + +const STEP_DEFINITIONS: readonly BookingWorkflowStepDefinition[] = [ + { key: 'checkin', label: 'Booking check-in' }, + { key: 'createRental', label: 'Create rental' }, + { key: 'createContract', label: 'Create rental contract' }, + { key: 'inspection', label: 'Record vehicle inspection' }, + { key: 'signRenter', label: 'Capture renter signature' }, + { key: 'signStaff', label: 'Capture staff signature' }, + { key: 'vehicleReceive', label: 'Confirm vehicle receipt' }, +]; + +@Injectable({ providedIn: 'root' }) +export class BookingWorkflowService { + private readonly bookingService = inject(BookingService); + private readonly rentalService = inject(RentalService); + + private readonly _steps = signal(this._createInitialStates()); + private readonly _running = signal(false); + private readonly _error = signal(null); + private readonly _result = signal(null); + + readonly steps = this._steps.asReadonly(); + readonly running = this._running.asReadonly(); + readonly error = this._error.asReadonly(); + readonly result = this._result.asReadonly(); + + reset(): void { + this._steps.set(this._createInitialStates()); + this._running.set(false); + this._error.set(null); + this._result.set(null); + } + + executeWorkflow(payload: BookingWorkflowPayload): Observable { + return defer(() => { + this._running.set(true); + this._error.set(null); + this._result.set(null); + this._steps.set(this._createInitialStates()); + + const context: WorkflowContext = { payload }; + + return this._runSteps(context).pipe( + map((summary) => { + this._result.set(summary); + return summary; + }), + catchError((error: unknown) => { + const message = this._resolveErrorMessage(error); + this._error.set(message); + return throwError(() => error); + }), + finalize(() => { + this._running.set(false); + }), + ); + }); + } + + private _runSteps(context: WorkflowContext): Observable { + return this._executeStep('checkin', () => this._checkinBooking(context)).pipe( + concatMap(() => this._executeStep('createRental', () => this._createRental(context))), + concatMap(() => this._executeStep('createContract', () => this._createContract(context))), + concatMap(() => this._executeStep('inspection', () => this._recordInspection(context))), + concatMap(() => + this._executeStep('signRenter', () => this._signContract(context, PartyRole.Renter)), + ), + concatMap(() => + this._executeStep('signStaff', () => this._signContract(context, PartyRole.Staff)), + ), + concatMap(() => this._executeStep('vehicleReceive', () => this._receiveVehicle(context))), + map( + () => + ({ + bookingId: context.payload.bookingId, + rentalId: context.rentalId ?? '', + contractId: context.contractId ?? '', + inspectionId: context.inspectionId ?? '', + renterSignatureId: context.renterSignatureId ?? '', + staffSignatureId: context.staffSignatureId ?? '', + }) satisfies BookingWorkflowSummary, + ), + ); + } + + private _checkinBooking(context: WorkflowContext): Observable { + const request: CheckinBookingRequest = { + bookingId: context.payload.bookingId, + bookingVerificationStatus: BookingVerificationStatus.Approved, + verifiedByStaffId: context.payload.staffId, + }; + + return this.bookingService + .apiBookingCheckinPost(request) + .pipe(map(() => ({ message: 'Booking verification approved.' }))); + } + + private _createRental(context: WorkflowContext): Observable { + const request: CreateRentalRequest = { + bookingId: context.payload.bookingId, + startTime: context.payload.rentalStart, + endTime: context.payload.rentalEnd, + }; + + return this.rentalService.apiRentalPost(request).pipe( + map((response: GuidApiResponse) => { + const rentalId = response.data; + if (!rentalId) { + throw new Error('Rental ID was not returned by the API.'); + } + + context.rentalId = rentalId; + return { message: `Rental ${rentalId} created.` } satisfies StepExecutionResult; + }), + ); + } + + private _createContract(context: WorkflowContext): Observable { + if (!context.rentalId) { + return throwError(() => new Error('Rental ID is required before creating a contract.')); + } + + const request: CreateContractRequest = { + rentalId: context.rentalId, + provider: EsignProvider.Native, + }; + + return this.rentalService.apiRentalContractPost(request).pipe( + map((response: GuidApiResponse) => { + const contractId = response.data; + if (!contractId) { + throw new Error('Contract ID was not returned by the API.'); + } + + context.contractId = contractId; + return { message: `Contract ${contractId} generated.` } satisfies StepExecutionResult; + }), + ); + } + + private _recordInspection(context: WorkflowContext): Observable { + if (!context.rentalId) { + return throwError(() => new Error('Rental ID is required before recording inspection.')); + } + + const inspection = context.payload.inspection; + const request: ReceiveInspectionRequest = { + rentalId: context.rentalId, + currentBatteryCapacityKwh: inspection.currentBatteryCapacityKwh, + inspectedAt: inspection.inspectedAt, + inspectorStaffId: context.payload.staffId, + url: inspection.url, + }; + + return this.rentalService.apiRentalInspectionPost(request).pipe( + map((response: GuidApiResponse) => { + const inspectionId = response.data ?? ''; + context.inspectionId = inspectionId; + return { + message: inspectionId ? `Inspection ${inspectionId} recorded.` : 'Inspection recorded.', + } satisfies StepExecutionResult; + }), + ); + } + + private _signContract( + context: WorkflowContext, + role: (typeof PartyRole)[keyof typeof PartyRole], + ): Observable { + if (!context.contractId) { + return throwError(() => new Error('Contract ID is required before capturing signatures.')); + } + + const signatureInput = + role === PartyRole.Renter ? context.payload.renterSignature : context.payload.staffSignature; + const request = this._buildSignContractRequest(context.contractId, role, signatureInput); + + return this.rentalService.apiRentalContractSignPost(request).pipe( + map((response: GuidApiResponse) => { + const signatureId = response.data ?? ''; + if (role === PartyRole.Renter) { + context.renterSignatureId = signatureId; + } else if (role === PartyRole.Staff) { + context.staffSignatureId = signatureId; + } + + const label = role === PartyRole.Renter ? 'Renter' : 'Staff'; + return { + message: signatureId + ? `${label} signature ${signatureId} captured.` + : `${label} signature captured.`, + } satisfies StepExecutionResult; + }), + ); + } + + private _buildSignContractRequest( + contractId: string, + role: (typeof PartyRole)[keyof typeof PartyRole], + signature: SignatureInput, + ): SignContractRequest { + const sanitizedSignatureImageUrl = this._sanitizeOptional(signature.signatureImageUrl); + const sanitizedSignerIp = this._sanitizeOptional(signature.signerIp); + const sanitizedUserAgent = this._sanitizeOptional(signature.userAgent); + const sanitizedProviderSignatureId = this._sanitizeOptional(signature.providerSignatureId); + const sanitizedSignatureHash = this._sanitizeOptional(signature.signatureHash); + const sanitizedEvidenceUrl = this._sanitizeOptional(signature.evidenceUrl); + + const eSignPayload: ESignPayloadDto | undefined = + sanitizedSignerIp || + sanitizedUserAgent || + sanitizedSignatureImageUrl || + sanitizedProviderSignatureId || + sanitizedSignatureHash || + sanitizedEvidenceUrl + ? { + signerIp: sanitizedSignerIp ?? undefined, + userAgent: sanitizedUserAgent ?? undefined, + signatureImageUrl: sanitizedSignatureImageUrl ?? undefined, + providerSignatureId: sanitizedProviderSignatureId ?? undefined, + signatureHash: sanitizedSignatureHash ?? undefined, + evidenceUrl: sanitizedEvidenceUrl ?? undefined, + } + : undefined; + + return { + createSignaturePayloadDto: { + contractId, + documentUrl: signature.documentUrl, + documentHash: signature.documentHash, + role, + signatureEvent: SignatureEvent.Pickup, + type: signature.signatureType, + signedAt: signature.signedAt, + }, + eSignPayload, + } satisfies SignContractRequest; + } + + private _receiveVehicle(context: WorkflowContext): Observable { + if (!context.rentalId) { + return throwError( + () => new Error('Rental ID is required before confirming vehicle receipt.'), + ); + } + + const request: ReceiveVehicleRequest = { + rentalId: context.rentalId, + receivedAt: context.payload.vehicleReceive.receivedAt, + receivedByStaffId: context.payload.staffId, + }; + + return this.rentalService + .apiRentalVehicleReceivePost(request) + .pipe(map(() => ({ message: 'Vehicle marked as received.' }) satisfies StepExecutionResult)); + } + + private _executeStep( + key: BookingWorkflowStepKey, + runner: () => Observable, + ): Observable { + this._updateStepState(key, { status: 'running', message: undefined }); + + return runner().pipe( + map((result) => { + this._updateStepState(key, { status: 'success', message: result.message }); + return result; + }), + catchError((error: unknown) => { + const message = this._resolveErrorMessage(error); + this._updateStepState(key, { status: 'error', message }); + return throwError(() => error); + }), + ); + } + + private _updateStepState( + key: BookingWorkflowStepKey, + changes: Partial>, + ): void { + this._steps.update((steps) => + steps.map((step) => (step.key === key ? { ...step, ...changes } : step)), + ); + } + + private _createInitialStates(): BookingWorkflowStepState[] { + return STEP_DEFINITIONS.map( + (definition) => ({ ...definition, status: 'idle' }) satisfies BookingWorkflowStepState, + ); + } + + private _sanitizeOptional(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private _resolveErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + return 'An unexpected error occurred while processing the booking workflow.'; + } +} 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..866f908 --- /dev/null +++ b/src/app/features/staff/booking-detail/booking-detail.html @@ -0,0 +1,438 @@ +
+
+ ← Back to bookings +
+

Booking workflow

+

+ Review booking details and execute the rental handoff workflow step-by-step. +

+
+
+ + @if (bookingSummary(); as summary) { +
+
+
+

Booking {{ summary.bookingId }}

+

{{ summary.customer }}

+
+
+ @for (badge of summary.badges; track badge.label) { + + {{ badge.label }} + + } +
+
+ +
+
+
Created
+
{{ summary.createdAt ?? '--' }}
+
+
+
Rental start
+
{{ summary.startTime ?? '--' }}
+
+
+
Expected return
+
{{ summary.endTime ?? '--' }}
+
+
+
Deposit
+
{{ summary.deposit ?? '--' }}
+
+
+
Estimated total
+
{{ summary.total ?? '--' }}
+
+
+
Vehicle
+
{{ summary.vehicle ?? '--' }}
+
+
+
Rental ID
+
{{ summary.rentalId ?? '--' }}
+
+
+
Rental status
+
{{ summary.rentalStatus ?? '--' }}
+
+
+
+ +
+
+

Rental handoff workflow

+

All actions will be executed in order. Provide the required data before starting.

+
+ +
    + @for (step of workflowSteps(); track step.key) { +
  1. +
    +

    {{ step.label }}

    + {{ statusLabel(step.status) }} +
    + @if (step.message) { +

    {{ step.message }}

    + } +
  2. + } +
+ + @if (workflowError()) { + + } + +
+
+ Vehicle inspection +
+ + + @if ( + inspectionGroup.controls.batteryCapacity.invalid && + inspectionGroup.controls.batteryCapacity.touched + ) { + Enter the current battery capacity. + } +
+ +
+ + + @if ( + inspectionGroup.controls.inspectedAt.invalid && + inspectionGroup.controls.inspectedAt.touched + ) { + Provide the inspection timestamp. + } +
+ +
+ + +
+
+ +
+ Renter signature +
+
+ + + @if ( + renterSignatureGroup.controls.documentUrl.invalid && + renterSignatureGroup.controls.documentUrl.touched + ) { + Document URL is required. + } +
+
+ + + @if ( + renterSignatureGroup.controls.documentHash.invalid && + renterSignatureGroup.controls.documentHash.touched + ) { + Document hash is required. + } +
+
+ + +
+
+ + + @if ( + renterSignatureGroup.controls.signedAt.invalid && + renterSignatureGroup.controls.signedAt.touched + ) { + Select when the renter signed. + } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ Staff signature +
+
+ + + @if ( + staffSignatureGroup.controls.documentUrl.invalid && + staffSignatureGroup.controls.documentUrl.touched + ) { + Document URL is required. + } +
+
+ + + @if ( + staffSignatureGroup.controls.documentHash.invalid && + staffSignatureGroup.controls.documentHash.touched + ) { + Document hash is required. + } +
+
+ + +
+
+ + + @if ( + staffSignatureGroup.controls.signedAt.invalid && + staffSignatureGroup.controls.signedAt.touched + ) { + Select when the staff member signed. + } +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ Vehicle receipt +
+ + + @if ( + vehicleReceiveGroup.controls.receivedAt.invalid && + vehicleReceiveGroup.controls.receivedAt.touched + ) { + Provide the vehicle handoff timestamp. + } +
+
+
+ +
+ +
+ + @if (workflowResult(); as result) { + + } +
+ } @else { +
+

Booking not found

+

The requested booking could not be located. Return to the bookings list and try again.

+ Back to bookings +
+ } +
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..74b4739 --- /dev/null +++ b/src/app/features/staff/booking-detail/booking-detail.scss @@ -0,0 +1,399 @@ +:host { + display: block; + padding: 2.5rem 0; + color: var(--mat-sys-on-surface, #0e1114); +} + +.booking-detail { + display: grid; + gap: 2rem; +} + +.booking-detail__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.booking-detail__back { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--mat-sys-outline-variant, #d1d5db); + background: var(--mat-sys-surface, #fff); + color: inherit; + text-decoration: none; + font-weight: 500; + transition: background 0.2s ease, transform 0.2s ease; +} + +.booking-detail__back:hover { + background: rgb(37 99 235 / 12%); + transform: translateY(-1px); +} + +.booking-detail__titles { + display: grid; + gap: 0.4rem; +} + +.booking-detail__title { + margin: 0; + font-size: clamp(1.75rem, 2vw, 2rem); + font-weight: 600; +} + +.booking-detail__subtitle { + margin: 0; + color: var(--mat-sys-on-surface-variant, #4b5563); + max-width: 640px; +} + +.booking-summary { + display: grid; + gap: 1.5rem; + padding: 1.75rem; + border-radius: 1.25rem; + background: var(--mat-sys-surface, #fff); + box-shadow: 0 16px 36px rgb(15 23 42 / 14%); + border: 1px solid rgb(148 163 184 / 18%); +} + +.booking-summary__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.booking-summary__customer { + margin: 0.5rem 0 0; + font-size: 1rem; + color: var(--mat-sys-on-surface-variant, #4b5563); +} + +.booking-summary__badges { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + +.booking-summary__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.booking-summary__grid div { + display: grid; + gap: 0.25rem; +} + +.booking-summary__grid dt { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--mat-sys-on-surface-variant, #6b7280); +} + +.booking-summary__grid dd { + margin: 0; + font-weight: 600; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.3rem 0.75rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + text-transform: capitalize; +} + +.badge--pending { + background: rgb(234 179 8 / 18%); + color: #92400e; +} + +.badge--success { + background: rgb(34 197 94 / 18%); + color: #166534; +} + +.badge--danger { + background: rgb(239 68 68 / 18%); + color: #b91c1c; +} + +.badge--info { + background: rgb(37 99 235 / 18%); + color: #1d4ed8; +} + +.workflow { + display: grid; + gap: 1.5rem; + padding: 1.75rem; + border-radius: 1.25rem; + background: var(--mat-sys-surface, #fff); + box-shadow: 0 16px 36px rgb(15 23 42 / 14%); + border: 1px solid rgb(148 163 184 / 18%); +} + +.workflow__header h2 { + margin: 0; + font-size: 1.4rem; + font-weight: 600; +} + +.workflow__header p { + margin: 0.4rem 0 0; + color: var(--mat-sys-on-surface-variant, #4b5563); +} + +.workflow-steps { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 1rem; +} + +.workflow-step { + padding: 1rem 1.25rem; + border-radius: 1rem; + border: 1px solid rgb(148 163 184 / 24%); + background: rgb(15 23 42 / 3%); + transition: border 0.2s ease, background 0.2s ease; +} + +.workflow-step--running { + border-color: var(--mat-sys-primary, #2563eb); + background: rgb(37 99 235 / 12%); +} + +.workflow-step--success { + border-color: rgb(34 197 94 / 45%); + background: rgb(34 197 94 / 12%); +} + +.workflow-step--error { + border-color: rgb(239 68 68 / 45%); + background: rgb(239 68 68 / 12%); +} + +.workflow-step__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.workflow-step__title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.workflow-step__status { + font-size: 0.9rem; + color: var(--mat-sys-on-surface-variant, #4b5563); +} + +.workflow-step__message { + margin: 0.5rem 0 0; + font-size: 0.95rem; + color: var(--mat-sys-on-surface, #0f172a); +} + +.workflow__error { + padding: 0.75rem 1rem; + border-radius: 0.75rem; + background: rgb(239 68 68 / 12%); + color: #b91c1c; + font-weight: 500; +} + +.workflow-form { + display: grid; + gap: 1.5rem; +} + +.workflow-fieldset { + margin: 0; + padding: 1.25rem; + border: 1px solid rgb(148 163 184 / 24%); + border-radius: 1rem; + display: grid; + gap: 1rem; +} + +.workflow-fieldset legend { + font-weight: 600; + padding: 0 0.5rem; +} + +.form-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.form-field { + display: grid; + gap: 0.4rem; +} + +.form-field label { + font-size: 0.9rem; + font-weight: 600; + color: var(--mat-sys-on-surface-variant, #4b5563); +} + +.form-field input, +.form-field select { + appearance: none; + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 0.75rem; + border: 1px solid var(--mat-sys-outline-variant, #d1d5db); + background: var(--mat-sys-surface, #fff); + font: inherit; + color: inherit; + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.form-field input:focus, +.form-field select:focus { + outline: none; + border-color: var(--mat-sys-primary, #2563eb); + box-shadow: 0 0 0 3px rgb(37 99 235 / 20%); +} + +.input--invalid { + border-color: rgb(239 68 68 / 65%); + box-shadow: 0 0 0 3px rgb(239 68 68 / 12%); +} + +.form-error { + font-size: 0.85rem; + color: #b91c1c; +} + +.workflow__actions { + display: flex; + justify-content: flex-end; +} + +.workflow__submit { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + border-radius: 999px; + border: 0; + background: var(--mat-sys-primary, #2563eb); + color: var(--mat-sys-on-primary, #fff); + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.workflow__submit:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.workflow__submit:hover:enabled { + background: color-mix(in srgb, var(--mat-sys-primary, #2563eb) 88%, #fff); + box-shadow: 0 12px 20px rgb(37 99 235 / 25%); + transform: translateY(-1px); +} + +.workflow-result { + padding: 1.25rem; + border-radius: 1rem; + border: 1px solid rgb(34 197 94 / 30%); + background: rgb(34 197 94 / 10%); + display: grid; + gap: 0.75rem; +} + +.workflow-result h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #166534; +} + +.workflow-result dl { + margin: 0; + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.workflow-result dl div { + display: grid; + gap: 0.25rem; +} + +.workflow-result dt { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #166534; +} + +.workflow-result dd { + margin: 0; + font-weight: 600; +} + +.booking-missing { + padding: 2rem; + border-radius: 1.25rem; + background: var(--mat-sys-surface, #fff); + box-shadow: 0 16px 36px rgb(15 23 42 / 12%); + border: 1px solid rgb(148 163 184 / 18%); + display: grid; + gap: 0.75rem; + text-align: center; +} + +.booking-missing__link { + justify-self: center; + padding: 0.6rem 1.25rem; + border-radius: 999px; + border: 0; + background: var(--mat-sys-primary, #2563eb); + color: var(--mat-sys-on-primary, #fff); + text-decoration: none; + font-weight: 600; +} + +@media (width <= 768px) { + :host { + padding: 1.5rem 0; + } + + .booking-detail__header { + flex-direction: column; + align-items: flex-start; + } + + .workflow { + padding: 1.25rem; + } + + .booking-summary { + padding: 1.25rem; + } +} 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..bc7f102 --- /dev/null +++ b/src/app/features/staff/booking-detail/booking-detail.ts @@ -0,0 +1,529 @@ +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { ActivatedRoute, ParamMap, RouterLink } from '@angular/router'; +import { map, take } from 'rxjs'; +import { + BookingStatus, + BookingVerificationStatus, + RentalStatus, + SignatureType, +} from '../../../../contract'; +import { BookingsService, StaffBookingRecord } from '../../../core-logic/bookings/bookings.service'; +import { + BookingWorkflowPayload, + BookingWorkflowService, + BookingWorkflowStepState, + BookingWorkflowStepStatus, + BookingWorkflowSummary, +} from '../../../core-logic/bookings/booking-workflow.service'; +import { UserService } from '../../../core-logic/user/user.service'; +import { ToastService } from '../../../lib/common-ui/services/toast/toast.service'; + +interface SignatureFormGroup { + documentUrl: FormControl; + documentHash: FormControl; + signatureType: FormControl; + signedAt: FormControl; + signerIp: FormControl; + userAgent: FormControl; + signatureImageUrl: FormControl; + providerSignatureId: FormControl; + signatureHash: FormControl; + evidenceUrl: FormControl; +} + +interface InspectionFormGroup { + batteryCapacity: FormControl; + inspectedAt: FormControl; + inspectionUrl: FormControl; +} + +interface VehicleReceiveFormGroup { + receivedAt: FormControl; +} + +interface BookingWorkflowFormModel { + inspection: FormGroup; + renterSignature: FormGroup; + staffSignature: FormGroup; + vehicleReceive: FormGroup; +} + +interface StatusBadgeViewModel { + readonly label: string; + readonly tone: 'pending' | 'success' | 'danger' | 'info'; +} + +interface BookingSummaryViewModel { + readonly bookingId: string; + readonly customer: string; + readonly badges: readonly StatusBadgeViewModel[]; + readonly createdAt?: string; + readonly startTime?: string; + readonly endTime?: string; + readonly deposit?: string; + readonly total?: string; + readonly vehicle?: string; + readonly rentalId?: string; + readonly rentalStatus?: string; +} + +const SIGNATURE_TYPE_OPTIONS: readonly { value: SignatureType; label: string }[] = [ + { value: SignatureType.Drawn, label: 'Drawn (handwritten)' }, + { value: SignatureType.Typed, label: 'Typed' }, + { value: SignatureType.DigitalCert, label: 'Digital certificate' }, + { value: SignatureType.OnPaper, label: 'On paper upload' }, +]; + +const STEP_STATUS_LABELS: Record = { + idle: 'Pending', + running: 'In progress', + success: 'Completed', + error: 'Failed', +}; + +const BOOKING_STATUS_BADGES: Partial> = { + pending_Verification: { label: 'Pending Verification', tone: 'pending' }, + verified: { label: 'Verified', tone: 'success' }, + cancelled: { label: 'Cancelled', tone: 'danger' }, + rental_Created: { label: 'Rental Created', tone: 'info' }, +}; + +const BOOKING_VERIFICATION_BADGES: Partial< + Record +> = { + pending: { label: 'Verification Pending', tone: 'pending' }, + approved: { label: 'Verification Approved', tone: 'success' }, + rejected_Mismatch: { label: 'Verification Rejected', tone: 'danger' }, + rejected_Other: { label: 'Verification Rejected', tone: 'danger' }, +}; + +const RENTAL_STATUS_LABELS: Partial> = { + reserved: 'Reserved', + in_Progress: 'In Progress', + completed: 'Completed', + late: 'Late', + cancelled: 'Cancelled', +}; + +@Component({ + selector: 'app-staff-booking-detail', + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './booking-detail.html', + styleUrl: './booking-detail.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StaffBookingDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly formBuilder = inject(FormBuilder); + private readonly bookingsService = inject(BookingsService); + private readonly workflowService = inject(BookingWorkflowService); + private readonly userService = inject(UserService); + private readonly toastService = inject(ToastService); + + private readonly bookingIdSignal = toSignal(this.route.paramMap.pipe(map(mapParamToBookingId)), { + initialValue: this.route.snapshot.paramMap.get('bookingId') ?? '', + }); + + readonly workflowForm: FormGroup = + this.formBuilder.group({ + inspection: this.formBuilder.group({ + batteryCapacity: this.formBuilder.nonNullable.control(80, { + validators: [Validators.required, Validators.min(0)], + }), + inspectedAt: this.formBuilder.nonNullable.control( + this._defaultDateTimeValue(), + Validators.required, + ), + inspectionUrl: this.formBuilder.control(null), + }), + renterSignature: this._createSignatureGroup(), + staffSignature: this._createSignatureGroup(), + vehicleReceive: this.formBuilder.group({ + receivedAt: this.formBuilder.nonNullable.control( + this._defaultDateTimeValue(), + Validators.required, + ), + }), + }); + + readonly bookingId = computed(() => this.bookingIdSignal()); + + readonly bookingRecord = computed(() => { + const id = this.bookingId(); + if (!id) { + return null; + } + + const records = this.bookingsService.staffBookings(); + return records.find((record) => record.bookingId === id) ?? null; + }); + + readonly bookingSummary = computed(() => { + const record = this.bookingRecord(); + if (!record) { + return null; + } + + const badges: StatusBadgeViewModel[] = []; + const statusBadge = record.status ? BOOKING_STATUS_BADGES[record.status] : undefined; + if (statusBadge) { + badges.push(statusBadge); + } + const verificationBadge = record.verificationStatus + ? BOOKING_VERIFICATION_BADGES[record.verificationStatus] + : undefined; + if (verificationBadge) { + badges.push(verificationBadge); + } + const rentalBadge = record.rental?.status + ? this._resolveRentalBadge(record.rental.status) + : undefined; + if (rentalBadge) { + badges.push(rentalBadge); + } + + return { + bookingId: record.bookingId, + customer: this._resolveCustomerLabel(record), + badges, + createdAt: this._formatDateTime(record.bookingCreatedAt), + startTime: this._formatDateTime(record.startTime), + endTime: this._formatDateTime(record.endTime), + deposit: this._formatCurrency(record.vehicleDetails?.depositPrice), + total: this._formatCurrency(this._computeEstimatedTotal(record)), + vehicle: this._resolveVehicleLabel(record), + rentalId: record.rental?.rentalId ?? undefined, + rentalStatus: record.rental?.status ? RENTAL_STATUS_LABELS[record.rental.status] : undefined, + } satisfies BookingSummaryViewModel; + }); + + readonly workflowSteps = computed(() => + this.workflowService.steps().map((step) => ({ ...step })), + ); + readonly workflowRunning = computed(() => this.workflowService.running()); + readonly workflowError = computed(() => this.workflowService.error()); + readonly workflowResult = computed(() => + this.workflowService.result(), + ); + + readonly signatureTypeOptions = SIGNATURE_TYPE_OPTIONS; + + private readonly currencyFormatter = new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0, + }); + + private readonly dateTimeFormatter = new Intl.DateTimeFormat('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + private readonly _ensureBookingEffect = effect(() => { + const id = this.bookingId(); + if (!id) { + return; + } + const existing = this.bookingRecord(); + if (existing) { + return; + } + + this.bookingsService.loadStaffBookings().pipe(take(1)).subscribe(); + }); + + constructor() { + this.workflowService.reset(); + } + + get inspectionGroup(): FormGroup { + return this.workflowForm.controls.inspection; + } + + get renterSignatureGroup(): FormGroup { + return this.workflowForm.controls.renterSignature; + } + + get staffSignatureGroup(): FormGroup { + return this.workflowForm.controls.staffSignature; + } + + get vehicleReceiveGroup(): FormGroup { + return this.workflowForm.controls.vehicleReceive; + } + + statusLabel(status: BookingWorkflowStepStatus): string { + return STEP_STATUS_LABELS[status]; + } + + runWorkflow(): void { + if (this.workflowRunning()) { + return; + } + + const booking = this.bookingRecord(); + if (!booking) { + this.toastService.error('Booking information could not be loaded.'); + return; + } + + if (this.workflowForm.invalid) { + this.workflowForm.markAllAsTouched(); + this.toastService.error('Please review the workflow form before continuing.'); + return; + } + + const payload = this._buildPayload(booking); + if (!payload) { + this.toastService.error('Unable to build workflow payload.'); + return; + } + + this.workflowService + .executeWorkflow(payload) + .pipe(take(1)) + .subscribe({ + next: () => { + this.toastService.success('Booking workflow completed successfully.'); + }, + error: (error: unknown) => { + console.error('Booking workflow failed', error); + const message = + this.workflowService.error() ?? 'Booking workflow failed. Please try again.'; + this.toastService.error(message); + }, + }); + } + + private _createSignatureGroup(): FormGroup { + return this.formBuilder.group({ + documentUrl: this.formBuilder.nonNullable.control('', Validators.required), + documentHash: this.formBuilder.nonNullable.control('', Validators.required), + signatureType: this.formBuilder.nonNullable.control( + SignatureType.Drawn, + Validators.required, + ), + signedAt: this.formBuilder.nonNullable.control( + this._defaultDateTimeValue(), + Validators.required, + ), + signerIp: this.formBuilder.control(null), + userAgent: this.formBuilder.control(null), + signatureImageUrl: this.formBuilder.control(null), + providerSignatureId: this.formBuilder.control(null), + signatureHash: this.formBuilder.control(null), + evidenceUrl: this.formBuilder.control(null), + }); + } + + private _buildPayload(record: StaffBookingRecord): BookingWorkflowPayload | null { + const staffId = this.userService.user?.id ?? undefined; + + const inspectionGroup = this.inspectionGroup.controls; + const renterGroup = this.renterSignatureGroup.controls; + const staffGroup = this.staffSignatureGroup.controls; + const vehicleGroup = this.vehicleReceiveGroup.controls; + + const batteryCapacity = inspectionGroup.batteryCapacity.value; + const inspectedAt = inspectionGroup.inspectedAt.value; + const inspectionUrl = inspectionGroup.inspectionUrl.value; + + const renterSignatureType = renterGroup.signatureType.value; + const staffSignatureType = staffGroup.signatureType.value; + + if (batteryCapacity === null || batteryCapacity === undefined) { + return null; + } + + return { + bookingId: record.bookingId, + staffId: staffId ?? undefined, + rentalStart: record.startTime ?? undefined, + rentalEnd: record.endTime ?? undefined, + inspection: { + currentBatteryCapacityKwh: Number(batteryCapacity), + inspectedAt: this._toIsoString(inspectedAt), + url: this._sanitizeOptional(inspectionUrl), + }, + renterSignature: { + documentUrl: renterGroup.documentUrl.value, + documentHash: renterGroup.documentHash.value, + signatureType: renterSignatureType ?? SignatureType.Drawn, + signedAt: this._toIsoString(renterGroup.signedAt.value), + signerIp: this._sanitizeOptional(renterGroup.signerIp.value), + userAgent: this._sanitizeOptional(renterGroup.userAgent.value), + signatureImageUrl: this._sanitizeOptional(renterGroup.signatureImageUrl.value), + providerSignatureId: this._sanitizeOptional(renterGroup.providerSignatureId.value), + signatureHash: this._sanitizeOptional(renterGroup.signatureHash.value), + evidenceUrl: this._sanitizeOptional(renterGroup.evidenceUrl.value), + }, + staffSignature: { + documentUrl: staffGroup.documentUrl.value, + documentHash: staffGroup.documentHash.value, + signatureType: staffSignatureType ?? SignatureType.Drawn, + signedAt: this._toIsoString(staffGroup.signedAt.value), + signerIp: this._sanitizeOptional(staffGroup.signerIp.value), + userAgent: this._sanitizeOptional(staffGroup.userAgent.value), + signatureImageUrl: this._sanitizeOptional(staffGroup.signatureImageUrl.value), + providerSignatureId: this._sanitizeOptional(staffGroup.providerSignatureId.value), + signatureHash: this._sanitizeOptional(staffGroup.signatureHash.value), + evidenceUrl: this._sanitizeOptional(staffGroup.evidenceUrl.value), + }, + vehicleReceive: { + receivedAt: this._toIsoString(vehicleGroup.receivedAt.value), + }, + } satisfies BookingWorkflowPayload; + } + + private _resolveCustomerLabel(record: StaffBookingRecord): string { + const renterName = record.renterProfile?.userName?.trim(); + if (renterName && renterName.length > 0) { + return renterName; + } + + return record.renterId ?? 'Unknown customer'; + } + + private _resolveVehicleLabel(record: StaffBookingRecord): string | undefined { + const vehicle = record.vehicleDetails; + if (!vehicle) { + return undefined; + } + + const make = vehicle.make?.trim(); + const model = vehicle.model?.trim(); + const year = vehicle.modelYear; + const vehicleId = vehicle.vehicleId; + + const parts: string[] = []; + if (make) { + parts.push(make); + } + if (model) { + parts.push(model); + } + if (year) { + parts.push(String(year)); + } + const name = parts.join(' '); + + if (name.length > 0) { + return vehicleId ? `${name} · ${vehicleId}` : name; + } + + return vehicleId ?? undefined; + } + + private _resolveRentalBadge(status: RentalStatus): StatusBadgeViewModel | undefined { + const label = RENTAL_STATUS_LABELS[status]; + if (!label) { + return undefined; + } + + const tone: StatusBadgeViewModel['tone'] = + status === 'completed' ? 'success' : status === 'cancelled' ? 'danger' : 'info'; + + return { label, tone } satisfies StatusBadgeViewModel; + } + + private _computeEstimatedTotal(record: StaffBookingRecord): number | undefined { + const vehicleDetails = record.vehicleDetails; + if (!vehicleDetails) { + return undefined; + } + + const rentalDays = this._computeRentalDays(record); + if (!rentalDays) { + return undefined; + } + + const dailyPrice = vehicleDetails.rentalPricePerDay ?? undefined; + if (!dailyPrice) { + return undefined; + } + + return rentalDays * dailyPrice; + } + + private _computeRentalDays(record: StaffBookingRecord): number | undefined { + const start = record.startTime ? Date.parse(record.startTime) : Number.NaN; + const end = record.endTime ? Date.parse(record.endTime) : Number.NaN; + + if (Number.isNaN(start) || Number.isNaN(end)) { + return undefined; + } + + const millisecondsPerDay = 1000 * 60 * 60 * 24; + const days = Math.max(1, Math.round((end - start) / millisecondsPerDay)); + return Number.isFinite(days) ? days : undefined; + } + + private _formatCurrency(value?: number | null): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + + return this.currencyFormatter.format(value); + } + + private _formatDateTime(value?: string | null): string | undefined { + if (!value) { + return undefined; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return undefined; + } + + return this.dateTimeFormatter.format(new Date(timestamp)); + } + + private _defaultDateTimeValue(): string { + const now = new Date(); + now.setSeconds(0, 0); + return this._formatDateForInput(now); + } + + private _formatDateForInput(date: Date): string { + const offsetMilliseconds = date.getTimezoneOffset() * 60 * 1000; + const localTime = new Date(date.getTime() - offsetMilliseconds); + return localTime.toISOString().slice(0, 16); + } + + private _toIsoString(value: string): string { + if (!value) { + return new Date().toISOString(); + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return new Date().toISOString(); + } + + return parsed.toISOString(); + } + + private _sanitizeOptional(value: string | null | undefined): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } +} + +function mapParamToBookingId(paramMap: ParamMap): string { + return paramMap.get('bookingId') ?? ''; +} diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.html b/src/app/features/staff/staff-dashboard/staff-dashboard.html index 3f1c98f..5c3e437 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.html +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.html @@ -297,6 +297,16 @@

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..05a7c76 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.scss +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.scss @@ -545,6 +545,27 @@ line-height: 1.45; } +.details-panel__actions { + display: flex; + justify-content: flex-end; +} + +.details-panel__action { + border: 0; + border-radius: 999px; + padding: 0.65rem 1.4rem; + background: var(--mat-sys-primary, #2563eb); + color: var(--mat-sys-on-primary, #fff); + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} + +.details-panel__action:hover { + background: color-mix(in srgb, var(--mat-sys-primary, #2563eb) 88%, #fff); + transform: translateY(-1px); +} + @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..76bdfcf 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.ts +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.ts @@ -9,6 +9,7 @@ import { signal, } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; +import { Router } from '@angular/router'; import { take } from 'rxjs'; import { BookingsService, StaffBookingRecord } from '../../../core-logic/bookings/bookings.service'; import { @@ -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; @@ -294,6 +296,15 @@ export class StaffDashboard { } } + goToBookingWorkflow(bookingId: string): void { + if (!bookingId) { + return; + } + + this.closeDetails(); + void this.router.navigate(['/staff/bookings', bookingId]); + } + onOverlayClick(event: MouseEvent): void { if (event.target === event.currentTarget) { this.closeDetails(); diff --git a/src/app/features/staff/staff.routes.ts b/src/app/features/staff/staff.routes.ts index 5eaf7ba..ebe92d9 100644 --- a/src/app/features/staff/staff.routes.ts +++ b/src/app/features/staff/staff.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { StaffDashboard } from './staff-dashboard/staff-dashboard'; import { RentalManagement } from './rental-management/rental-management'; +import { StaffBookingDetailComponent } from './booking-detail/booking-detail'; export default [ { @@ -8,6 +9,10 @@ export default [ pathMatch: 'full', redirectTo: 'bookings', }, + { + path: 'bookings/:bookingId', + component: StaffBookingDetailComponent, + }, { path: 'bookings', component: StaffDashboard,