From b2dcf88492ef0c3d055bb40ff9e6fbe9617ffadc Mon Sep 17 00:00:00 2001 From: nnh53 Date: Fri, 14 Nov 2025 14:25:37 +0700 Subject: [PATCH 1/2] build: update open --- angular.json | 2 +- openapi-spec.json | 8 ++++++++ src/contract/model/bookingDetailsDto.ts | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/angular.json b/angular.json index 67a94d2..59ae477 100644 --- a/angular.json +++ b/angular.json @@ -31,7 +31,7 @@ "src/vendors.scss", "src/app/lib/ngm-dev-blocks/styles/ngm-dev-blocks-styles.scss" ], - "allowedCommonJsDependencies": ["lottie-web"], + "allowedCommonJsDependencies": ["lottie-web", "qrcode"], "outputPath": "./dist" }, "configurations": { diff --git a/openapi-spec.json b/openapi-spec.json index 4f0ac54..823f3cf 100644 --- a/openapi-spec.json +++ b/openapi-spec.json @@ -1941,6 +1941,14 @@ "format": "date-time", "nullable": true }, + "depositAmount": { + "type": "number", + "format": "double" + }, + "totalAmount": { + "type": "number", + "format": "double" + }, "cancelReason": { "type": "string", "nullable": true diff --git a/src/contract/model/bookingDetailsDto.ts b/src/contract/model/bookingDetailsDto.ts index b41b64e..225b7de 100644 --- a/src/contract/model/bookingDetailsDto.ts +++ b/src/contract/model/bookingDetailsDto.ts @@ -21,6 +21,8 @@ export interface BookingDetailsDto { verificationStatus?: BookingVerificationStatus; verifiedByStaffId?: string | null; verifiedAt?: string | null; + depositAmount?: number; + totalAmount?: number; cancelReason?: string | null; } export namespace BookingDetailsDto {} From cc836a8736285e53106fd5a341b5daa91b23971b Mon Sep 17 00:00:00 2001 From: nnh53 Date: Fri, 14 Nov 2025 19:11:48 +0700 Subject: [PATCH 2/2] feat: alot --- specs/001-staff-booking-flow/quickstart.md | 2 +- specs/001-staff-booking-flow/spec.md | 2 +- src/app/core-logic/auth/auth.interceptor.ts | 106 ++-- .../core-logic/bookings/bookings.service.ts | 166 +++-- src/app/core-logic/renters/renters.service.ts | 309 ++++++++- .../fulfillment-page.models.ts | 137 ++++ .../fulfillment-page.presenter.ts | 458 ++++++++++++++ .../fulfillment-page/fulfillment-page.ts | 595 +----------------- .../renter-management/renter-management.html | 52 +- .../renter-management/renter-management.scss | 58 ++ .../renter-management/renter-management.ts | 168 ++++- .../staff-dashboard/staff-dashboard.models.ts | 131 ++++ .../staff-dashboard.presenter.ts | 378 +++++++++++ .../staff/staff-dashboard/staff-dashboard.ts | 510 ++------------- src/app/features/staff/staff.routes.ts | 5 +- 15 files changed, 1926 insertions(+), 1151 deletions(-) create mode 100644 src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.models.ts create mode 100644 src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.presenter.ts create mode 100644 src/app/features/staff/staff-dashboard/staff-dashboard.models.ts create mode 100644 src/app/features/staff/staff-dashboard/staff-dashboard.presenter.ts diff --git a/specs/001-staff-booking-flow/quickstart.md b/specs/001-staff-booking-flow/quickstart.md index 67cc4d7..a310838 100644 --- a/specs/001-staff-booking-flow/quickstart.md +++ b/specs/001-staff-booking-flow/quickstart.md @@ -4,7 +4,7 @@ 1. Ensure dependencies are installed with `pnpm install`. 2. Start the application locally via `pnpm start` and navigate to `http://localhost:4200/staff/bookings` using a staff account. -3. Open a booking that is deposit-paid and pending approval, then click **Tiếp tục xử lý** to reach `/staff/bookings/:bookingId/fulfillment`. +3. Open a booking that is deposit-paid and pending approval, then click **Tiếp tục xử lý** to reach `/staff/fulfillment/:bookingId`. ## Required API Sequence diff --git a/specs/001-staff-booking-flow/spec.md b/specs/001-staff-booking-flow/spec.md index 20eb278..66de366 100644 --- a/specs/001-staff-booking-flow/spec.md +++ b/specs/001-staff-booking-flow/spec.md @@ -65,7 +65,7 @@ With the rental package in place, the staff agent secures both renter and staff ### Functional Requirements -- **FR-001**: Provide a dedicated fulfillment route (`/staff/bookings/{bookingId}/fulfillment`) available only from the staff bookings workspace for bookings that are deposit-paid and awaiting manual processing; the existing dashboard component must remain unchanged. +- **FR-001**: Provide a dedicated fulfillment route (`/staff/fulfillment/{bookingId}`) available only from the staff bookings workspace for bookings that are deposit-paid and awaiting manual processing; the existing dashboard component must remain unchanged. - **FR-002**: Display booking, renter, vehicle, and rental summaries on the fulfillment route using read-only data from existing staff booking sources so agents can confirm they are working on the correct record. - **FR-003**: Present the six required milestones as a sequential checklist that enforces the order `check-in → create rental → create contract → upload inspection → capture renter signature → capture staff signature → confirm vehicle receipt`, unlocking each item only when its predecessors succeed. - **FR-004**: When the agent confirms booking check-in, call `/booking/POST/api/Booking/checkin` with `bookingId` and `BookingStatus.Approved`, persist completion metadata, and prevent duplicate submissions while awaiting the response. diff --git a/src/app/core-logic/auth/auth.interceptor.ts b/src/app/core-logic/auth/auth.interceptor.ts index 9c9de16..7e888f6 100644 --- a/src/app/core-logic/auth/auth.interceptor.ts +++ b/src/app/core-logic/auth/auth.interceptor.ts @@ -1,10 +1,61 @@ import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; -import { Observable, catchError, finalize, take, throwError } from 'rxjs'; +import { + Observable, + catchError, + finalize, + of, + shareReplay, + switchMap, + take, + throwError, +} from 'rxjs'; import { AuthService } from './auth.service'; import { TokenService } from '../token/token.service'; +let refreshInFlight$: Observable | null = null; + +const ensureFreshAccessToken = ( + authService: AuthService, + tokenService: TokenService, +): Observable => { + const needsRefresh = tokenService.isAccessTokenExpiration(); + if (!needsRefresh) { + return of(null); + } + + const hasRefreshToken = !!tokenService.refreshToken.token; + if (!hasRefreshToken) { + return throwError(() => new Error('Missing refresh token.')); + } + + if (!refreshInFlight$) { + refreshInFlight$ = authService.invokeAccessTokenExpiration().pipe( + finalize(() => { + refreshInFlight$ = null; + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); + } + + return refreshInFlight$; +}; + +const attachAuthorizationHeader = ( + request: HttpRequest, + tokenService: TokenService, +): HttpRequest => { + const accessToken = tokenService.accessToken.token; + if (!accessToken) { + return request; + } + + return request.clone({ + headers: request.headers.set('Authorization', `Bearer ${accessToken}`), + }); +}; + /** * Intercept * @@ -18,36 +69,14 @@ export const authInterceptor = ( const authService = inject(AuthService); const tokenService = inject(TokenService); const router = inject(Router); - // Clone the request object - let newReq = req.clone(); // Request //Check if the user is authenticated if (!authService.isAuthenticated) { return next(req); } - // Check if the access token is expiration - if (tokenService.isAccessTokenExpiration()) { - // Invoke access token expiration - authService.invokeAccessTokenExpiration().subscribe({ - next: () => { - newReq = req.clone({ - headers: req.headers.set('Authorization', 'Bearer ' + tokenService.accessToken.token), - }); - }, - error: () => { - return throwError(() => new Error('Failed to invoke access token expiration.')); - }, - }); - } else { - // If the access token exists, add the Authorization header. - newReq = req.clone({ - headers: req.headers.set('Authorization', 'Bearer ' + tokenService.accessToken.token), - }); - } - - // Response - return next(newReq).pipe( + return ensureFreshAccessToken(authService, tokenService).pipe( + switchMap(() => next(attachAuthorizationHeader(req, tokenService))), catchError((error) => { // Catch "401 Unauthorized" responses if (error instanceof HttpErrorResponse && error.status === 401) { @@ -55,26 +84,13 @@ export const authInterceptor = ( // alertService.show('401 Unauthorized'); // Run when refresh token is expiration // Sign out - authService - .signOut() - .pipe( - take(1), - finalize(() => { - // Reload the app - location.reload(); - }), - ) - .subscribe({ - next: () => { - return next(newReq); - }, - error: () => { - return throwError(() => error); - }, - complete: () => { - return next(newReq); - }, - }); + return authService.signOut().pipe( + take(1), + finalize(() => { + location.reload(); + }), + switchMap(() => throwError(() => error)), + ); } // Catch other errors if (error instanceof HttpErrorResponse) { diff --git a/src/app/core-logic/bookings/bookings.service.ts b/src/app/core-logic/bookings/bookings.service.ts index 9c222d3..99d1759 100644 --- a/src/app/core-logic/bookings/bookings.service.ts +++ b/src/app/core-logic/bookings/bookings.service.ts @@ -33,6 +33,7 @@ import { export interface StaffBookingRecord { readonly bookingId: string; readonly renterId?: string; + readonly renterUserId?: string; readonly status?: BookingStatus; readonly verificationStatus?: BookingVerificationStatus; readonly bookingCreatedAt?: string; @@ -183,67 +184,82 @@ export class BookingsService { const bookingIdentifier = this._normalizeString(booking.bookingId) ?? bookingId; const renterId = this._normalizeString(booking.renterId); + const userIdFromBooking = this._extractUserId(booking); - const renter$ = renterId - ? this._bookingService.apiBookingRenterProfileGet(renterId).pipe( - map((response: RenterProfileDtoApiResponse) => response.data ?? undefined), - catchError(() => of(undefined)), - ) - : of(undefined); - - const rental$ = renterId - ? this._rentalService.apiRentalByRenterGet(renterId).pipe( - map((response) => response.data?.items ?? []), - map((items) => - items.find((item) => this._normalizeString(item.bookingId) === bookingIdentifier), - ), - switchMap((match) => { - const rentalId = this._normalizeString(match?.rentalId); - if (!rentalId) { - return of(undefined); - } - - return this._rentalService.apiRentalRentalIdGet(rentalId).pipe( - map((response) => response.data ?? undefined), + const userId$ = userIdFromBooking + ? of(userIdFromBooking) + : this._resolveRenterUserId$(renterId); + + return userId$.pipe( + switchMap((resolvedUserId) => { + const renterLookupId = resolvedUserId ?? renterId; + + const renter$ = renterLookupId + ? this._bookingService.apiBookingRenterProfileGet(renterLookupId).pipe( + map((response: RenterProfileDtoApiResponse) => response.data ?? undefined), catchError(() => of(undefined)), - ); - }), - catchError(() => of(undefined)), - ) - : of(undefined); - - return forkJoin({ renter: renter$, rental: rental$ }).pipe( - switchMap(({ renter, rental }) => { - const vehicleId = this._normalizeString( - rental?.vehicle?.vehicleId ?? rental?.vehicleId, - ); - const vehicle$ = vehicleId - ? this._bookingService.apiBookingVehiclesVehicleIdGet(vehicleId).pipe( - map((response: VehicleDetailsDtoApiResponse) => response.data ?? undefined), + ) + : of(undefined); + + const rental$ = renterId + ? this._rentalService.apiRentalByRenterGet(renterId).pipe( + map((response) => response.data?.items ?? []), + map((items) => + items.find( + (item) => this._normalizeString(item.bookingId) === bookingIdentifier, + ), + ), + switchMap((match) => { + const rentalId = this._normalizeString(match?.rentalId); + if (!rentalId) { + return of(undefined); + } + + return this._rentalService.apiRentalRentalIdGet(rentalId).pipe( + map((response) => response.data ?? undefined), + catchError(() => of(undefined)), + ); + }), catchError(() => of(undefined)), ) : of(undefined); - return vehicle$.pipe( - map( - (vehicle) => - ({ - bookingId: bookingIdentifier, - renterId, - status: booking.status, - verificationStatus: booking.verificationStatus, - bookingCreatedAt: this._normalizeString(booking.bookingCreatedAt), - startTime: this._normalizeString(booking.startTime), - endTime: this._normalizeString(booking.endTime), - vehicleAtStationId: this._normalizeString(booking.vehicleAtStationId), - verifiedAt: this._normalizeString(booking.verifiedAt), - verifiedByStaffId: this._normalizeString(booking.verifiedByStaffId), - cancelReason: this._normalizeString(booking.cancelReason), - renterProfile: renter, - rental, - vehicleDetails: vehicle, - }) satisfies StaffBookingRecord, - ), + return forkJoin({ renter: renter$, rental: rental$ }).pipe( + switchMap(({ renter, rental }) => { + const vehicleId = this._normalizeString( + rental?.vehicle?.vehicleId ?? rental?.vehicleId, + ); + const vehicle$ = vehicleId + ? this._bookingService.apiBookingVehiclesVehicleIdGet(vehicleId).pipe( + map((response: VehicleDetailsDtoApiResponse) => response.data ?? undefined), + catchError(() => of(undefined)), + ) + : of(undefined); + + return vehicle$.pipe( + map( + (vehicle) => + ({ + bookingId: bookingIdentifier, + renterId, + renterUserId: + resolvedUserId ?? userIdFromBooking ?? this._extractUserId(renter), + status: booking.status, + verificationStatus: booking.verificationStatus, + bookingCreatedAt: this._normalizeString(booking.bookingCreatedAt), + startTime: this._normalizeString(booking.startTime), + endTime: this._normalizeString(booking.endTime), + vehicleAtStationId: this._normalizeString(booking.vehicleAtStationId), + verifiedAt: this._normalizeString(booking.verifiedAt), + verifiedByStaffId: this._normalizeString(booking.verifiedByStaffId), + cancelReason: this._normalizeString(booking.cancelReason), + renterProfile: renter, + rental, + vehicleDetails: vehicle, + }) satisfies StaffBookingRecord, + ), + ); + }), ); }), ); @@ -343,11 +359,13 @@ export class BookingsService { const bookingId = booking.bookingId; const rental = rentalByBookingId.get(bookingId); const renterProfile = booking.renterId ? renterById.get(booking.renterId) : undefined; + const renterUserId = this._extractUserId(booking) ?? this._extractUserId(renterProfile); const vehicleDetails = this._resolveVehicleDetails(rental, vehicleDetailsMap); records.push({ bookingId, renterId: this._normalizeString(booking.renterId), + renterUserId, status: booking.status, verificationStatus: booking.verificationStatus, bookingCreatedAt: this._normalizeString(booking.bookingCreatedAt), @@ -553,6 +571,44 @@ export class BookingsService { return trimmed.length > 0 ? trimmed : undefined; } + private _extractUserId(source: unknown): string | undefined { + if (!source || typeof source !== 'object') { + return undefined; + } + + const record = source as Record; + const candidateKeys: readonly string[] = ['userId', 'userID', 'identityId', 'accountId']; + + for (const key of candidateKeys) { + const candidate = record[key]; + if (typeof candidate !== 'string') { + continue; + } + + const normalized = this._normalizeString(candidate); + if (normalized) { + return normalized; + } + } + + return undefined; + } + + private _resolveRenterUserId$(renterId: string | undefined): Observable { + if (!renterId) { + return of(undefined); + } + + return this._staffService.apiStaffRentersGet().pipe( + map((response: RenterProfileDtoListApiResponse) => response.data ?? []), + map((renters) => + renters.find((renter) => this._normalizeString(renter?.renterId) === renterId), + ), + map((match) => this._extractUserId(match)), + catchError(() => of(undefined)), + ); + } + private _compareByDateDesc(first?: string, second?: string): number { const firstTime = first ? Date.parse(first) : Number.NaN; const secondTime = second ? Date.parse(second) : Number.NaN; diff --git a/src/app/core-logic/renters/renters.service.ts b/src/app/core-logic/renters/renters.service.ts index 8ff49d0..37f7de3 100644 --- a/src/app/core-logic/renters/renters.service.ts +++ b/src/app/core-logic/renters/renters.service.ts @@ -9,6 +9,19 @@ import { UploadKycRequest, } from '../../../contract'; +type StaffKycType = (typeof KycType)[keyof typeof KycType] | 'unknown'; + +type KycStatusLevel = 'pending' | 'approved' | 'rejected' | 'unknown'; + +export interface StaffKycDocument { + readonly type: StaffKycType; + readonly label: string; + readonly documentNumber?: string; + readonly status?: string; + readonly statusLevel: KycStatusLevel; + readonly lastUpdated?: string; +} + export interface StaffRenterRecord { readonly renterId: string; readonly name: string; @@ -16,8 +29,58 @@ export interface StaffRenterRecord { readonly dateOfBirth?: string; readonly address?: string; readonly riskScore?: number; + readonly kycDocuments: readonly StaffKycDocument[]; } +const KYC_TYPE_LABELS: Record = { + [KycType.DriverLicense]: 'Driver license', + [KycType.NationalId]: 'National ID', + [KycType.Passport]: 'Passport', + [KycType.Other]: 'Other document', + unknown: 'KYC document', +}; + +const KYC_TYPE_PRIORITY: readonly StaffKycType[] = [ + KycType.DriverLicense, + KycType.NationalId, + KycType.Passport, + KycType.Other, + 'unknown', +]; + +const APPROVED_STATUS_KEYWORDS: readonly string[] = [ + 'approved', + 'verified', + 'completed', + 'accepted', + 'success', +]; + +const PENDING_STATUS_KEYWORDS: readonly string[] = [ + 'pending', + 'submitted', + 'processing', + 'review', + 'inprogress', + 'waiting', +]; + +const REJECTED_STATUS_KEYWORDS: readonly string[] = [ + 'rejected', + 'failed', + 'declined', + 'denied', + 'invalid', +]; + +const KYC_SOURCE_KEYS: readonly string[] = [ + 'kycDocuments', + 'kycs', + 'kycSummaries', + 'identityDocuments', + 'documents', +]; + @Injectable({ providedIn: 'root' }) export class RentersService { private readonly _staffService = inject(StaffService); @@ -76,23 +139,261 @@ export class RentersService { const name = this._normalizeString(renter.userName) ?? `Renter ${renterId.slice(-5).toUpperCase()}`; + const driverLicense = this._normalizeString(renter.driverLicenseNo); + const kycDocuments = this._extractKycDocuments(renter, driverLicense); records.push({ renterId, name, - driverLicense: this._normalizeString(renter.driverLicenseNo), + driverLicense, dateOfBirth: this._normalizeString(renter.dateOfBirth), address: this._normalizeString(renter.address), riskScore: typeof renter.riskScore === 'number' ? renter.riskScore : undefined, + kycDocuments, }); } return records.sort((first, second) => first.name.localeCompare(second.name)); } - private _normalizeString(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; + private _extractKycDocuments( + renter: RenterProfileDto, + driverLicense: string | undefined, + ): StaffKycDocument[] { + const sources = this._resolveKycSources(renter); + const documents: StaffKycDocument[] = []; + const seenKeys = new Set(); + + for (const item of sources) { + if (!item || typeof item !== 'object') { + continue; + } + + const record = item as Record; + const type = this._normalizeKycType( + record['type'] ?? record['kycType'] ?? record['documentType'] ?? record['name'], + ); + const label = this._describeKycType(type); + const documentNumber = this._firstNonEmptyString( + record['documentNumber'], + record['number'], + record['identifier'], + record['id'], + ); + const statusText = this._firstNonEmptyString( + record['status'], + record['state'], + record['verificationStatus'], + record['result'], + ); + const lastUpdated = this._firstNonEmptyString( + record['lastUpdated'], + record['updatedAt'], + record['modifiedAt'], + record['reviewedAt'], + record['createdAt'], + ); + const statusLevel = this._classifyKycStatus(statusText); + const key = `${type}|${documentNumber ?? ''}|${statusText ?? ''}`; + + if (seenKeys.has(key)) { + continue; + } + + seenKeys.add(key); + documents.push({ + type, + label, + documentNumber, + status: statusText, + statusLevel, + lastUpdated, + }); + } + + if (documents.length === 0 && driverLicense) { + documents.push({ + type: KycType.DriverLicense, + label: this._describeKycType(KycType.DriverLicense), + documentNumber: driverLicense, + status: undefined, + statusLevel: 'unknown', + lastUpdated: undefined, + }); + } + + documents.sort((first, second) => this._compareKycDocuments(first, second)); + return documents; + } + + private _resolveKycSources(renter: RenterProfileDto): unknown[] { + const extended = renter as Record; + const collected: unknown[] = []; + + for (const key of KYC_SOURCE_KEYS) { + const candidate = extended[key]; + if (!candidate) { + continue; + } + + if (Array.isArray(candidate)) { + for (const entry of candidate) { + collected.push(entry); + } + continue; + } + + if (typeof candidate === 'object') { + const dictionary = candidate as Record; + const values = Object.values(dictionary); + for (const value of values) { + collected.push(value); + } + } + } + + return collected; + } + + private _compareKycDocuments(first: StaffKycDocument, second: StaffKycDocument): number { + const typePriority = this._kycTypePriority(first.type) - this._kycTypePriority(second.type); + if (typePriority !== 0) { + return typePriority; + } + + const firstNumber = first.documentNumber ?? ''; + const secondNumber = second.documentNumber ?? ''; + const numberComparison = firstNumber.localeCompare(secondNumber); + if (numberComparison !== 0) { + return numberComparison; + } + + const firstStatus = first.status ?? ''; + const secondStatus = second.status ?? ''; + return firstStatus.localeCompare(secondStatus); + } + + private _kycTypePriority(type: StaffKycType): number { + for (let index = 0; index < KYC_TYPE_PRIORITY.length; index += 1) { + if (KYC_TYPE_PRIORITY[index] === type) { + return index; + } + } + + return KYC_TYPE_PRIORITY.length; + } + + private _normalizeKycType(value: unknown): StaffKycType { + if (typeof value === 'number') { + return this._normalizeKycType(String(value)); + } + + if (typeof value !== 'string') { + return 'unknown'; + } + + const sanitized = this._sanitizeKeyword(value); + + if (sanitized.includes('driver') || sanitized.includes('license')) { + return KycType.DriverLicense; + } + + if ( + sanitized.includes('nationalid') || + sanitized.includes('citizen') || + sanitized.includes('cmnd') || + sanitized.includes('cccd') + ) { + return KycType.NationalId; + } + + if (sanitized.includes('passport')) { + return KycType.Passport; + } + + if (sanitized.includes('other')) { + return KycType.Other; + } + + return 'unknown'; + } + + private _classifyKycStatus(statusText: string | undefined): KycStatusLevel { + if (!statusText) { + return 'unknown'; + } + + const sanitized = this._sanitizeKeyword(statusText); + if (sanitized.length === 0) { + return 'unknown'; + } + + if (this._includesKeyword(sanitized, APPROVED_STATUS_KEYWORDS)) { + return 'approved'; + } + + if (this._includesKeyword(sanitized, REJECTED_STATUS_KEYWORDS)) { + return 'rejected'; + } + + if (this._includesKeyword(sanitized, PENDING_STATUS_KEYWORDS)) { + return 'pending'; + } + + return 'unknown'; + } + + private _sanitizeKeyword(value: string): string { + const trimmed = value.trim().toLowerCase(); + let sanitized = ''; + + for (let index = 0; index < trimmed.length; index += 1) { + const character = trimmed.charAt(index); + const isLetter = character >= 'a' && character <= 'z'; + if (isLetter) { + sanitized += character; + } + } + + return sanitized; + } + + private _includesKeyword(value: string, keywords: readonly string[]): boolean { + for (const keyword of keywords) { + if (value === keyword || value.includes(keyword)) { + return true; + } + } + + return false; + } + + private _firstNonEmptyString(...values: unknown[]): string | undefined { + for (const value of values) { + const normalized = this._normalizeString(value); + if (normalized) { + return normalized; + } + } + + return undefined; + } + + private _describeKycType(type: StaffKycType): string { + return KYC_TYPE_LABELS[type]; + } + + private _normalizeString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + + return trimmed; } private _resolveErrorMessage(error: unknown): string { diff --git a/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.models.ts b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.models.ts new file mode 100644 index 0000000..b87fecb --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.models.ts @@ -0,0 +1,137 @@ +import { + FulfillmentStepId, + FulfillmentStepState, +} from '../../../../../core-logic/rental-fulfillment'; +import { EsignProvider as EsignProviderEnum } from '../../../../../../contract'; + +export type StepActionKind = + | 'button' + | 'contract' + | 'inspection' + | 'signature' + | 'vehicle' + | 'none'; + +export interface SummaryViewModel { + readonly bookingId: string; + readonly bookingStatusLabel: string; + readonly verificationStatusLabel: string; + readonly renterName: string; + readonly renterLicense: string; + readonly renterAddress: string; + readonly vehicleLabel: string; + readonly depositDisplay: string; + readonly rentalWindowDisplay: string; + readonly createdAtDisplay: string; + readonly rentalId?: string; + readonly contractId?: string; + readonly inspectionId?: string; + readonly renterSignatureId?: string; + readonly staffSignatureId?: string; + readonly vehicleReceipt?: { + readonly receivedAtDisplay: string; + readonly receivedByStaffId?: string; + }; +} +export interface StepViewModel { + readonly step: FulfillmentStepId; + readonly title: string; + readonly description: string; + readonly status: FulfillmentStepState['status']; + readonly isCurrent: boolean; + readonly canPerform: boolean; + readonly disabled: boolean; + readonly errorMessage?: string; + readonly completedAtDisplay?: string; + readonly action: StepActionConfig; + readonly artifactEntries: readonly StepArtifactEntry[]; +} + +export interface TimelineViewModel { + readonly key: string; + readonly title: string; + readonly description?: string; + readonly actorLabel: string; + readonly occurredAtDisplay: string; + readonly metadataEntries: readonly { readonly label: string; readonly value: string }[]; +} + +export type StepActionConfig = + | { readonly kind: 'button'; readonly label: string } + | { readonly kind: 'contract' } + | { readonly kind: 'inspection' } + | { readonly kind: 'signature'; readonly role: 'renter' | 'staff' } + | { readonly kind: 'vehicle' } + | { readonly kind: 'none' }; + +export interface StepArtifactEntry { + readonly label: string; + readonly value: string; +} + +export interface SummaryArtifacts { + readonly rentalId?: string; + readonly contractId?: string; + readonly inspectionId?: string; + readonly renterSignatureId?: string; + readonly staffSignatureId?: string; + readonly vehicleReceipt?: { + readonly receivedAt?: string; + readonly receivedByStaffId?: string; + }; +} + +export interface StepDefinition { + readonly title: string; + readonly description: string; + readonly action: StepActionConfig; +} + +export const CONTRACT_PROVIDERS: readonly { + readonly value: (typeof EsignProviderEnum)[keyof typeof EsignProviderEnum]; + readonly label: string; +}[] = [ + { value: EsignProviderEnum.Native, label: 'EV Rental eSign' }, + { value: EsignProviderEnum.Docusign, label: 'DocuSign' }, + { value: EsignProviderEnum.Adobesign, label: 'Adobe Sign' }, + { value: EsignProviderEnum.Signnow, label: 'SignNow' }, + { value: EsignProviderEnum.Other, label: 'Nhà cung cấp khác' }, +] as const; + +export const STEP_CONFIG: Readonly> = { + checkin: { + title: 'Duyệt đặt xe', + description: 'Xác nhận khách đã hoàn tất kiểm tra và cho phép tạo đơn thuê.', + action: { kind: 'button', label: 'Xác nhận check-in booking' }, + }, + 'create-rental': { + title: 'Tạo đơn thuê', + description: 'Tạo đơn thuê từ booking đã duyệt để bắt đầu quy trình bàn giao.', + action: { kind: 'button', label: 'Tạo đơn thuê' }, + }, + 'create-contract': { + title: 'Phát hành hợp đồng', + description: 'Khởi tạo hợp đồng điện tử cho đơn thuê và gửi tới các bên liên quan.', + action: { kind: 'contract' }, + }, + inspection: { + title: 'Ghi nhận kiểm tra xe', + description: 'Thu thập thông tin kiểm tra xe trước khi bàn giao cho khách.', + action: { kind: 'inspection' }, + }, + 'sign-renter': { + title: 'Khách ký hợp đồng', + description: 'Lấy chữ ký số của khách thuê trên hợp đồng điện tử.', + action: { kind: 'signature', role: 'renter' }, + }, + 'sign-staff': { + title: 'Nhân viên ký hợp đồng', + description: 'Nhân viên xác nhận hợp đồng sau khi khách đã ký.', + action: { kind: 'signature', role: 'staff' }, + }, + 'vehicle-receive': { + title: 'Xác nhận bàn giao xe', + description: 'Ghi nhận thời điểm bàn giao xe và người phụ trách.', + action: { kind: 'vehicle' }, + }, +}; diff --git a/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.presenter.ts b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.presenter.ts new file mode 100644 index 0000000..a84ca4f --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.presenter.ts @@ -0,0 +1,458 @@ +import { + BookingStatus as BookingStatusEnum, + BookingVerificationStatus as BookingVerificationStatusEnum, + EsignProvider as EsignProviderEnum, +} from '../../../../../../contract'; +import type { + BookingFulfillmentSummary, + FulfillmentStepId, + FulfillmentStepState, + FulfillmentTimelineEvent, +} from '../../../../../core-logic/rental-fulfillment'; +import { + CONTRACT_PROVIDERS, + STEP_CONFIG, + SummaryArtifacts, + SummaryViewModel, + StepArtifactEntry, + StepViewModel, + TimelineViewModel, +} from './fulfillment-page.models'; + +interface StepViewInput { + readonly steps: readonly FulfillmentStepState[]; + readonly nextStep?: FulfillmentStepState; + readonly isBusy: boolean; +} + +export class FulfillmentPagePresenter { + readonly contractProviders = CONTRACT_PROVIDERS; + + private readonly stepConfig = STEP_CONFIG; + 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', + }); + + collectSummaryArtifacts( + steps: readonly FulfillmentStepState[], + summary: BookingFulfillmentSummary, + ): SummaryArtifacts { + let artifacts: SummaryArtifacts = {}; + + for (const step of steps) { + const stepArtifact = step.artifact; + if (!stepArtifact) { + continue; + } + + if (stepArtifact.rentalId && !artifacts.rentalId) { + artifacts = { ...artifacts, rentalId: stepArtifact.rentalId }; + } + + if (stepArtifact.contractId && !artifacts.contractId) { + artifacts = { ...artifacts, contractId: stepArtifact.contractId }; + } + + if (stepArtifact.inspectionId && !artifacts.inspectionId) { + artifacts = { ...artifacts, inspectionId: stepArtifact.inspectionId }; + } + + if (stepArtifact.renterSignatureId && !artifacts.renterSignatureId) { + artifacts = { ...artifacts, renterSignatureId: stepArtifact.renterSignatureId }; + } + + if (stepArtifact.staffSignatureId && !artifacts.staffSignatureId) { + artifacts = { ...artifacts, staffSignatureId: stepArtifact.staffSignatureId }; + } + + if (stepArtifact.vehicleReceipt) { + const currentReceipt = artifacts.vehicleReceipt ?? {}; + artifacts = { + ...artifacts, + vehicleReceipt: { + receivedAt: stepArtifact.vehicleReceipt.receivedAt ?? currentReceipt.receivedAt, + receivedByStaffId: + stepArtifact.vehicleReceipt.receivedByStaffId ?? currentReceipt.receivedByStaffId, + }, + }; + } + } + + if (!artifacts.rentalId && summary.rental?.rentalId) { + artifacts = { ...artifacts, rentalId: summary.rental.rentalId }; + } + + return artifacts; + } + + buildSummaryView( + summary: BookingFulfillmentSummary, + artifacts: SummaryArtifacts, + ): SummaryViewModel { + const booking = summary.booking; + const renter = summary.renterProfile; + const vehicle = summary.vehicleDetails; + + const rentalId = this.safeString(artifacts.rentalId ?? summary.rental?.rentalId); + const contractId = this.safeString(artifacts.contractId); + const inspectionId = this.safeString(artifacts.inspectionId); + const renterSignatureId = this.safeString(artifacts.renterSignatureId); + const staffSignatureId = this.safeString(artifacts.staffSignatureId); + const vehicleReceipt = artifacts.vehicleReceipt; + + return { + bookingId: summary.bookingId, + bookingStatusLabel: this.bookingStatusLabel(summary.status), + verificationStatusLabel: this.verificationStatusLabel(summary.verificationStatus), + renterName: renter?.userName?.trim() || 'Chưa có tên khách', + renterLicense: renter?.driverLicenseNo?.trim() || 'Không có giấy phép', + renterAddress: renter?.address?.trim() || 'Không có địa chỉ', + vehicleLabel: this.vehicleLabel(vehicle), + depositDisplay: this.formatCurrency(vehicle?.depositPrice), + rentalWindowDisplay: this.formatDateRange(booking?.startTime, booking?.endTime), + createdAtDisplay: this.formatDateTime(booking?.bookingCreatedAt), + rentalId, + contractId, + inspectionId, + renterSignatureId, + staffSignatureId, + vehicleReceipt: + vehicleReceipt && (vehicleReceipt.receivedAt || vehicleReceipt.receivedByStaffId) + ? { + receivedAtDisplay: this.formatDateTime(vehicleReceipt.receivedAt), + receivedByStaffId: this.safeString(vehicleReceipt.receivedByStaffId), + } + : undefined, + } satisfies SummaryViewModel; + } + + buildStepViewModels({ steps, nextStep, isBusy }: StepViewInput): StepViewModel[] { + const stepMap = new Map(steps.map((state) => [state.step, state] as const)); + + return steps.map((step) => this.toStepViewModel(step, nextStep, isBusy, stepMap)); + } + + canPerformStep( + stepId: FulfillmentStepId, + steps: readonly FulfillmentStepState[], + isBusy: boolean, + ): boolean { + if (isBusy) { + return false; + } + + const stepMap = new Map(steps.map((state) => [state.step, state] as const)); + const step = stepMap.get(stepId); + if (!step) { + return false; + } + + const config = this.stepConfig[stepId]; + if (!config || config.action.kind === 'none') { + return false; + } + + if (step.status === 'fulfilled' || step.status === 'in-progress') { + return false; + } + + return this.prerequisitesFulfilled(step, stepMap); + } + + buildTimelineView(summary: BookingFulfillmentSummary | undefined): readonly TimelineViewModel[] { + if (!summary?.timeline) { + return []; + } + + return summary.timeline.map((event, index) => this.toTimelineViewModel(event, index)); + } + + hasFailure(steps: readonly StepViewModel[]): boolean { + return steps.some((step) => step.status === 'error' && !!step.errorMessage); + } + + statusLabel(status: FulfillmentStepState['status']): string { + switch (status) { + case 'pending': + return 'Chưa thực hiện'; + case 'in-progress': + return 'Đang xử lý'; + case 'fulfilled': + return 'Hoàn tất'; + case 'error': + return 'Gặp lỗi'; + default: + return 'Không xác định'; + } + } + + defaultDateTimeInput(): string { + return this.formatDateInput(new Date().toISOString()); + } + + formatDateInput(value: string | null | undefined): string { + if (!value) { + return ''; + } + + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return ''; + } + + const date = new Date(timestamp); + const pad = (input: number): string => input.toString().padStart(2, '0'); + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + return `${year}-${month}-${day}T${hours}:${minutes}`; + } + + toIsoString(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return null; + } + + return new Date(timestamp).toISOString(); + } + + resolveContractProvider( + rawValue: string, + ): (typeof EsignProviderEnum)[keyof typeof EsignProviderEnum] { + const provider = CONTRACT_PROVIDERS.find((option) => option.value === rawValue)?.value; + return provider ?? EsignProviderEnum.Native; + } + + private formatCurrency(value?: number | null): string { + if (value === undefined || value === null) { + return '--'; + } + return this.currencyFormatter.format(value); + } + + private formatDateTime(value?: string | null): string { + if (!value) { + return '--'; + } + + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return '--'; + } + + return this.dateTimeFormatter.format(new Date(timestamp)); + } + + private formatDateRange(start?: string | null, end?: string | null): string { + const startDisplay = this.formatDateTime(start); + const endDisplay = this.formatDateTime(end); + + if (startDisplay === '--' && endDisplay === '--') { + return '--'; + } + + return `${startDisplay} → ${endDisplay}`; + } + + private toStepViewModel( + step: FulfillmentStepState, + next: FulfillmentStepState | undefined, + isBusy: boolean, + stepMap: ReadonlyMap, + ): StepViewModel { + const config = this.stepConfig[step.step]; + const isCurrent = next?.step === step.step; + const action = config?.action ?? { kind: 'none' }; + const prerequisitesMet = this.prerequisitesFulfilled(step, stepMap); + const canPerform = + action.kind !== 'none' && + step.status !== 'fulfilled' && + step.status !== 'in-progress' && + prerequisitesMet; + + const disabled = isBusy || !canPerform; + + return { + step: step.step, + title: config?.title ?? step.step, + description: config?.description ?? '', + status: step.status, + isCurrent, + canPerform, + disabled, + errorMessage: step.error?.message, + completedAtDisplay: this.formatDateTime(step.completedAt), + action, + artifactEntries: this.artifactEntries(step), + } satisfies StepViewModel; + } + + private prerequisitesFulfilled( + step: FulfillmentStepState, + stepMap: ReadonlyMap, + ): boolean { + const requirements = step.requires; + if (!requirements || requirements.length === 0) { + return true; + } + + return requirements.every( + (requiredStepId) => stepMap.get(requiredStepId)?.status === 'fulfilled', + ); + } + + private artifactEntries(step: FulfillmentStepState): StepArtifactEntry[] { + const artifact = step.artifact; + if (!artifact) { + return []; + } + + const entries: StepArtifactEntry[] = []; + + if (artifact.rentalId) { + entries.push({ label: 'Mã đơn thuê', value: artifact.rentalId }); + } + + if (artifact.contractId) { + entries.push({ label: 'Mã hợp đồng', value: artifact.contractId }); + } + + if (artifact.inspectionId) { + entries.push({ label: 'Biên bản kiểm tra', value: artifact.inspectionId }); + } + + if (artifact.renterSignatureId) { + entries.push({ label: 'Chữ ký khách', value: artifact.renterSignatureId }); + } + + if (artifact.staffSignatureId) { + entries.push({ label: 'Chữ ký nhân viên', value: artifact.staffSignatureId }); + } + + if (artifact.vehicleReceipt) { + const { receivedAt, receivedByStaffId } = artifact.vehicleReceipt; + if (receivedAt) { + entries.push({ + label: 'Thời điểm bàn giao', + value: this.formatDateTime(receivedAt), + }); + } + + if (receivedByStaffId) { + entries.push({ label: 'Nhân viên bàn giao', value: receivedByStaffId }); + } + } + + return entries; + } + + private toTimelineViewModel(event: FulfillmentTimelineEvent, index: number): TimelineViewModel { + return { + key: `${event.step}-${index}`, + title: event.title, + description: event.description ?? undefined, + actorLabel: this.actorLabel(event.actor), + occurredAtDisplay: this.formatDateTime(event.occurredAt), + metadataEntries: this.metadataEntries(event), + } satisfies TimelineViewModel; + } + + private actorLabel(actor: FulfillmentTimelineEvent['actor']): string { + switch (actor) { + case 'renter': + return 'Khách thuê'; + case 'staff': + return 'Nhân viên'; + default: + return 'Hệ thống'; + } + } + + private metadataEntries(event: FulfillmentTimelineEvent): readonly { + readonly label: string; + readonly value: string; + }[] { + const metadata = event.metadata ?? {}; + const entries: { readonly label: string; readonly value: string }[] = []; + + for (const [key, value] of Object.entries(metadata)) { + if (!value) { + continue; + } + + entries.push({ + label: key, + value, + }); + } + + return entries; + } + + private vehicleLabel(vehicle: BookingFulfillmentSummary['vehicleDetails']): string { + if (!vehicle) { + return 'Chưa có thông tin xe'; + } + + const { make, model, modelYear } = vehicle; + const parts = [make?.trim(), model?.trim(), modelYear ? modelYear.toString() : undefined] + .filter((part): part is string => !!part && part.length > 0) + .join(' '); + + return parts.length > 0 ? parts : 'Chưa có thông tin xe'; + } + + private bookingStatusLabel(status: BookingFulfillmentSummary['status']): string { + switch (status) { + case BookingStatusEnum.PendingVerification: + return 'Chờ xác minh'; + case BookingStatusEnum.Verified: + return 'Đã xác minh'; + case BookingStatusEnum.Cancelled: + return 'Đã hủy'; + case BookingStatusEnum.RentalCreated: + return 'Đã tạo đơn thuê'; + default: + return 'Không rõ trạng thái'; + } + } + + private verificationStatusLabel(status: BookingFulfillmentSummary['verificationStatus']): string { + switch (status) { + case BookingVerificationStatusEnum.Pending: + return 'Chờ duyệt'; + case BookingVerificationStatusEnum.Approved: + return 'Đã duyệt'; + case BookingVerificationStatusEnum.RejectedMismatch: + case BookingVerificationStatusEnum.RejectedOther: + return 'Bị từ chối'; + default: + return 'Không rõ trạng thái'; + } + } + + private safeString(value: string | null | undefined): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } +} diff --git a/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts index 2d27776..2df0c21 100644 --- a/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts +++ b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts @@ -23,144 +23,16 @@ import { FulfillmentAnalyticsService, FulfillmentOrchestrator, FulfillmentStepId, - FulfillmentStepState, InspectionPayload, SignaturePayload, VehicleReceivePayload, } from '../../../../../core-logic/rental-fulfillment'; -import type { - BookingFulfillmentSummary, - FulfillmentTimelineEvent, -} from '../../../../../core-logic/rental-fulfillment'; -import { - EsignProvider as EsignProviderEnum, - BookingStatus as BookingStatusEnum, - BookingVerificationStatus as BookingVerificationStatusEnum, -} from '../../../../../../contract'; +import type { FulfillmentStepState } from '../../../../../core-logic/rental-fulfillment'; +import { EsignProvider as EsignProviderEnum } from '../../../../../../contract'; import { InspectionFormComponent } from '../../components/inspection-form/inspection-form'; import { SignatureStepComponent } from '../../components/signature-step/signature-step'; - -interface SummaryViewModel { - readonly bookingId: string; - readonly bookingStatusLabel: string; - readonly verificationStatusLabel: string; - readonly renterName: string; - readonly renterLicense: string; - readonly renterAddress: string; - readonly vehicleLabel: string; - readonly depositDisplay: string; - readonly rentalWindowDisplay: string; - readonly createdAtDisplay: string; - readonly rentalId?: string; - readonly contractId?: string; - readonly inspectionId?: string; - readonly renterSignatureId?: string; - readonly staffSignatureId?: string; - readonly vehicleReceipt?: { - readonly receivedAtDisplay: string; - readonly receivedByStaffId?: string; - }; -} - -interface StepViewModel { - readonly step: FulfillmentStepId; - readonly title: string; - readonly description: string; - readonly status: FulfillmentStepState['status']; - readonly isCurrent: boolean; - readonly canPerform: boolean; - readonly disabled: boolean; - readonly errorMessage?: string; - readonly completedAtDisplay?: string; - readonly action: StepActionConfig; - readonly artifactEntries: readonly StepArtifactEntry[]; -} - -interface TimelineViewModel { - readonly key: string; - readonly title: string; - readonly description?: string; - readonly actorLabel: string; - readonly occurredAtDisplay: string; - readonly metadataEntries: readonly { readonly label: string; readonly value: string }[]; -} - -type StepActionConfig = - | { readonly kind: 'button'; readonly label: string } - | { readonly kind: 'contract' } - | { readonly kind: 'inspection' } - | { readonly kind: 'signature'; readonly role: 'renter' | 'staff' } - | { readonly kind: 'vehicle' } - | { readonly kind: 'none' }; - -interface StepArtifactEntry { - readonly label: string; - readonly value: string; -} - -interface SummaryArtifacts { - readonly rentalId?: string; - readonly contractId?: string; - readonly inspectionId?: string; - readonly renterSignatureId?: string; - readonly staffSignatureId?: string; - readonly vehicleReceipt?: { - readonly receivedAt?: string; - readonly receivedByStaffId?: string; - }; -} - -const STEP_CONFIG: Record< - FulfillmentStepId, - { readonly title: string; readonly description: string; readonly action: StepActionConfig } -> = { - checkin: { - title: 'Duyệt đặt xe', - description: 'Xác nhận khách đã hoàn tất kiểm tra và cho phép tạo đơn thuê.', - action: { kind: 'button', label: 'Xác nhận check-in booking' }, - }, - 'create-rental': { - title: 'Tạo đơn thuê', - description: 'Tạo đơn thuê từ booking đã duyệt để bắt đầu quy trình bàn giao.', - action: { kind: 'button', label: 'Tạo đơn thuê' }, - }, - 'create-contract': { - title: 'Phát hành hợp đồng', - description: 'Khởi tạo hợp đồng điện tử cho đơn thuê và gửi tới các bên liên quan.', - action: { kind: 'contract' }, - }, - inspection: { - title: 'Ghi nhận kiểm tra xe', - description: 'Thu thập thông tin kiểm tra xe trước khi bàn giao cho khách.', - action: { kind: 'inspection' }, - }, - 'sign-renter': { - title: 'Khách ký hợp đồng', - description: 'Lấy chữ ký số của khách thuê trên hợp đồng điện tử.', - action: { kind: 'signature', role: 'renter' }, - }, - 'sign-staff': { - title: 'Nhân viên ký hợp đồng', - description: 'Nhân viên xác nhận hợp đồng sau khi khách đã ký.', - action: { kind: 'signature', role: 'staff' }, - }, - 'vehicle-receive': { - title: 'Xác nhận bàn giao xe', - description: 'Ghi nhận thời điểm bàn giao xe và người phụ trách.', - action: { kind: 'vehicle' }, - }, -}; - -const CONTRACT_PROVIDERS: readonly { - readonly value: (typeof EsignProviderEnum)[keyof typeof EsignProviderEnum]; - readonly label: string; -}[] = [ - { value: EsignProviderEnum.Native, label: 'EV Rental eSign' }, - { value: EsignProviderEnum.Docusign, label: 'DocuSign' }, - { value: EsignProviderEnum.Adobesign, label: 'Adobe Sign' }, - { value: EsignProviderEnum.Signnow, label: 'SignNow' }, - { value: EsignProviderEnum.Other, label: 'Nhà cung cấp khác' }, -]; +import { SummaryViewModel, StepViewModel, TimelineViewModel } from './fulfillment-page.models'; +import { FulfillmentPagePresenter } from './fulfillment-page.presenter'; @Component({ selector: 'app-fulfillment-page', @@ -187,6 +59,7 @@ export class FulfillmentPage { private readonly formBuilder = inject(NonNullableFormBuilder); private readonly cdr = inject(ChangeDetectorRef); private readonly environmentInjector = inject(EnvironmentInjector); + private readonly presenter = new FulfillmentPagePresenter(); private lastInitializedBookingId: string | null = null; private readonly headingRef = viewChild>('pageHeading'); @@ -196,12 +69,12 @@ export class FulfillmentPage { private readonly _contractProvider = signal< (typeof EsignProviderEnum)[keyof typeof EsignProviderEnum] >(EsignProviderEnum.Native); - readonly contractProviders = CONTRACT_PROVIDERS; + readonly contractProviders = this.presenter.contractProviders; readonly selectedContractProvider = this._contractProvider.asReadonly(); private readonly vehicleReceiveSubmitAttempted = signal(false); readonly vehicleReceiveForm = this.formBuilder.group({ - receivedAt: this.formBuilder.control(this._defaultDateTimeInput(), { + receivedAt: this.formBuilder.control(this.presenter.defaultDateTimeInput(), { validators: [Validators.required], }), }); @@ -218,52 +91,29 @@ export class FulfillmentPage { initialValue: this.route.snapshot.paramMap.get('bookingId') ?? '', }, ); - - 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', - }); - readonly summaryView = computed(() => { const summary = this.summary(); if (!summary) { return null; } - const artifacts = this._collectSummaryArtifacts(summary); - return this._buildSummaryView(summary, artifacts); + const artifacts = this.presenter.collectSummaryArtifacts(this.steps(), summary); + return this.presenter.buildSummaryView(summary, artifacts); }); readonly stepViewModels = computed(() => { - const steps = this.steps(); - const next = this.nextStep(); - const busy = this.isBusy(); - const stepMap = new Map(steps.map((stepState) => [stepState.step, stepState] as const)); - - return steps.map((step) => this._toStepViewModel(step, next, busy, stepMap)); + return this.presenter.buildStepViewModels({ + steps: this.steps(), + nextStep: this.nextStep(), + isBusy: this.isBusy(), + }); }); readonly timelineView = computed(() => { - const summary = this.summary(); - if (!summary?.timeline) { - return []; - } - - return summary.timeline.map((event, index) => this._toTimelineViewModel(event, index)); + return this.presenter.buildTimelineView(this.summary()); }); - readonly hasFailure = computed(() => - this.stepViewModels().some((step) => step.status === 'error' && !!step.errorMessage), - ); + readonly hasFailure = computed(() => this.presenter.hasFailure(this.stepViewModels())); readonly initializationErrorMessage = computed(() => this.initializationError()); @@ -285,8 +135,12 @@ export class FulfillmentPage { }); effect(() => { - const shouldDisable = this.isBusy() || !this._canPerformStep('vehicle-receive'); - if (shouldDisable) { + const canPerform = this.presenter.canPerformStep( + 'vehicle-receive', + this.steps(), + this.isBusy(), + ); + if (!canPerform) { this.vehicleReceiveForm.disable({ emitEvent: false }); } else { this.vehicleReceiveForm.enable({ emitEvent: false }); @@ -332,7 +186,7 @@ export class FulfillmentPage { } onCheckIn(): void { - if (this.isBusy() || !this._canPerformStep('checkin')) { + if (!this.presenter.canPerformStep('checkin', this.steps(), this.isBusy())) { return; } @@ -345,7 +199,7 @@ export class FulfillmentPage { } onCreateRental(): void { - if (this.isBusy() || !this._canPerformStep('create-rental')) { + if (!this.presenter.canPerformStep('create-rental', this.steps(), this.isBusy())) { return; } @@ -363,16 +217,11 @@ export class FulfillmentPage { return; } - const selectedValue = - select.value as (typeof EsignProviderEnum)[keyof typeof EsignProviderEnum]; - const provider = - CONTRACT_PROVIDERS.find((option) => option.value === selectedValue)?.value ?? - EsignProviderEnum.Native; - this._contractProvider.set(provider); + this._contractProvider.set(this.presenter.resolveContractProvider(select.value)); } onCreateContract(): void { - if (this.isBusy() || !this._canPerformStep('create-contract')) { + if (!this.presenter.canPerformStep('create-contract', this.steps(), this.isBusy())) { return; } @@ -385,7 +234,7 @@ export class FulfillmentPage { } onSubmitInspection(payload: InspectionPayload): void { - if (this.isBusy() || !this._canPerformStep('inspection')) { + if (!this.presenter.canPerformStep('inspection', this.steps(), this.isBusy())) { return; } @@ -412,7 +261,7 @@ export class FulfillmentPage { onSubmitSignature(payload: SignaturePayload): void { const stepId: FulfillmentStepId = payload.role === 'renter' ? 'sign-renter' : 'sign-staff'; - if (this.isBusy() || !this._canPerformStep(stepId)) { + if (!this.presenter.canPerformStep(stepId, this.steps(), this.isBusy())) { return; } @@ -427,7 +276,7 @@ export class FulfillmentPage { onSubmitVehicleReceive(): void { this.vehicleReceiveSubmitAttempted.set(true); - if (this.isBusy() || !this._canPerformStep('vehicle-receive')) { + if (!this.presenter.canPerformStep('vehicle-receive', this.steps(), this.isBusy())) { return; } @@ -437,7 +286,7 @@ export class FulfillmentPage { } const receivedAtInput = this.vehicleReceiveForm.controls.receivedAt.value; - const receivedAtIso = this._toIsoString(receivedAtInput); + const receivedAtIso = this.presenter.toIsoString(receivedAtInput); if (!receivedAtIso) { this.vehicleReceiveForm.controls.receivedAt.setErrors({ required: true }); return; @@ -459,7 +308,7 @@ export class FulfillmentPage { next: () => { this.vehicleReceiveSubmitAttempted.set(false); this.vehicleReceiveForm.reset( - { receivedAt: this._defaultDateTimeInput() }, + { receivedAt: this.presenter.defaultDateTimeInput() }, { emitEvent: false }, ); }, @@ -480,18 +329,7 @@ export class FulfillmentPage { } statusLabel(status: FulfillmentStepState['status']): string { - switch (status) { - case 'pending': - return 'Chưa thực hiện'; - case 'in-progress': - return 'Đang xử lý'; - case 'fulfilled': - return 'Hoàn tất'; - case 'error': - return 'Gặp lỗi'; - default: - return 'Không xác định'; - } + return this.presenter.statusLabel(status); } private _loadFulfillment(rawBookingId: string): void { @@ -521,375 +359,6 @@ export class FulfillmentPage { }); } - private _isActionable(stepId: FulfillmentStepId): boolean { - const config = STEP_CONFIG[stepId]; - return !!config && config.action.kind !== 'none'; - } - - private _toStepViewModel( - step: FulfillmentStepState, - next: FulfillmentStepState | undefined, - isBusy: boolean, - stepMap: ReadonlyMap, - ): StepViewModel { - const config = STEP_CONFIG[step.step]; - const isCurrent = next?.step === step.step; - const action = config?.action ?? { kind: 'none' }; - const prerequisitesMet = this._prerequisitesFulfilled(step, stepMap); - const canPerform = - action.kind !== 'none' && - step.status !== 'fulfilled' && - step.status !== 'in-progress' && - prerequisitesMet; - const disabled = isBusy || !canPerform; - - return { - step: step.step, - title: config?.title ?? step.step, - description: config?.description ?? '', - status: step.status, - isCurrent, - canPerform, - disabled, - errorMessage: step.error?.message, - completedAtDisplay: this._formatDateTime(step.completedAt), - action, - artifactEntries: this._artifactEntries(step), - } satisfies StepViewModel; - } - - private _prerequisitesFulfilled( - step: FulfillmentStepState, - stepMap: ReadonlyMap, - ): boolean { - const requirements = step.requires; - if (!requirements || requirements.length === 0) { - return true; - } - - return requirements.every( - (requiredStepId) => stepMap.get(requiredStepId)?.status === 'fulfilled', - ); - } - - private _canPerformStep(stepId: FulfillmentStepId): boolean { - const stepMap = new Map(this.steps().map((state) => [state.step, state] as const)); - const step = stepMap.get(stepId); - if (!step) { - return false; - } - - const config = STEP_CONFIG[stepId]; - if (!config || config.action.kind === 'none') { - return false; - } - - if (step.status === 'fulfilled' || step.status === 'in-progress') { - return false; - } - - return this._prerequisitesFulfilled(step, stepMap); - } - - private _artifactEntries(step: FulfillmentStepState): StepArtifactEntry[] { - const artifact = step.artifact; - if (!artifact) { - return []; - } - - const entries: StepArtifactEntry[] = []; - - if (artifact.rentalId) { - entries.push({ label: 'Mã đơn thuê', value: artifact.rentalId }); - } - - if (artifact.contractId) { - entries.push({ label: 'Mã hợp đồng', value: artifact.contractId }); - } - - if (artifact.inspectionId) { - entries.push({ label: 'Biên bản kiểm tra', value: artifact.inspectionId }); - } - - if (artifact.renterSignatureId) { - entries.push({ label: 'Chữ ký khách', value: artifact.renterSignatureId }); - } - - if (artifact.staffSignatureId) { - entries.push({ label: 'Chữ ký nhân viên', value: artifact.staffSignatureId }); - } - - if (artifact.vehicleReceipt) { - const { receivedAt, receivedByStaffId } = artifact.vehicleReceipt; - if (receivedAt) { - entries.push({ - label: 'Thời điểm bàn giao', - value: this._formatDateTime(receivedAt), - }); - } - - if (receivedByStaffId) { - entries.push({ label: 'Nhân viên bàn giao', value: receivedByStaffId }); - } - } - - return entries; - } - - private _collectSummaryArtifacts(summary: BookingFulfillmentSummary): SummaryArtifacts { - let artifacts: SummaryArtifacts = {}; - - for (const step of this.steps()) { - const stepArtifact = step.artifact; - if (!stepArtifact) { - continue; - } - - if (stepArtifact.rentalId && !artifacts.rentalId) { - artifacts = { ...artifacts, rentalId: stepArtifact.rentalId }; - } - - if (stepArtifact.contractId && !artifacts.contractId) { - artifacts = { ...artifacts, contractId: stepArtifact.contractId }; - } - - if (stepArtifact.inspectionId && !artifacts.inspectionId) { - artifacts = { ...artifacts, inspectionId: stepArtifact.inspectionId }; - } - - if (stepArtifact.renterSignatureId && !artifacts.renterSignatureId) { - artifacts = { ...artifacts, renterSignatureId: stepArtifact.renterSignatureId }; - } - - if (stepArtifact.staffSignatureId && !artifacts.staffSignatureId) { - artifacts = { ...artifacts, staffSignatureId: stepArtifact.staffSignatureId }; - } - - if (stepArtifact.vehicleReceipt) { - const currentReceipt = artifacts.vehicleReceipt ?? {}; - artifacts = { - ...artifacts, - vehicleReceipt: { - receivedAt: stepArtifact.vehicleReceipt.receivedAt ?? currentReceipt.receivedAt, - receivedByStaffId: - stepArtifact.vehicleReceipt.receivedByStaffId ?? currentReceipt.receivedByStaffId, - }, - }; - } - } - - if (!artifacts.rentalId && summary.rental?.rentalId) { - artifacts = { ...artifacts, rentalId: summary.rental.rentalId }; - } - - return artifacts; - } - - private _buildSummaryView( - summary: BookingFulfillmentSummary, - artifacts: SummaryArtifacts, - ): SummaryViewModel { - const booking = summary.booking; - const renter = summary.renterProfile; - const vehicle = summary.vehicleDetails; - const rentalId = this._safeString(artifacts.rentalId ?? summary.rental?.rentalId); - const contractId = this._safeString(artifacts.contractId); - const inspectionId = this._safeString(artifacts.inspectionId); - const renterSignatureId = this._safeString(artifacts.renterSignatureId); - const staffSignatureId = this._safeString(artifacts.staffSignatureId); - const vehicleReceipt = artifacts.vehicleReceipt; - - return { - bookingId: summary.bookingId, - bookingStatusLabel: this._bookingStatusLabel(summary.status), - verificationStatusLabel: this._verificationStatusLabel(summary.verificationStatus), - renterName: renter?.userName?.trim() || 'Chưa có tên khách', - renterLicense: renter?.driverLicenseNo?.trim() || 'Không có giấy phép', - renterAddress: renter?.address?.trim() || 'Không có địa chỉ', - vehicleLabel: this._vehicleLabel(vehicle), - depositDisplay: this._formatCurrency(vehicle?.depositPrice), - rentalWindowDisplay: this._formatDateRange(booking?.startTime, booking?.endTime), - createdAtDisplay: this._formatDateTime(booking?.bookingCreatedAt), - rentalId, - contractId, - inspectionId, - renterSignatureId, - staffSignatureId, - vehicleReceipt: - vehicleReceipt && (vehicleReceipt.receivedAt || vehicleReceipt.receivedByStaffId) - ? { - receivedAtDisplay: this._formatDateTime(vehicleReceipt.receivedAt), - receivedByStaffId: this._safeString(vehicleReceipt.receivedByStaffId), - } - : undefined, - } satisfies SummaryViewModel; - } - - private _toTimelineViewModel(event: FulfillmentTimelineEvent, index: number): TimelineViewModel { - return { - key: `${event.step}-${index}`, - title: event.title, - description: event.description ?? undefined, - actorLabel: this._actorLabel(event.actor), - occurredAtDisplay: this._formatDateTime(event.occurredAt), - metadataEntries: this._metadataEntries(event), - } satisfies TimelineViewModel; - } - - private _actorLabel(actor: FulfillmentTimelineEvent['actor']): string { - switch (actor) { - case 'renter': - return 'Khách thuê'; - case 'staff': - return 'Nhân viên'; - default: - return 'Hệ thống'; - } - } - - private _metadataEntries(event: FulfillmentTimelineEvent): readonly { - readonly label: string; - readonly value: string; - }[] { - const metadata = event.metadata ?? {}; - const entries: { readonly label: string; readonly value: string }[] = []; - - for (const [key, value] of Object.entries(metadata)) { - if (!value) { - continue; - } - - entries.push({ - label: key, - value, - }); - } - - return entries; - } - - private _vehicleLabel(vehicle: BookingFulfillmentSummary['vehicleDetails']): string { - if (!vehicle) { - return 'Chưa có thông tin xe'; - } - - const { make, model, modelYear } = vehicle; - const parts = [make?.trim(), model?.trim(), modelYear ? modelYear.toString() : undefined] - .filter((part): part is string => !!part && part.length > 0) - .join(' '); - - return parts.length > 0 ? parts : 'Chưa có thông tin xe'; - } - - private _bookingStatusLabel(status: BookingFulfillmentSummary['status']): string { - switch (status) { - case BookingStatusEnum.PendingVerification: - return 'Chờ xác minh'; - case BookingStatusEnum.Verified: - return 'Đã xác minh'; - case BookingStatusEnum.Cancelled: - return 'Đã hủy'; - case BookingStatusEnum.RentalCreated: - return 'Đã tạo đơn thuê'; - default: - return 'Không rõ trạng thái'; - } - } - - private _verificationStatusLabel( - status: BookingFulfillmentSummary['verificationStatus'], - ): string { - switch (status) { - case BookingVerificationStatusEnum.Pending: - return 'Chờ duyệt'; - case BookingVerificationStatusEnum.Approved: - return 'Đã duyệt'; - case BookingVerificationStatusEnum.RejectedMismatch: - case BookingVerificationStatusEnum.RejectedOther: - return 'Bị từ chối'; - default: - return 'Không rõ trạng thái'; - } - } - - private _formatCurrency(value?: number | null): string { - if (value === undefined || value === null) { - return '--'; - } - - return this.currencyFormatter.format(value); - } - - private _formatDateTime(value?: string | null): string { - if (!value) { - return '--'; - } - - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return '--'; - } - - return this.dateTimeFormatter.format(new Date(timestamp)); - } - - private _formatDateRange(start?: string | null, end?: string | null): string { - const startDisplay = this._formatDateTime(start); - const endDisplay = this._formatDateTime(end); - - if (startDisplay === '--' && endDisplay === '--') { - return '--'; - } - - return `${startDisplay} → ${endDisplay}`; - } - - private _defaultDateTimeInput(): string { - return this._formatDateInput(new Date().toISOString()); - } - - private _formatDateInput(value: string | null | undefined): string { - if (!value) { - return ''; - } - - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return ''; - } - - const date = new Date(timestamp); - const pad = (input: number): string => input.toString().padStart(2, '0'); - const year = date.getFullYear(); - const month = pad(date.getMonth() + 1); - const day = pad(date.getDate()); - const hours = pad(date.getHours()); - const minutes = pad(date.getMinutes()); - return `${year}-${month}-${day}T${hours}:${minutes}`; - } - - private _toIsoString(value: string | null | undefined): string | null { - if (!value) { - return null; - } - - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return null; - } - - return new Date(timestamp).toISOString(); - } - - private _safeString(value: string | null | undefined): string | undefined { - if (!value) { - return undefined; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - private _setPageTitle(bookingId: string): void { this.title.setTitle(`Xử lý đặt xe · ${this._shortIdentifier(bookingId)}`); } diff --git a/src/app/features/staff/renter-management/renter-management.html b/src/app/features/staff/renter-management/renter-management.html index 7db5408..5e64d06 100644 --- a/src/app/features/staff/renter-management/renter-management.html +++ b/src/app/features/staff/renter-management/renter-management.html @@ -120,8 +120,8 @@

{{ card.name }}

-
License
-
{{ card.licenseDisplay }}
+
{{ card.primaryKycLabel }}
+
{{ card.primaryKycValue }}
Address
@@ -190,9 +190,15 @@

Identity

{{ detail.renterId }}
-
Driver license
-
{{ detail.driverLicense ?? 'No license on file' }}
+
{{ detail.primaryKycLabel }}
+
{{ detail.primaryKycValue }}
+ @if (detail.showDriverLicense) { +
+
Driver license
+
{{ detail.driverLicenseDisplay ?? 'Not provided' }}
+
+ }
Date of birth
{{ detail.dateOfBirth ?? 'Unavailable' }}
@@ -204,6 +210,44 @@

Identity

+
+

KYC documents

+ @if (detail.kycDocuments.length > 0) { +
    + @for (document of detail.kycDocuments; track document.id) { +
  • +
    + {{ document.label }} + + {{ document.statusText }} + +
    +
    +
    +
    Document number
    +
    {{ document.numberDisplay }}
    +
    + @if (document.lastUpdatedDisplay) { +
    +
    Last updated
    +
    {{ document.lastUpdatedDisplay }}
    +
    + } +
    +
  • + } +
+ } @else { +

No KYC documents have been submitted.

+ } +
+

Risk assessment

diff --git a/src/app/features/staff/renter-management/renter-management.scss b/src/app/features/staff/renter-management/renter-management.scss index 02ed813..a09f9f0 100644 --- a/src/app/features/staff/renter-management/renter-management.scss +++ b/src/app/features/staff/renter-management/renter-management.scss @@ -555,6 +555,64 @@ font-size: 0.95rem; } +.kyc-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.kyc-list__item { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + border: 1px solid var(--mat-sys-outline-variant, #d1d5db); + background: rgb(248 250 252); +} + +.kyc-list__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.kyc-list__label { + font-weight: 600; + font-size: 0.95rem; +} + +.kyc-list__details { + margin: 0; + display: grid; + gap: 0.5rem; +} + +.kyc-list__details div { + display: grid; + gap: 0.15rem; +} + +.kyc-list__details dt { + font-size: 0.8rem; + color: var(--mat-sys-on-surface-variant, #64748b); +} + +.kyc-list__details dd { + margin: 0; + font-size: 0.95rem; + color: var(--mat-sys-on-surface, #111827); +} + +.kyc-empty { + margin: 0; + font-size: 0.9rem; + color: var(--mat-sys-on-surface-variant, #475569); +} + .risk-summary { display: grid; gap: 0.75rem; diff --git a/src/app/features/staff/renter-management/renter-management.ts b/src/app/features/staff/renter-management/renter-management.ts index f6454eb..1c3f51b 100644 --- a/src/app/features/staff/renter-management/renter-management.ts +++ b/src/app/features/staff/renter-management/renter-management.ts @@ -10,7 +10,12 @@ import { } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { finalize, take } from 'rxjs'; -import { RentersService, StaffRenterRecord } from '../../../core-logic/renters/renters.service'; +import { + RentersService, + StaffKycDocument, + StaffRenterRecord, +} from '../../../core-logic/renters/renters.service'; +import { KycType } from '../../../../contract'; interface RiskFilterDescriptor { readonly key: RiskFilterKey; @@ -33,23 +38,37 @@ interface RenterCardViewModel { readonly record: StaffRenterRecord; readonly name: string; readonly identifier: string; - readonly licenseDisplay: string; + readonly primaryKycLabel: string; + readonly primaryKycValue: string; readonly locationDisplay: string; readonly dateOfBirthDisplay: string; readonly riskScoreDisplay: string; readonly riskBadge?: StatusBadge; } +interface KycDocumentViewModel { + readonly id: string; + readonly label: string; + readonly numberDisplay: string; + readonly statusText: string; + readonly statusTone: StatusTone; + readonly lastUpdatedDisplay?: string; +} + interface SelectedRenterViewModel { readonly record: StaffRenterRecord; readonly name: string; readonly renterId: string; - readonly driverLicense?: string; + readonly primaryKycLabel: string; + readonly primaryKycValue: string; + readonly showDriverLicense: boolean; + readonly driverLicenseDisplay?: string; readonly address?: string; readonly dateOfBirth?: string; readonly riskScore?: number; readonly riskBadge?: StatusBadge; readonly riskDescription: string; + readonly kycDocuments: readonly KycDocumentViewModel[]; } interface ForceVerifyResult { @@ -140,11 +159,14 @@ export class RenterManagement { this.filteredRenters().map((record) => { const riskLevel = this._determineRiskLevel(record.riskScore); const badge = RISK_BADGES[riskLevel]; + const primaryDocument = this._selectPrimaryKyc(record.kycDocuments); + const primaryDisplay = this._buildPrimaryKycDisplay(record, primaryDocument); return { record, name: record.name, identifier: record.renterId, - licenseDisplay: record.driverLicense ?? 'No license on file', + primaryKycLabel: primaryDisplay.label, + primaryKycValue: primaryDisplay.value, locationDisplay: record.address ?? 'Address not provided', dateOfBirthDisplay: this._formatDate(record.dateOfBirth) ?? 'Date of birth unavailable', riskScoreDisplay: @@ -161,16 +183,30 @@ export class RenterManagement { } const riskLevel = this._determineRiskLevel(record.riskScore); + const primaryDocument = this._selectPrimaryKyc(record.kycDocuments); + const primaryDisplay = this._buildPrimaryKycDisplay(record, primaryDocument); + const driverLicense = record.driverLicense; + const showDriverLicense = Boolean( + driverLicense && (!primaryDocument || primaryDocument.type !== KycType.DriverLicense), + ); + const kycDocuments = record.kycDocuments.map((document, index) => + this._mapKycDocument(document, index), + ); + return { record, name: record.name, renterId: record.renterId, - driverLicense: record.driverLicense, + primaryKycLabel: primaryDisplay.label, + primaryKycValue: primaryDisplay.value, + showDriverLicense, + driverLicenseDisplay: showDriverLicense ? driverLicense : undefined, address: record.address, dateOfBirth: this._formatDate(record.dateOfBirth) ?? undefined, riskScore: record.riskScore, riskBadge: RISK_BADGES[riskLevel], riskDescription: this._describeRisk(riskLevel), + kycDocuments, } satisfies SelectedRenterViewModel; }); @@ -180,6 +216,14 @@ export class RenterManagement { year: 'numeric', }); + private readonly dateTimeFormatter = new Intl.DateTimeFormat('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + constructor() { this.refresh(); } @@ -280,6 +324,111 @@ export class RenterManagement { } } + private _selectPrimaryKyc(documents: readonly StaffKycDocument[]): StaffKycDocument | undefined { + if (!documents || documents.length === 0) { + return undefined; + } + + const priority: readonly StaffKycDocument['type'][] = [ + KycType.DriverLicense, + KycType.NationalId, + KycType.Passport, + KycType.Other, + 'unknown', + ]; + + for (const preferred of priority) { + for (const document of documents) { + if (document.type === preferred) { + return document; + } + } + } + + return documents[0]; + } + + private _buildPrimaryKycDisplay( + record: StaffRenterRecord, + primaryDocument: StaffKycDocument | undefined, + ): { label: string; value: string } { + if (primaryDocument) { + return { + label: primaryDocument.label, + value: primaryDocument.documentNumber ?? 'Not provided', + }; + } + + if (record.driverLicense) { + return { + label: 'Driver license', + value: record.driverLicense, + }; + } + + return { + label: 'KYC documents', + value: 'No KYC documents on file', + }; + } + + private _mapKycDocument(document: StaffKycDocument, index: number): KycDocumentViewModel { + const numberDisplay = document.documentNumber ?? 'Not provided'; + const statusText = document.status ?? this._defaultStatusLabel(document.statusLevel); + const statusTone = this._resolveStatusTone(document.statusLevel); + const lastUpdatedDisplay = document.lastUpdated + ? (this._formatDateTime(document.lastUpdated) ?? undefined) + : undefined; + + return { + id: 'kyc-' + document.type + '-' + index, + label: document.label, + numberDisplay, + statusText, + statusTone, + lastUpdatedDisplay, + } satisfies KycDocumentViewModel; + } + + private _defaultStatusLabel(level: StaffKycDocument['statusLevel']): string { + switch (level) { + case 'approved': + return 'Approved'; + case 'pending': + return 'Pending review'; + case 'rejected': + return 'Rejected'; + default: + return 'Status unknown'; + } + } + + private _resolveStatusTone(level: StaffKycDocument['statusLevel']): StatusTone { + switch (level) { + case 'approved': + return 'success'; + case 'rejected': + return 'danger'; + case 'pending': + return 'pending'; + default: + return 'info'; + } + } + + private _formatDateTime(isoString: string | undefined): string | null { + if (!isoString) { + return null; + } + + const date = new Date(isoString); + if (Number.isNaN(date.getTime())) { + return null; + } + + return this.dateTimeFormatter.format(date); + } + private _matchesSearch(record: StaffRenterRecord, query: string): boolean { if (!query) { return true; @@ -289,6 +438,15 @@ export class RenterManagement { .map((value) => value?.toLowerCase() ?? '') .filter((value) => value.length > 0); + for (const document of record.kycDocuments) { + if (document.documentNumber) { + const normalized = document.documentNumber.toLowerCase(); + if (normalized.length > 0) { + haystacks.push(normalized); + } + } + } + return haystacks.some((value) => value.includes(query)); } diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.models.ts b/src/app/features/staff/staff-dashboard/staff-dashboard.models.ts new file mode 100644 index 0000000..84fcbf2 --- /dev/null +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.models.ts @@ -0,0 +1,131 @@ +import { + BookingStatus as BookingStatusEnum, + BookingVerificationStatus as BookingVerificationStatusEnum, + RentalStatus as RentalStatusEnum, +} from '../../../../contract'; +import type { BookingStatus, BookingVerificationStatus, RentalStatus } from '../../../../contract'; +import type { StaffBookingRecord } from '../../../core-logic/bookings/bookings.service'; + +export type BookingTabKey = 'all' | 'pendingVerification' | 'verified' | 'cancelled'; + +export interface BookingStatusCounters { + readonly total: number; + readonly pendingVerification: number; + readonly verified: number; + readonly cancelled: number; +} + +export interface BookingTabDescriptor { + readonly key: BookingTabKey; + readonly label: string; + readonly description: string; + readonly counterKey: keyof BookingStatusCounters; +} + +export type StatusTone = 'pending' | 'success' | 'danger' | 'info'; + +export interface StatusBadge { + readonly text: string; + readonly tone: StatusTone; +} + +export interface BookingCardViewModel { + readonly key: string; + readonly record: StaffBookingRecord; + readonly customerDisplay: string; + readonly bookingCreatedDisplay: string; + readonly rentalStartDisplay: string; + readonly rentalEndDisplay: string; + readonly totalAmountDisplay: string; + readonly depositDisplay: string; + readonly vehicleDisplay: string; + readonly rentalSummary: string; + readonly badges: readonly StatusBadge[]; +} + +export interface SelectedBookingViewModel { + readonly record: StaffBookingRecord; + readonly customerDisplay: string; + readonly badges: readonly StatusBadge[]; + readonly createdAtDisplay: string; + readonly startDateTimeDisplay: string; + readonly endDateTimeDisplay: string; + readonly totalAmountDisplay: string; + readonly depositDisplay: string; + readonly vehicleDisplay: string; + readonly rentalSummary: string; + readonly rentalDays?: number; +} + +export const BOOKING_TABS: readonly BookingTabDescriptor[] = [ + { + key: 'all', + label: 'All bookings', + description: 'Every booking status', + counterKey: 'total', + }, + { + key: 'pendingVerification', + label: 'Pending verification', + description: 'Awaiting review', + counterKey: 'pendingVerification', + }, + { + key: 'verified', + label: 'Verified', + description: 'Approved bookings', + counterKey: 'verified', + }, + { + key: 'cancelled', + label: 'Cancelled', + description: 'No longer active', + counterKey: 'cancelled', + }, +] as const; + +export const BOOKING_STATUS_BADGES: Record = { + [BookingStatusEnum.PendingVerification]: { text: 'Pending Verification', tone: 'pending' }, + [BookingStatusEnum.Verified]: { text: 'Verified', tone: 'success' }, + [BookingStatusEnum.Cancelled]: { text: 'Cancelled', tone: 'danger' }, + [BookingStatusEnum.RentalCreated]: { text: 'Rental Created', tone: 'info' }, +}; + +export const BOOKING_VERIFICATION_BADGES: Record = { + [BookingVerificationStatusEnum.Pending]: { text: 'Verification Pending', tone: 'pending' }, + [BookingVerificationStatusEnum.Approved]: { text: 'Verification Approved', tone: 'success' }, + [BookingVerificationStatusEnum.RejectedMismatch]: { + text: 'Verification Rejected', + tone: 'danger', + }, + [BookingVerificationStatusEnum.RejectedOther]: { + text: 'Verification Rejected', + tone: 'danger', + }, +}; + +export const RENTAL_STATUS_LABELS: Record = { + [RentalStatusEnum.Reserved]: 'Reserved', + [RentalStatusEnum.InProgress]: 'In Progress', + [RentalStatusEnum.Completed]: 'Completed', + [RentalStatusEnum.Late]: 'Late', + [RentalStatusEnum.Cancelled]: 'Cancelled', +}; + +export const RENTAL_STATUS_BADGES: Record = { + [RentalStatusEnum.Reserved]: { text: 'Rental Reserved', tone: 'info' }, + [RentalStatusEnum.InProgress]: { text: 'Rental In Progress', tone: 'info' }, + [RentalStatusEnum.Completed]: { text: 'Rental Completed', tone: 'success' }, + [RentalStatusEnum.Late]: { text: 'Rental Late', tone: 'danger' }, + [RentalStatusEnum.Cancelled]: { text: 'Rental Cancelled', tone: 'danger' }, +}; + +export const RENTAL_NOT_CREATED_BADGE: StatusBadge = { + text: 'Rental Not Created', + tone: 'pending', +}; + +export const RENTAL_LINKED_BADGE: StatusBadge = { + text: 'Rental Linked', + tone: 'info', +}; diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.presenter.ts b/src/app/features/staff/staff-dashboard/staff-dashboard.presenter.ts new file mode 100644 index 0000000..c446d7b --- /dev/null +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.presenter.ts @@ -0,0 +1,378 @@ +import { + BookingCardViewModel, + BookingStatusCounters, + BookingTabKey, + BOOKING_STATUS_BADGES, + BOOKING_VERIFICATION_BADGES, + RENTAL_LINKED_BADGE, + RENTAL_NOT_CREATED_BADGE, + RENTAL_STATUS_BADGES, + RENTAL_STATUS_LABELS, + SelectedBookingViewModel, + StatusBadge, +} from './staff-dashboard.models'; +import type { StaffBookingRecord } from '../../../core-logic/bookings/bookings.service'; +import { + BookingStatus as BookingStatusEnum, + BookingVerificationStatus as BookingVerificationStatusEnum, + RentalStatus as RentalStatusEnum, +} from '../../../../contract'; + +interface FilterInput { + readonly records: readonly StaffBookingRecord[]; + readonly tab: BookingTabKey; + readonly query: string; +} + +export class StaffDashboardPresenter { + private readonly currencyFormatter = new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0, + }); + + private readonly dateFormatter = new Intl.DateTimeFormat('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + + private readonly dateTimeFormatter = new Intl.DateTimeFormat('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + calculateStatusCounters(records: readonly StaffBookingRecord[]): BookingStatusCounters { + let pending = 0; + let verified = 0; + let cancelled = 0; + + for (const record of records) { + if (record.verificationStatus === BookingVerificationStatusEnum.Pending) { + pending += 1; + } + + if ( + record.status === BookingStatusEnum.Verified || + record.status === BookingStatusEnum.RentalCreated + ) { + verified += 1; + } + + if (record.status === BookingStatusEnum.Cancelled) { + cancelled += 1; + } + } + + return { + total: records.length, + pendingVerification: pending, + verified, + cancelled, + } satisfies BookingStatusCounters; + } + + filterRecords({ records, tab, query }: FilterInput): StaffBookingRecord[] { + const normalizedQuery = query.trim().toLowerCase(); + return records.filter( + (record) => this.matchesTab(record, tab) && this.matchesSearch(record, normalizedQuery), + ); + } + + buildCardViewModels(records: readonly StaffBookingRecord[]): BookingCardViewModel[] { + return records.map((record) => ({ + key: this.buildCardKey(record), + record, + customerDisplay: this.resolveCustomerLabel(record), + bookingCreatedDisplay: this.formatDate(record.bookingCreatedAt), + rentalStartDisplay: this.formatDate(record.startTime), + rentalEndDisplay: this.formatDate(record.endTime), + totalAmountDisplay: this.formatCurrency(this.computeEstimatedTotal(record)), + depositDisplay: this.formatCurrency(record.vehicleDetails?.depositPrice), + vehicleDisplay: this.resolveVehicleLabel(record), + rentalSummary: this.resolveRentalSummary(record), + badges: this.buildBadges(record), + })); + } + + buildSelectedBookingView(record: StaffBookingRecord | null): SelectedBookingViewModel | null { + if (!record) { + return null; + } + + return { + record, + customerDisplay: this.resolveCustomerLabel(record), + badges: this.buildBadges(record), + createdAtDisplay: this.formatDateTime(record.bookingCreatedAt), + startDateTimeDisplay: this.formatDateTime(record.startTime), + endDateTimeDisplay: this.formatDateTime(record.endTime), + totalAmountDisplay: this.formatCurrency(this.computeEstimatedTotal(record)), + depositDisplay: this.formatCurrency(record.vehicleDetails?.depositPrice), + vehicleDisplay: this.resolveVehicleLabel(record), + rentalSummary: this.resolveRentalSummary(record), + rentalDays: this.computeRentalDays(record), + } satisfies SelectedBookingViewModel; + } + + formatCurrency(value?: number | null): string { + if (value === undefined || value === null) { + return '--'; + } + return this.currencyFormatter.format(value); + } + + formatDate(value?: string): string { + if (!value) { + return '--'; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return '--'; + } + return this.dateFormatter.format(new Date(timestamp)); + } + + formatDateTime(value?: string): string { + if (!value) { + return '--'; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return '--'; + } + return this.dateTimeFormatter.format(new Date(timestamp)); + } + + rentalStatusLabel(status?: string | null): string { + if (!status) { + return 'Rental Linked'; + } + return RENTAL_STATUS_LABELS[status as RentalStatusEnum] ?? 'Rental Linked'; + } + + private matchesTab(record: StaffBookingRecord, tab: BookingTabKey): boolean { + switch (tab) { + case 'pendingVerification': + return record.verificationStatus === BookingVerificationStatusEnum.Pending; + case 'verified': + return ( + record.status === BookingStatusEnum.Verified || + record.status === BookingStatusEnum.RentalCreated + ); + case 'cancelled': + return record.status === BookingStatusEnum.Cancelled; + default: + return true; + } + } + + private matchesSearch(record: StaffBookingRecord, query: string): boolean { + if (query.length === 0) { + return true; + } + + const haystack: (string | undefined)[] = [ + record.bookingId, + record.renterId, + record.vehicleAtStationId, + record.rental?.rentalId ?? undefined, + record.rental?.vehicleId ?? undefined, + record.vehicleDetails?.make ?? undefined, + record.vehicleDetails?.model ?? undefined, + record.renterProfile?.userName ?? undefined, + record.renterProfile?.address ?? undefined, + record.renterProfile?.driverLicenseNo ?? undefined, + ]; + + return haystack.some((value) => value?.toLowerCase().includes(query) ?? false); + } + + private resolveCustomerLabel(record: StaffBookingRecord): string { + const customerName = this.normalize(record.renterProfile?.userName ?? undefined); + const renterId = this.normalize(record.renterId); + + if (customerName && renterId) { + return `${customerName} · ${this.shortenIdentifier(renterId)}`; + } + + if (customerName) { + return customerName; + } + + if (renterId) { + return `ID ${this.shortenIdentifier(renterId)}`; + } + + return 'Customer details unavailable'; + } + + private resolveVehicleLabel(record: StaffBookingRecord): string { + const details = record.vehicleDetails; + if (details) { + const parts = [ + this.normalize(details.make), + this.normalize(details.model), + details.modelYear ? details.modelYear.toString() : undefined, + ].filter((value): value is string => value !== undefined); + + if (parts.length > 0) { + return parts.join(' '); + } + } + + const fallback = this.normalize(record.vehicleAtStationId ?? record.rental?.vehicleId); + if (fallback) { + return `Vehicle ${this.shortenIdentifier(fallback)}`; + } + + return 'Vehicle --'; + } + + private resolveRentalSummary(record: StaffBookingRecord): string { + if (!record.rental) { + switch (record.status) { + case BookingStatusEnum.Cancelled: + return 'Booking cancelled'; + case BookingStatusEnum.PendingVerification: + return 'Awaiting verification'; + case BookingStatusEnum.RentalCreated: + return 'Rental creation in progress'; + case BookingStatusEnum.Verified: + return 'Awaiting rental creation'; + default: + return 'Rental not created'; + } + } + + const rental = record.rental; + const status = rental.status; + const statusLabel = status ? this.rentalStatusLabel(status) : 'Rental Linked'; + const rentalId = + this.normalize(rental.rentalId) ?? + this.normalize(rental.bookingId) ?? + this.normalize(rental.booking?.bookingId); + + if (rentalId) { + return `${statusLabel} · ${this.shortenIdentifier(rentalId)}`; + } + + const startTimeDisplay = this.formatDateTime(rental.startTime); + if (statusLabel && startTimeDisplay !== '--') { + return `${statusLabel} · Starts ${startTimeDisplay}`; + } + + return statusLabel; + } + + private resolveRentalBadge(record: StaffBookingRecord): StatusBadge | null { + const rental = record.rental; + if (!rental) { + if ( + record.status === BookingStatusEnum.Verified || + record.status === BookingStatusEnum.RentalCreated + ) { + return RENTAL_NOT_CREATED_BADGE; + } + return null; + } + + if (rental.status) { + const badge = RENTAL_STATUS_BADGES[rental.status as RentalStatusEnum]; + if (badge) { + return badge; + } + } + + return RENTAL_LINKED_BADGE; + } + + private buildBadges(record: StaffBookingRecord): StatusBadge[] { + const badges: StatusBadge[] = []; + + if (record.status) { + const bookingBadge = BOOKING_STATUS_BADGES[record.status as BookingStatusEnum]; + if (bookingBadge) { + badges.push(bookingBadge); + } + } + + if (record.verificationStatus) { + const verificationBadge = + BOOKING_VERIFICATION_BADGES[record.verificationStatus as BookingVerificationStatusEnum]; + if (verificationBadge && !badges.includes(verificationBadge)) { + badges.push(verificationBadge); + } + } + + const rentalBadge = this.resolveRentalBadge(record); + if (rentalBadge && !badges.includes(rentalBadge)) { + badges.push(rentalBadge); + } + + return badges; + } + + private computeEstimatedTotal(record: StaffBookingRecord): number | undefined { + const pricePerDay = record.vehicleDetails?.rentalPricePerDay; + if (pricePerDay === undefined || pricePerDay === null) { + return undefined; + } + + const rentalDays = this.computeRentalDays(record); + if (!rentalDays) { + return pricePerDay; + } + + return pricePerDay * rentalDays; + } + + private computeRentalDays(record: StaffBookingRecord): number | undefined { + if (!record.startTime || !record.endTime) { + return undefined; + } + + const start = Date.parse(record.startTime); + const end = Date.parse(record.endTime); + + if (Number.isNaN(start) || Number.isNaN(end)) { + return undefined; + } + + const diffMs = end - start; + if (diffMs <= 0) { + return 1; + } + + const dayInMs = 86_400_000; + return Math.ceil(diffMs / dayInMs); + } + + private normalize(value: string | null | undefined): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private shortenIdentifier(value: string): string { + if (value.length <= 8) { + return value; + } + return `${value.slice(0, 4)}...${value.slice(-4)}`; + } + + private buildCardKey(record: StaffBookingRecord): string { + const bookingId = record.bookingId; + const rentalId = record.rental?.rentalId ?? 'no-rental'; + const status = record.status ?? 'unknown-status'; + const verification = record.verificationStatus ?? 'unknown-verification'; + + return `${bookingId}|${status}|${verification}|${rentalId}`; + } +} diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.ts b/src/app/features/staff/staff-dashboard/staff-dashboard.ts index 5548727..cf3c760 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.ts +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.ts @@ -12,137 +12,18 @@ import { runInInjectionContext, signal, } from '@angular/core'; +import { Router } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { take } from 'rxjs'; import { BookingsService, StaffBookingRecord } from '../../../core-logic/bookings/bookings.service'; import { - BookingStatus as BookingStatusEnum, - BookingVerificationStatus as BookingVerificationStatusEnum, - RentalStatus as RentalStatusEnum, -} from '../../../../contract'; -import type { BookingStatus, BookingVerificationStatus, RentalStatus } from '../../../../contract'; -import { Router } from '@angular/router'; - -type BookingTabKey = 'all' | 'pendingVerification' | 'verified' | 'cancelled'; - -interface BookingStatusCounters { - readonly total: number; - readonly pendingVerification: number; - readonly verified: number; - readonly cancelled: number; -} - -interface BookingTabDescriptor { - readonly key: BookingTabKey; - readonly label: string; - readonly description: string; - readonly counterKey: keyof BookingStatusCounters; -} - -type StatusTone = 'pending' | 'success' | 'danger' | 'info'; - -interface StatusBadge { - readonly text: string; - readonly tone: StatusTone; -} - -interface BookingCardViewModel { - readonly key: string; - readonly record: StaffBookingRecord; - readonly customerDisplay: string; - readonly bookingCreatedDisplay: string; - readonly rentalStartDisplay: string; - readonly rentalEndDisplay: string; - readonly totalAmountDisplay: string; - readonly depositDisplay: string; - readonly vehicleDisplay: string; - readonly rentalSummary: string; - readonly badges: readonly StatusBadge[]; -} - -interface SelectedBookingViewModel { - readonly record: StaffBookingRecord; - readonly customerDisplay: string; - readonly badges: readonly StatusBadge[]; - readonly createdAtDisplay: string; - readonly startDateTimeDisplay: string; - readonly endDateTimeDisplay: string; - readonly totalAmountDisplay: string; - readonly depositDisplay: string; - readonly vehicleDisplay: string; - readonly rentalSummary: string; - readonly rentalDays?: number; -} - -const BOOKING_TABS: readonly BookingTabDescriptor[] = [ - { - key: 'all', - label: 'All bookings', - description: 'Every booking status', - counterKey: 'total', - }, - { - key: 'pendingVerification', - label: 'Pending verification', - description: 'Awaiting review', - counterKey: 'pendingVerification', - }, - { - key: 'verified', - label: 'Verified', - description: 'Approved bookings', - counterKey: 'verified', - }, - { - key: 'cancelled', - label: 'Cancelled', - description: 'No longer active', - counterKey: 'cancelled', - }, -] as const; - -const BOOKING_STATUS_BADGES: Record = { - [BookingStatusEnum.PendingVerification]: { text: 'Pending Verification', tone: 'pending' }, - [BookingStatusEnum.Verified]: { text: 'Verified', tone: 'success' }, - [BookingStatusEnum.Cancelled]: { text: 'Cancelled', tone: 'danger' }, - [BookingStatusEnum.RentalCreated]: { text: 'Rental Created', tone: 'info' }, -}; - -const BOOKING_VERIFICATION_BADGES: Record = { - [BookingVerificationStatusEnum.Pending]: { text: 'Verification Pending', tone: 'pending' }, - [BookingVerificationStatusEnum.Approved]: { text: 'Verification Approved', tone: 'success' }, - [BookingVerificationStatusEnum.RejectedMismatch]: { - text: 'Verification Rejected', - tone: 'danger', - }, - [BookingVerificationStatusEnum.RejectedOther]: { text: 'Verification Rejected', tone: 'danger' }, -}; - -const RENTAL_STATUS_LABELS: Record = { - [RentalStatusEnum.Reserved]: 'Reserved', - [RentalStatusEnum.InProgress]: 'In Progress', - [RentalStatusEnum.Completed]: 'Completed', - [RentalStatusEnum.Late]: 'Late', - [RentalStatusEnum.Cancelled]: 'Cancelled', -}; - -const RENTAL_STATUS_BADGES: Record = { - [RentalStatusEnum.Reserved]: { text: 'Rental Reserved', tone: 'info' }, - [RentalStatusEnum.InProgress]: { text: 'Rental In Progress', tone: 'info' }, - [RentalStatusEnum.Completed]: { text: 'Rental Completed', tone: 'success' }, - [RentalStatusEnum.Late]: { text: 'Rental Late', tone: 'danger' }, - [RentalStatusEnum.Cancelled]: { text: 'Rental Cancelled', tone: 'danger' }, -}; - -const RENTAL_NOT_CREATED_BADGE: StatusBadge = { - text: 'Rental Not Created', - tone: 'pending', -}; - -const RENTAL_LINKED_BADGE: StatusBadge = { - text: 'Rental Linked', - tone: 'info', -}; + BOOKING_TABS, + BookingCardViewModel, + BookingStatusCounters, + BookingTabKey, + SelectedBookingViewModel, +} from './staff-dashboard.models'; +import { StaffDashboardPresenter } from './staff-dashboard.presenter'; @Component({ selector: 'app-staff-dashboard', @@ -156,6 +37,7 @@ export class StaffDashboard { private readonly router = inject(Router); private readonly cdr = inject(ChangeDetectorRef); private readonly environmentInjector = inject(EnvironmentInjector); + private readonly presenter = new StaffDashboardPresenter(); @ViewChild('detailPanel') private detailPanel?: ElementRef; private activeDetailTrigger: HTMLElement | null = null; @@ -165,120 +47,42 @@ export class StaffDashboard { readonly viewMode = signal<'grid' | 'list'>('grid'); readonly selectedBooking = signal(null); - private readonly focusDetailPanelEffect = effect(() => { - if (!this.selectedBooking()) { - return; - } - - runInInjectionContext(this.environmentInjector, () => { - afterNextRender(() => { - this.detailPanel?.nativeElement.focus(); - }); - }); - }); - readonly loading = computed(() => this.bookingsService.staffBookingsLoading()); readonly error = computed(() => this.bookingsService.staffBookingsError()); private readonly allRecords = computed(() => this.bookingsService.staffBookings()); - private readonly normalizedSearch = computed(() => this.searchTerm().trim().toLowerCase()); - - readonly statusCounters = computed(() => { - const records = this.allRecords(); - let pending = 0; - let verified = 0; - let cancelled = 0; - for (const record of records) { - if (record.verificationStatus === BookingVerificationStatusEnum.Pending) { - pending += 1; - } - - if ( - record.status === BookingStatusEnum.Verified || - record.status === BookingStatusEnum.RentalCreated - ) { - verified += 1; - } - - if (record.status === BookingStatusEnum.Cancelled) { - cancelled += 1; - } - } - - return { - total: records.length, - pendingVerification: pending, - verified, - cancelled, - } satisfies BookingStatusCounters; - }); - - private readonly filteredRecords = computed(() => { - const tab = this.activeTab(); - const query = this.normalizedSearch(); + readonly statusCounters = computed(() => + this.presenter.calculateStatusCounters(this.allRecords()), + ); - return this.allRecords().filter( - (record) => this._matchesTab(record, tab) && this._matchesSearch(record, query), - ); - }); + private readonly filteredRecords = computed(() => + this.presenter.filterRecords({ + records: this.allRecords(), + tab: this.activeTab(), + query: this.searchTerm(), + }), + ); readonly filteredCount = computed(() => this.filteredRecords().length); readonly cardViewModels = computed(() => - this.filteredRecords().map((record) => ({ - key: this._buildCardKey(record), - record, - customerDisplay: this._resolveCustomerLabel(record), - bookingCreatedDisplay: this.formatDate(record.bookingCreatedAt), - rentalStartDisplay: this.formatDate(record.startTime), - rentalEndDisplay: this.formatDate(record.endTime), - totalAmountDisplay: this.formatCurrency(this._computeEstimatedTotal(record)), - depositDisplay: this.formatCurrency(record.vehicleDetails?.depositPrice), - vehicleDisplay: this._resolveVehicleLabel(record), - rentalSummary: this._resolveRentalSummary(record), - badges: this._buildBadges(record), - })), + this.presenter.buildCardViewModels(this.filteredRecords()), ); - readonly selectedBookingView = computed(() => { - const record = this.selectedBooking(); - if (!record) { - return null; - } - - return { - record, - customerDisplay: this._resolveCustomerLabel(record), - badges: this._buildBadges(record), - createdAtDisplay: this.formatDateTime(record.bookingCreatedAt), - startDateTimeDisplay: this.formatDateTime(record.startTime), - endDateTimeDisplay: this.formatDateTime(record.endTime), - totalAmountDisplay: this.formatCurrency(this._computeEstimatedTotal(record)), - depositDisplay: this.formatCurrency(record.vehicleDetails?.depositPrice), - vehicleDisplay: this._resolveVehicleLabel(record), - rentalSummary: this._resolveRentalSummary(record), - rentalDays: this._computeRentalDays(record), - } satisfies SelectedBookingViewModel; - }); - - private readonly currencyFormatter = new Intl.NumberFormat('vi-VN', { - style: 'currency', - currency: 'VND', - maximumFractionDigits: 0, - }); + readonly selectedBookingView = computed(() => + this.presenter.buildSelectedBookingView(this.selectedBooking()), + ); - private readonly dateFormatter = new Intl.DateTimeFormat('vi-VN', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }); + private readonly focusDetailPanelEffect = effect(() => { + if (!this.selectedBooking()) { + return; + } - private readonly dateTimeFormatter = new Intl.DateTimeFormat('vi-VN', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', + runInInjectionContext(this.environmentInjector, () => { + afterNextRender(() => { + this.detailPanel?.nativeElement.focus(); + }); + }); }); constructor() { @@ -349,17 +153,12 @@ export class StaffDashboard { } this.closeDetails(); - void this.router.navigate(['/staff/bookings', record.bookingId, 'fulfillment']); + void this.router.navigate(['/staff/fulfillment', record.bookingId]); } // eslint-disable-next-line @typescript-eslint/no-unused-vars - canOpenFulfillment(record: StaffBookingRecord): boolean { + canOpenFulfillment(_record: StaffBookingRecord): boolean { return true; - // return ( - // record.verificationStatus === BookingVerificationStatusEnum.Approved || - // record.status === BookingStatusEnum.Verified || - // record.status === BookingStatusEnum.RentalCreated - // ); } onOverlayClick(event: MouseEvent): void { @@ -369,249 +168,18 @@ export class StaffDashboard { } formatCurrency(value?: number | null): string { - if (value === undefined || value === null) { - return '--'; - } - return this.currencyFormatter.format(value); + return this.presenter.formatCurrency(value); } formatDate(value?: string): string { - if (!value) { - return '--'; - } - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return '--'; - } - return this.dateFormatter.format(new Date(timestamp)); + return this.presenter.formatDate(value); } formatDateTime(value?: string): string { - if (!value) { - return '--'; - } - const timestamp = Date.parse(value); - if (Number.isNaN(timestamp)) { - return '--'; - } - return this.dateTimeFormatter.format(new Date(timestamp)); - } - - private _matchesTab(record: StaffBookingRecord, tab: BookingTabKey): boolean { - switch (tab) { - case 'pendingVerification': - return record.verificationStatus === BookingVerificationStatusEnum.Pending; - case 'verified': - return ( - record.status === BookingStatusEnum.Verified || - record.status === BookingStatusEnum.RentalCreated - ); - case 'cancelled': - return record.status === BookingStatusEnum.Cancelled; - default: - return true; - } + return this.presenter.formatDateTime(value); } - private _matchesSearch(record: StaffBookingRecord, query: string): boolean { - if (query.length === 0) { - return true; - } - - const haystack: (string | undefined)[] = [ - record.bookingId, - record.renterId, - record.vehicleAtStationId, - record.rental?.rentalId ?? undefined, - record.rental?.vehicleId ?? undefined, - record.vehicleDetails?.make ?? undefined, - record.vehicleDetails?.model ?? undefined, - record.renterProfile?.address ?? undefined, - record.renterProfile?.driverLicenseNo ?? undefined, - ]; - - return haystack.some((value) => value?.toLowerCase().includes(query) ?? false); - } - - private _resolveCustomerLabel(record: StaffBookingRecord): string { - const renterId = this._normalize(record.renterId); - if (renterId) { - return `Customer ${this._shortenIdentifier(renterId)}`; - } - - return 'Customer --'; - } - - private _resolveVehicleLabel(record: StaffBookingRecord): string { - const details = record.vehicleDetails; - if (details) { - const parts = [ - this._normalize(details.make), - this._normalize(details.model), - details.modelYear ? details.modelYear.toString() : undefined, - ].filter((value): value is string => value !== undefined); - - if (parts.length > 0) { - return parts.join(' '); - } - } - - const fallback = this._normalize(record.vehicleAtStationId ?? record.rental?.vehicleId); - if (fallback) { - return `Vehicle ${this._shortenIdentifier(fallback)}`; - } - - return 'Vehicle --'; - } - - private _resolveRentalSummary(record: StaffBookingRecord): string { - if (!record.rental) { - switch (record.status) { - case BookingStatusEnum.Cancelled: - return 'Booking cancelled'; - case BookingStatusEnum.PendingVerification: - return 'Awaiting verification'; - case BookingStatusEnum.RentalCreated: - return 'Rental creation in progress'; - case BookingStatusEnum.Verified: - return 'Awaiting rental creation'; - default: - return 'Rental not created'; - } - } - - const rental = record.rental; - const status = rental.status; - const statusLabel = status ? this.rentalStatusLabel(status) : 'Rental Linked'; - const rentalId = - this._normalize(rental.rentalId) ?? - this._normalize(rental.bookingId) ?? - this._normalize(rental.booking?.bookingId); - - if (rentalId) { - return `${statusLabel} · ${this._shortenIdentifier(rentalId)}`; - } - - const startTimeDisplay = this.formatDateTime(rental.startTime); - if (statusLabel && startTimeDisplay !== '--') { - return `${statusLabel} · Starts ${startTimeDisplay}`; - } - - return statusLabel; - } - - rentalStatusLabel(status?: RentalStatus | null): string { - if (!status) { - return 'Rental Linked'; - } - - return RENTAL_STATUS_LABELS[status] ?? 'Rental Linked'; - } - - private _resolveRentalBadge(record: StaffBookingRecord): StatusBadge | null { - const rental = record.rental; - if (!rental) { - if ( - record.status === BookingStatusEnum.Verified || - record.status === BookingStatusEnum.RentalCreated - ) { - return RENTAL_NOT_CREATED_BADGE; - } - return null; - } - - if (rental.status) { - const badge = RENTAL_STATUS_BADGES[rental.status]; - if (badge) { - return badge; - } - } - - return RENTAL_LINKED_BADGE; - } - - private _buildBadges(record: StaffBookingRecord): StatusBadge[] { - const badges: StatusBadge[] = []; - - if (record.status) { - const bookingBadge = BOOKING_STATUS_BADGES[record.status]; - if (bookingBadge) { - badges.push(bookingBadge); - } - } - - if (record.verificationStatus) { - const verificationBadge = BOOKING_VERIFICATION_BADGES[record.verificationStatus]; - if (verificationBadge && !badges.includes(verificationBadge)) { - badges.push(verificationBadge); - } - } - - const rentalBadge = this._resolveRentalBadge(record); - if (rentalBadge && !badges.includes(rentalBadge)) { - badges.push(rentalBadge); - } - - return badges; - } - - private _computeEstimatedTotal(record: StaffBookingRecord): number | undefined { - const pricePerDay = record.vehicleDetails?.rentalPricePerDay; - if (pricePerDay === undefined || pricePerDay === null) { - return undefined; - } - - const rentalDays = this._computeRentalDays(record); - if (!rentalDays) { - return pricePerDay; - } - - return pricePerDay * rentalDays; - } - - private _computeRentalDays(record: StaffBookingRecord): number | undefined { - if (!record.startTime || !record.endTime) { - return undefined; - } - - const start = Date.parse(record.startTime); - const end = Date.parse(record.endTime); - - if (Number.isNaN(start) || Number.isNaN(end)) { - return undefined; - } - - const diffMs = end - start; - if (diffMs <= 0) { - return 1; - } - - const dayInMs = 86_400_000; - return Math.ceil(diffMs / dayInMs); - } - - private _normalize(value: string | null | undefined): string | undefined { - if (!value) { - return undefined; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - - private _shortenIdentifier(value: string): string { - if (value.length <= 8) { - return value; - } - return `${value.slice(0, 4)}...${value.slice(-4)}`; - } - - private _buildCardKey(record: StaffBookingRecord): string { - const bookingId = record.bookingId; - const rentalId = record.rental?.rentalId ?? 'no-rental'; - const status = record.status ?? 'unknown-status'; - const verification = record.verificationStatus ?? 'unknown-verification'; - - return `${bookingId}|${status}|${verification}|${rentalId}`; + rentalStatusLabel(status?: string | null): string { + return this.presenter.rentalStatusLabel(status); } } diff --git a/src/app/features/staff/staff.routes.ts b/src/app/features/staff/staff.routes.ts index e8764b1..b41c7e7 100644 --- a/src/app/features/staff/staff.routes.ts +++ b/src/app/features/staff/staff.routes.ts @@ -1,8 +1,9 @@ import { Routes } from '@angular/router'; -import { StaffDashboard } from './staff-dashboard/staff-dashboard'; + import { RentalManagement } from './rental-management/rental-management'; import { RenterManagement } from './renter-management/renter-management'; import { VehicleManagement } from './vehicle-management/vehicle-management'; +import { StaffDashboard } from './staff-dashboard/staff-dashboard'; export default [ { @@ -11,7 +12,7 @@ export default [ redirectTo: 'bookings', }, { - path: 'bookings/:bookingId/fulfillment', + path: 'fulfillment/:bookingId', loadChildren: () => import('./booking-fulfillment/booking-fulfillment.routes'), }, {