diff --git a/.gitignore b/.gitignore index b1d225e..2c5029f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ /node_modules npm-debug.log yarn-error.log +pnpm-debug.log +.env +.env.* # IDEs and editors .idea/ diff --git a/.prettierignore b/.prettierignore index b14d30e..4fae988 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,8 @@ *.scss +node_modules/ +dist/ +build/ +coverage/ +package-lock.json +yarn.lock +pnpm-lock.yaml diff --git a/docs/staff-dashboard-improvements.md b/docs/staff-dashboard-improvements.md deleted file mode 100644 index 4ad6bb6..0000000 --- a/docs/staff-dashboard-improvements.md +++ /dev/null @@ -1,15 +0,0 @@ -# Staff Dashboard Follow-up Improvements - -This note captures quick enhancements identified during the review of commit f50a6f1b8ceb5b0b687557be13810605851a94a0. - -## Issues Observed - -- Modal overlay closes on any Enter/Space key press bubbling from descendants, making it impossible to activate buttons inside the detail drawer with the keyboard. -- When data refresh fails, the existing booking list is wiped, which creates unnecessary churn and hides potentially useful information from staff. -- The booking detail drawer does not manage focus (no focus is sent into the dialog and focus is not restored on close), creating accessibility gaps. - -## Planned Fixes - -1. Restrict the overlay key handling to the Escape key and keep click-to-close behaviour, preventing accidental closures while still supporting keyboard dismissal. -2. Preserve the previously loaded booking data if a refresh fails so staff can continue working with stale-but-usable data and still see the error message. -3. Add basic modal focus management: send focus to the drawer when it opens, trap the Escape key for dismissal, and return focus to the launch button after closing. diff --git a/eslint.config.js b/eslint.config.js index cb9a65d..1149587 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,21 @@ const angular = require('angular-eslint'); module.exports = tseslint.config( { files: ['**/*.ts'], - ignores: ['**/*.spec.ts', 'node_modules/**', 'src/contract/**'], + ignores: [ + '**/*.spec.ts', + '**/*.min.js', + 'node_modules/**', + 'src/contract/**', + 'dist/**', + 'build/**', + 'out/**', + 'coverage/**', + '.angular/cache/**', + '.bazel/**', + 'public/**', + 'docs/**', + 'pnpm-lock.yaml', + ], extends: [ eslint.configs.recommended, ...tseslint.configs.recommended, @@ -35,7 +49,21 @@ module.exports = tseslint.config( }, { files: ['**/*.html'], - ignores: ['**/*.spec.ts', 'node_modules/**', 'src/contract/**'], + ignores: [ + '**/*.spec.ts', + '**/*.min.js', + 'node_modules/**', + 'src/contract/**', + 'dist/**', + 'build/**', + 'out/**', + 'coverage/**', + '.angular/cache/**', + '.bazel/**', + 'public/**', + 'docs/**', + 'pnpm-lock.yaml', + ], extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: {}, }, diff --git a/specs/001-staff-booking-flow/checklists/requirements.md b/specs/001-staff-booking-flow/checklists/requirements.md new file mode 100644 index 0000000..7811577 --- /dev/null +++ b/specs/001-staff-booking-flow/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Staff Booking Fulfillment Workspace + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-11-12 +**Feature**: [Link to spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Centered on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` diff --git a/specs/001-staff-booking-flow/checklists/us1-booking-entry.md b/specs/001-staff-booking-flow/checklists/us1-booking-entry.md new file mode 100644 index 0000000..f1e06c2 --- /dev/null +++ b/specs/001-staff-booking-flow/checklists/us1-booking-entry.md @@ -0,0 +1,17 @@ +# User Story 1 – Booking Fulfillment Entry Validation + +## Manual QA Walkthrough + +- [ ] Launch local app with `pnpm start` and authenticate as staff user +- [ ] From `Bookings` dashboard, select an approved booking and open fulfillment route +- [ ] Confirm document title updates to "Xử lý đặt xe" (or localized equivalent) +- [ ] Check booking summary shows renter profile, vehicle, and booking metadata +- [ ] Validate fulfillment checklist locks steps other than check-in before completion +- [ ] Trigger check-in action and observe optimistic progress indicator +- [ ] Confirm success toast/banner copy and timeline entry appear after check-in +- [ ] Refresh browser tab; verify fulfillment state, timeline, and checklist persist +- [ ] Exercise error path by forcing API failure (mock/network) and confirm retry affordance + +## Evidence Log + +- Notes: diff --git a/specs/001-staff-booking-flow/checklists/us2-rental-package.md b/specs/001-staff-booking-flow/checklists/us2-rental-package.md new file mode 100644 index 0000000..6ebb6d9 --- /dev/null +++ b/specs/001-staff-booking-flow/checklists/us2-rental-package.md @@ -0,0 +1,16 @@ +# User Story 2 – Rental Package Preparation + +## Manual QA Walkthrough + +- [ ] From the fulfillment page, verify only the rental creation step is actionable after check-in +- [ ] Trigger rental creation and confirm success toast or timeline entry exposes the new `rentalId` +- [ ] Proceed to contract issuance, choose an e-sign provider, and confirm the `contractId` appears in the summary metadata +- [ ] Attempt to re-run contract issuance and ensure the UI prevents duplicate submissions while busy +- [ ] Complete the inspection form with battery level, timestamp, and evidence URL; verify optimistic status change and persisted inspection reference +- [ ] Refresh the page and validate rental, contract, and inspection artifacts remain visible with fulfilled badges +- [ ] Force an API failure on inspection submission and confirm error messaging plus retry affordance +- [ ] Confirm the next actionable step advances to renter signing after inspection success + +## Evidence Log + +- Notes: diff --git a/specs/001-staff-booking-flow/checklists/us3-finalization.md b/specs/001-staff-booking-flow/checklists/us3-finalization.md new file mode 100644 index 0000000..8d88a92 --- /dev/null +++ b/specs/001-staff-booking-flow/checklists/us3-finalization.md @@ -0,0 +1,16 @@ +# User Story 3 – Finalize Contract & Vehicle Handover + +## Manual QA Walkthrough + +- [ ] Continue from a fulfilled inspection and verify renter signature step becomes actionable +- [ ] Submit renter signature details and confirm signature ID surfaces in the checklist and summary +- [ ] Attempt double submission of renter signature while busy to confirm duplicate prevention +- [ ] Complete staff signature using a different signature type and ensure both signatures display distinct metadata +- [ ] Inspect the activity timeline to verify renter and staff signature events include actor labels and timestamps +- [ ] Capture vehicle receive time and confirm staff identifier appears in both checklist artifact chips and summary cards +- [ ] Refresh the browser and validate all finalization steps remain fulfilled with artifacts intact +- [ ] Force a failure on vehicle receive API and confirm error state with retry button restores actionable status + +## Evidence Log + +- Notes: diff --git a/specs/001-staff-booking-flow/contracts/booking-fulfillment.yaml b/specs/001-staff-booking-flow/contracts/booking-fulfillment.yaml new file mode 100644 index 0000000..247671e --- /dev/null +++ b/specs/001-staff-booking-flow/contracts/booking-fulfillment.yaml @@ -0,0 +1,147 @@ +openapi: 3.0.3 +info: + title: EV Rental Frontend Booking Fulfillment Orchestration + version: 1.0.0 + description: >- + Logical contract map for required backend calls triggered by the staff booking + fulfillment UI. Mirrors existing EV Rental REST API endpoints consumed via + generated OpenAPI clients. +servers: + - url: https://api.ev-rental.local +paths: + /api/Booking/checkin: + post: + summary: Staff confirms booking check-in + operationId: staffCheckinBooking + tags: + - Booking + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CheckinBookingRequest' + responses: + '200': + description: Booking check-in processed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BookingApiResponse' + /api/Rental: + post: + summary: Create rental for approved booking + operationId: staffCreateRental + tags: + - Rental + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRentalRequest' + responses: + '200': + description: Rental created + content: + application/json: + schema: + $ref: '#/components/schemas/RentalApiResponse' + /api/Rental/contract: + post: + summary: Generate contract for rental + operationId: staffCreateRentalContract + tags: + - Rental + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateContractRequest' + responses: + '200': + description: Contract created + content: + application/json: + schema: + $ref: '#/components/schemas/ContractApiResponse' + /api/Rental/inspection: + post: + summary: Record pre-handover inspection + operationId: staffRecordInspection + tags: + - Rental + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReceiveInspectionRequest' + responses: + '200': + description: Inspection stored + content: + application/json: + schema: + $ref: '#/components/schemas/InspectionApiResponse' + /api/Rental/contract/sign: + post: + summary: Capture digital signature + operationId: staffSignContract + tags: + - Rental + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SignContractRequest' + responses: + '200': + description: Signature stored + content: + application/json: + schema: + $ref: '#/components/schemas/ContractApiResponse' + /api/Rental/vehicle/receive: + post: + summary: Confirm vehicle handover + operationId: staffReceiveVehicle + tags: + - Rental + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReceiveVehicleRequest' + responses: + '204': + description: Vehicle receive acknowledged +components: + schemas: + CheckinBookingRequest: + $ref: '../../src/contract/model/checkinBookingRequest.ts' + CreateRentalRequest: + $ref: '../../src/contract/model/createRentalRequest.ts' + CreateContractRequest: + $ref: '../../src/contract/model/createContractRequest.ts' + ReceiveInspectionRequest: + $ref: '../../src/contract/model/receiveInspectionRequest.ts' + SignContractRequest: + $ref: '../../src/contract/model/signContractRequest.ts' + ReceiveVehicleRequest: + $ref: '../../src/contract/model/receiveVehicleRequest.ts' + BookingApiResponse: + description: Wraps booking details payload (placeholder for generated model) + type: object + RentalApiResponse: + description: Wraps rental details payload (placeholder for generated model) + type: object + ContractApiResponse: + description: Wraps contract details payload (placeholder for generated model) + type: object + InspectionApiResponse: + description: Wraps inspection details payload (placeholder for generated model) + type: object diff --git a/specs/001-staff-booking-flow/data-model.md b/specs/001-staff-booking-flow/data-model.md new file mode 100644 index 0000000..bcbe31e --- /dev/null +++ b/specs/001-staff-booking-flow/data-model.md @@ -0,0 +1,85 @@ +# Data Model: Staff Booking Fulfillment Workspace + +## Entities + +### 1. BookingFulfillmentSummary + +- **Purpose**: Present read-only context so staff verify they are working on the correct booking before progressing. +- **Fields**: + - `bookingId: string` — primary identifier. + - `status: BookingStatus` — current booking status (enum from OpenAPI contract). + - `verificationStatus: BookingVerificationStatus` — verification state, used to gate check-in. + - `renterProfile: RenterProfileDto` — renter identity, address, and risk information. + - `vehicleDetails: VehicleDetailsDto` — make/model, deposit, pricing, station origin. + - `rental: RentalDetailsDto | null` — existing rental record if backend already progressed the booking. + - `timeline: ReadonlyArray` — ordered history of completed steps with timestamps and actor metadata. +- **Relationships**: Aggregates DTOs sourced via `BookingsService` and `RentalsService`. Timeline entries derive from `FulfillmentStepState` history. + +### 2. FulfillmentStepState + +- **Purpose**: Track UI status for each mandated milestone, enabling resume-after-refresh and sequential enforcement. +- **Shape**: + - `step: FulfillmentStepId` — discriminated union identifier (`checkin`, `create-rental`, `create-contract`, `inspection`, `sign-renter`, `sign-staff`, `vehicle-receive`). + - `status: 'pending' | 'in-progress' | 'fulfilled' | 'error'` — UI state. + - `startedAt?: string` — ISO timestamp when user initiated the action. + - `completedAt?: string` — ISO timestamp saved on success. + - `error?: FulfillmentError` — optional failure payload. + - `artifact?: FulfillmentArtifact` — holds returned identifiers (`rentalId`, `contractId`, inspection reference, signature hash). + - `requires?: FulfillmentStepId[]` — prerequisite steps; default is previous milestone. +- **Transitions**: + 1. `pending → in-progress` when user triggers the action. + 2. `in-progress → fulfilled` after successful API call. + 3. `in-progress → error` on failure; user may retry shifting back to `in-progress`. +- **Validation Rules**: + - A step cannot enter `in-progress` unless all `requires` are `fulfilled`. + - `artifact` must include the expected identifiers per step: e.g., `create-rental` requires `artifact.rentalId`. + +### 3. FulfillmentArtifact + +- **Purpose**: Persist backend identifiers per step for subsequent payloads. +- **Fields**: + - `rentalId?: string` — produced by rental creation. + - `contractId?: string` — produced by contract creation. + - `inspectionId?: string` — produced by inspection upload. + - `renterSignatureId?: string` — identifier/hash returned when renter signs. + - `staffSignatureId?: string` — identifier/hash returned when staff signs. + - `vehicleReceipt?: { receivedAt: string; receivedByStaffId: string }` — details of vehicle handover. + +### 4. FulfillmentError + +- **Purpose**: Capture retry details and display actionable messages. +- **Fields**: + - `message: string` — human-readable summary for toast/banner. + - `code?: string` — backend-specific code for diagnostics. + - `detail?: unknown` — raw response/logging payload (not surfaced to end user). + +### 5. FulfillmentTimelineEvent + +- **Purpose**: Provide chronological audit record for staff and QA review. +- **Fields**: + - `step: FulfillmentStepId` + - `title: string` — localized label. + - `description?: string` + - `actor: 'staff' | 'renter' | 'system'` + - `occurredAt: string` + - `metadata?: Record` — contextual values (IDs, battery level, URLs). + +## Enumerations + +- `FulfillmentStepId = 'checkin' | 'create-rental' | 'create-contract' | 'inspection' | 'sign-renter' | 'sign-staff' | 'vehicle-receive'` + +## Derived/Computed State + +- `isReadyFor(stepId)`: computed boolean verifying all prerequisites fulfilled. +- `nextStep`: first `FulfillmentStepState` with `status !== 'fulfilled'` to guide CTA state. +- `completionPercentage`: ratio of fulfilled steps over total, surfaced in progress indicator. + +## External Dependencies + +- DTOs imported from `src/contract/model/**` (BookingDetailsDto, CreateRentalRequest, etc.). +- `BookingsService` and `RentalsService` provide initial data and may update state when backend changes are detected. + +## Notes + +- All timestamps stored as ISO strings in UTC to simplify comparison. +- Arrays exposed via signals must return defensive copies to avoid downstream mutation. diff --git a/specs/001-staff-booking-flow/plan.md b/specs/001-staff-booking-flow/plan.md new file mode 100644 index 0000000..ffb1687 --- /dev/null +++ b/specs/001-staff-booking-flow/plan.md @@ -0,0 +1,64 @@ +# Implementation Plan: Staff Booking Fulfillment Workspace + +**Branch**: `001-staff-booking-flow` | **Date**: 2025-11-12 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/001-staff-booking-flow/spec.md` + +## Summary + +Introduce a dedicated staff fulfillment route that guides agents through the mandated booking-to-rental workflow, orchestrates the ordered API sequence (`checkin → rental → contract → inspection → renter signature → staff signature → vehicle receive`), and surfaces progress, audit history, and retry handling without altering the existing dashboard surface. + +## Technical Context + +**Language/Version**: TypeScript 5.5+, Angular 20.3 zoneless standalone app +**Primary Dependencies**: Angular router lazy routes, RxJS 7 signals interop, Angular Material 3 surface components, existing OpenAPI-generated `BookingService`/`RentalService`, internal `core-logic/bookings` orchestrators +**Storage**: Client-only state via signals (no persistent storage) +**Testing**: Manual QA flows per user story, optional targeted Karma specs if regression risk persists +**Target Platform**: Chromium-based desktop and 10" class tablets used by staff +**Performance Goals**: Fulfillment page renders actionable content within 100 ms after booking data arrives; API retries avoid blocking UI thread +**Constraints**: Maintain strict typing (no `any`), rely on signals/computed plus OnPush components, guard new route with staff auth role, ensure sequential API enforcement, reuse OpenAPI clients +**Scale/Scope**: Impacts staff bookings route, introduces new fulfillment feature module with nested components and stepper-like UI, touches `core-logic` for state synchronization +**Risks/Unknowns**: Inspection media upload UI confirmed absent—new form required; renter PII remains visible to staff, mirroring rental management precedent. + +## Constitution Check + +- **TypeScript Without Compromise**: Plan to define `FulfillmentStepState` and derived view models in `core-logic` with exhaustively typed discriminated unions; leverage generated DTOs for API payloads and add runtime guards for optional fields before rendering. +- **Signals-Driven Angular Architecture**: Introduce a fulfillment service exposing signals for booking snapshot, step progression, and optimistic updates; fulfillment page component consumes read-only signals, uses computed accessors, and is registered as a lazily loaded child route under `staff.routes.ts`. +- **Fit-for-Purpose Validation**: Each user story maps to a manual QA script capturing screenshots and timestamps; signature double-call flow receives stakeholder walkthrough sign-off; lint suites run (`pnpm lint`, `pnpm lint-style`) prior to merge. +- **Tooling Discipline & API Fidelity**: Use `pnpm` commands exclusively, rely on existing OpenAPI services (`BookingService`, `RentalService`), extend `core-logic/bookings` or add `core-logic/rental-fulfillment` without bypassing code generation, and keep feature UI under `src/app/features/staff/booking-fulfillment`. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-staff-booking-flow/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +└── tasks.md # Generated via /speckit.tasks later +``` + +### Source Code (repository root) + +```text +src/ +├── app/ +│ ├── core-logic/ +│ │ ├── bookings/ +│ │ └── rental-fulfillment/ # new orchestrator (proposed) +│ ├── features/ +│ │ └── staff/ +│ │ ├── staff-dashboard/ +│ │ └── booking-fulfillment/ # new route + components +│ ├── layout/ +│ └── lib/common-ui/ +└── contract/ +``` + +**Structure Decision**: Keep orchestration in `core-logic` (signal store, API coordination) while exposing dedicated UI components within `features/staff/booking-fulfillment`; augment `staff.routes.ts` with a child route pointing to the new fulfillment workspace guarded by staff role. + +## Complexity Tracking + +No constitution violations anticipated; table intentionally left empty. diff --git a/specs/001-staff-booking-flow/quickstart.md b/specs/001-staff-booking-flow/quickstart.md new file mode 100644 index 0000000..67cc4d7 --- /dev/null +++ b/specs/001-staff-booking-flow/quickstart.md @@ -0,0 +1,33 @@ +# Quickstart: Staff Booking Fulfillment Workspace + +## Bootstrapping the Flow + +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`. + +## Required API Sequence + +Actions must execute in order; the UI blocks progression until each call succeeds. + +1. `POST /api/Booking/checkin` — approve the booking (`BookingStatus.Approved`). +2. `POST /api/Rental` — create rental; capture returned `rentalId`. +3. `POST /api/Rental/contract` — generate contract; capture `contractId`. +4. `POST /api/Rental/inspection` — upload inspection details. +5. `POST /api/Rental/contract/sign` — call twice, first with role `renter`, then `staff`. +6. `POST /api/Rental/vehicle/receive` — mark vehicle handover complete. + +All requests must use generated OpenAPI clients (`BookingService`, `RentalService`) and propagate identifiers stored in fulfillment step state. + +## Validation Checklist + +- Run `pnpm lint` and `pnpm lint-style` before submitting changes. +- Execute the manual QA script for each user story: + - Confirm the checklist never enables a step prematurely. +- Document validation evidence in the pull request description. + +## Troubleshooting + +- If a step fails, retry logic should call the same endpoint once the error is resolved; inspect console logs for detailed `FulfillmentError` codes. +- Ensure staff identity (`staffId`) is available from the existing auth context; if missing, refresh tokens via `AuthService` utilities. +- When backend state changes outside the UI, reload the fulfillment route to resync signals. diff --git a/specs/001-staff-booking-flow/research.md b/specs/001-staff-booking-flow/research.md new file mode 100644 index 0000000..f11a756 --- /dev/null +++ b/specs/001-staff-booking-flow/research.md @@ -0,0 +1,17 @@ +# Research Notes: Staff Booking Fulfillment Workspace + +## Decision 1: Inspection form implementation + +- **Decision**: Build a dedicated inspection form component within the fulfillment route that leverages existing reactive-form utilities but does not attempt to reuse prior UI (none exists today). +- **Rationale**: Repository search across `features/**` found no prior inspection UI or reusable media upload pattern; implementing the form inline ensures requirements (battery capacity, timestamp, staff ID, media URL) are satisfied without blocking on missing shared assets. +- **Alternatives considered**: + - _Reuse rental-management details view_: rejected because it only displays read-only inspection metadata and offers no form scaffolding. + - _Introduce shared inspection library component up front_: deferred until a second consumer emerges to avoid premature abstraction. + +## Decision 2: Display of renter PII during fulfillment + +- **Decision**: Surface renter profile details (name, address, license number) consistent with the existing staff rental management view while ensuring the UI clearly indicates sensitive data and respects authenticated staff-only access. +- **Rationale**: `features/staff/rental-management` already exposes this information to staff, demonstrating that current policy allows full visibility when processing rentals; matching this precedent avoids inconsistent staff experiences. +- **Alternatives considered**: + - _Mask PII by default_: dismissed because staff rely on these fields to verify identity before vehicle handover. + - _Require additional consent gating_: unnecessary given existing staff workflows and would introduce friction absent in other screens. diff --git a/specs/001-staff-booking-flow/spec.md b/specs/001-staff-booking-flow/spec.md new file mode 100644 index 0000000..20eb278 --- /dev/null +++ b/specs/001-staff-booking-flow/spec.md @@ -0,0 +1,97 @@ +# Feature Specification: Staff Booking Fulfillment Workspace + +**Feature Branch**: `001-staff-booking-flow` +**Created**: 2025-11-12 +**Status**: Draft +**Input**: User description: "ở http://localhost:4200/staff/bookings , sau khi bấm vào một details-panel xuất hiện một nút để routing qua một trang booking detail để tiếp tục cho luồng bên dưới. những luồng này phải ở một route khác chứ không phải staff-dashboard.ts. ĐÂY LÀ THỨ TỰ GỌI API BẠN BẮT BUỘC PHẢI GỌI THEO 1. /booking/POST/api/Booking/checkin 2. /rental/POST/api/Rental ---> return (rentalId) 3. /rental/POST/api/Rental/contract ---> (contractId) 4. /rental/POST/api/Rental/inspection 5. /rental/POST/api/Rental/contract/sign -- BẮT BUỘC PHẢI ĐỦ 2 LẦN KÍ TỪ 2 PHÍA RENTER VÀ STAFF - TỨC PHẢI GỌI API NÀY 2 LẦN 6. /rental/POST/api/Rental/vehicle/receive. lấy thêm context dựa vào #REQUIREMENT.md" + +## User Scenarios & Validation _(mandatory)_ + +### User Story 1 - Booking Fulfillment Entry (Priority: P1) + +A staff agent handling a fully paid booking opens the record in the bookings list, sees a "Tiếp tục xử lý" call-to-action inside the details panel, and follows it to a dedicated booking fulfillment route that summarises the booking, highlights pending steps, and enables the booking check-in. + +**Why this priority**: Without a dedicated entry point and check-in capability, staff cannot progress bookings that are waiting for manual confirmation, blocking downstream rental creation and revenue recognition. + +**Independent Validation**: Manual walkthrough on desktop breakpoints verifying navigation and confirmation messaging for the check-in request. + +**Acceptance Scenarios**: + +1. **Given** a booking that is deposit-paid and awaiting staff approval, **When** the agent opens the details panel and activates the fulfillment button, **Then** the UI navigates to `staff/bookings/{bookingId}/fulfillment`, spotlights the page heading, and displays booking summary information and a checklist of remaining steps with only "Check in booking" enabled. +2. **Given** the fulfillment page for an eligible booking, **When** the agent confirms the check-in step, **Then** the system sends `/booking/POST/api/Booking/checkin` with the booking identifier and status `Approved`, shows a success banner, records the completion timestamp, and unlocks the "Create rental" step. +3. **Given** the fulfillment page for a booking that is already marked as approved in the backend, **When** the page loads, **Then** the check-in step is marked complete with backend metadata and subsequent steps are available without requiring the agent to re-trigger the API. + +--- + +### User Story 2 - Prepare Rental Package (Priority: P2) + +After confirming the booking, the staff agent uses the fulfillment route to create the rental, generate the contract, and capture the pre-handover inspection so the renter has an official agreement tied to a specific vehicle. + +**Why this priority**: Rental creation and documentation are the core deliverables of the hand-off process; without them, the renter cannot legally or operationally receive the vehicle. + +**Independent Validation**: Manual QA script covering the sequential buttons, mock API responses verifying the returned `rentalId`, `contractId`, and inspection identifiers, and confirming all form interactions. + +**Acceptance Scenarios**: + +1. **Given** the check-in step is complete, **When** the agent triggers "Create rental", **Then** the UI sends `/rental/POST/api/Rental` with the booking identifier, captures the returned `rentalId`, reflects the rental status on the summary, and enables "Create contract" only after success. +2. **Given** a rental exists in the flow, **When** the agent initiates "Create contract", **Then** `/rental/POST/api/Rental/contract` is called with the `rentalId` and selected e-sign provider, the returned `contractId` is stored, and the UI confirms the association before enabling the inspection form. +3. **Given** a contract has been generated, **When** the agent completes the inspection form with battery level, timestamp, inspector identity, and media URL, **Then** `/rental/POST/api/Rental/inspection` is invoked, the resulting inspection reference is logged, and the booking timeline displays a confirmation that supports replay by QA and stakeholders. + +--- + +### User Story 3 - Finalize Contract and Vehicle Handover (Priority: P3) + +With the rental package in place, the staff agent secures both renter and staff signatures and confirms vehicle receipt so the rental transitions to "in progress" and downstream invoices can start. + +**Why this priority**: Dual signatures and handover confirmation are regulatory and operational checkpoints; completing them ensures both parties acknowledge the contract and the fleet inventory updates in real time. + +**Independent Validation**: Stakeholder ride-along verifying digital signature capture flows, QA evidence that two distinct signature records are created per booking, and confirmation UI review for vehicle handover timestamps. + +**Acceptance Scenarios**: + +1. **Given** a contract identifier exists, **When** the agent uploads or records the renter signature, **Then** `/rental/POST/api/Rental/contract/sign` is called with role `Renter`, `SignatureEvent.Pickup`, and the captured metadata, the UI marks the renter signature step complete, and the staff signature step remains pending. +2. **Given** the renter signature is recorded, **When** the agent records the staff signature, **Then** `/rental/POST/api/Rental/contract/sign` is called a second time with role `Staff`, the UI requires distinct files or acknowledgements, and both signature cards display signed timestamps before enabling the final confirmation. +3. **Given** both signatures are marked complete, **When** the agent confirms vehicle receipt, **Then** `/rental/POST/api/Rental/vehicle/receive` is sent with the rental identifier and staff handover details, the rental status updates to "In Progress" within the summary, and the fulfillment checklist shows all items completed with clear success messaging. + +### Edge Cases + +- Booking is opened in fulfillment view after another colleague already advanced certain steps; the UI must reconcile backend state and mark completed steps automatically without duplicating API calls. +- Any API call in the sequence fails (network, validation, or backend rejection); the UI must show an actionable error, keep previous steps intact, and allow a retry once the issue is resolved. +- Staff attempts to skip ahead (e.g., trying to upload a signature before a contract exists); the interface must provide clear guidance and keep later actions disabled until prerequisites are met. +- Inspection evidence or signature files exceed allowed size or fail security checks; the form must surface validation feedback without losing previously entered data. +- Session expires or staff identity cannot be confirmed mid-flow; the user is redirected to re-authenticate and sees a message explaining that no irreversible actions occurred. + +## Requirements _(mandatory)_ + +### 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-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. +- **FR-005**: After check-in, invoke `/rental/POST/api/Rental` with the booking identifier, store the returned `rentalId`, surface confirmation messaging, and block subsequent steps if the call fails or returns an unexpected payload. +- **FR-006**: Require a contract before inspection by calling `/rental/POST/api/Rental/contract` with the new `rentalId` and the selected e-sign provider, capture the `contractId`, and display an audit trail entry. +- **FR-007**: Collect inspection inputs (battery capacity, inspection timestamp, inspector identifier, media URL) and submit them via `/rental/POST/api/Rental/inspection`, presenting the inspection reference and allowing edits until signatures begin. +- **FR-008**: Capture two distinct `/rental/POST/api/Rental/contract/sign` submissions—one with role `Renter`, one with role `Staff`—and require both to succeed before enabling `/rental/POST/api/Rental/vehicle/receive`. +- **FR-009**: Upon vehicle receipt confirmation, call `/rental/POST/api/Rental/vehicle/receive` with the rental identifier, received timestamp, and staff identifier, and update the booking overview to show the rental as "In Progress". +- **FR-010**: Provide persistent status indicators, error handling, and audit notes so staff, QA, and stakeholders can review which steps succeeded, failed, or were skipped due to backend state reconciliation. + +### Key Entities _(include if feature involves data)_ + +- **Booking Fulfillment Summary**: Aggregates booking, renter, vehicle, and rental snapshots surfaced to staff so they can verify contextual details before each action. +- **Fulfillment Step State**: Tracks the current status, timestamps, and backend identifiers (e.g., `rentalId`, `contractId`, inspection reference) for each milestone, supporting resume-after-refresh. +- **Signature Record**: Represents each signing event with role, document details, evidence URL, and audit metadata, ensuring both renter and staff signatures are accounted for prior to vehicle handover. + +## Assumptions + +- Only bookings that are fully paid and not cancelled will expose the fulfillment entry point; others continue to use the existing dashboard experience. +- Staff identity, including `staffId`, is already available through the authenticated session and can be attached to inspection and vehicle receipt payloads without additional prompts. +- Signature capture leverages existing digital or uploaded ink signature assets that can be converted to a document URL before invoking the signing endpoint. +- Media for inspections (photos, videos) are hosted on an approved storage provider that returns a URL compatible with the backend contract. + +## Success Criteria _(mandatory)_ + +### Measurable Outcomes + +- **SC-001**: During user acceptance testing, 95% of eligible bookings are advanced from "Approved" to "In Progress" within 10 minutes of an agent opening the fulfillment route, demonstrating that the flow removes bottlenecks. +- **SC-003**: QA regression logs confirm that the checklist never issues API calls out of the mandated order across 100% of automated and manual test cases. diff --git a/specs/001-staff-booking-flow/tasks.md b/specs/001-staff-booking-flow/tasks.md new file mode 100644 index 0000000..344ede9 --- /dev/null +++ b/specs/001-staff-booking-flow/tasks.md @@ -0,0 +1,207 @@ +--- +description: 'Task list template for feature implementation' +--- + +# Tasks: Staff Booking Fulfillment Workspace + +**Input**: Design documents from `/specs/001-staff-booking-flow/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Organization**: Tasks are grouped by user story so each increment can deploy, validate, and roll back independently. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- Angular SPA: code under `src/app/**`; shared orchestrators in `src/app/core-logic/**`, feature UI in `src/app/features/**`, reusable primitives in `src/app/lib/**`. +- Generated OpenAPI clients live in `src/contract/**`; modify only after running `pnpm generate-openapi`. +- Validation artifacts (manual scripts, docs, or optional specs) should live alongside the touched feature (`src/app/features/...`) or supporting `core-logic` service notes. +- Use `pnpm` scripts (`pnpm lint`, `pnpm lint:fix`, `pnpm lint-style`, optional `pnpm test`) for quality gates—never run `ng` or `npm` equivalents directly. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Prepare the workspace and shared documentation before adding fulfillment code paths. + +- [x] T001 Run `pnpm install` in workspace root ./ to sync dependencies before feature work +- [x] T002 Run `pnpm lint` and `pnpm lint-style` in workspace root ./ to capture baseline diagnostics +- [x] T003 Create feature architecture README describing planned folders at `src/app/features/staff/booking-fulfillment/README.md` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish fulfillment domain types and signal stores required by every story. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T004 Define fulfillment domain types (steps, artifacts, timeline, errors) in `src/app/core-logic/rental-fulfillment/fulfillment.types.ts` +- [x] T005 Implement signal-backed `FulfillmentStateStore` managing ordered milestones in `src/app/core-logic/rental-fulfillment/fulfillment.state.ts` +- [x] T006 Implement `FulfillmentOrchestrator` coordinating generated API clients in `src/app/core-logic/rental-fulfillment/fulfillment.service.ts` +- [x] T007 Export rental-fulfillment APIs through `src/app/core-logic/rental-fulfillment/index.ts` +- [x] T008 Extend booking aggregation with a single-record loader and timeline merge in `src/app/core-logic/bookings/bookings.service.ts` + **Checkpoint**: Foundation ready—user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Booking Fulfillment Entry (Priority: P1) 🎯 MVP + +**Goal**: Give staff a dedicated fulfillment route that loads booking context and enables mandatory check-in. + +**Independent Validation**: Manual walkthrough on desktop verifying navigation and check-in for the new route. + +### Validation for User Story 1 ⚠️ + +- [x] T010 [P] [US1] Outline manual QA checkpoints for fulfillment entry at `specs/001-staff-booking-flow/checklists/us1-booking-entry.md` + +### Implementation for User Story 1 + +- [x] T012 [US1] Add booking fulfillment lazy route with staff guard and bookingId param at `src/app/features/staff/booking-fulfillment/booking-fulfillment.routes.ts` +- [x] T013 [US1] Scaffold `FulfillmentPage` component (signals, OnPush, providers) at `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts|.html|.scss` +- [x] T014 [US1] Render booking summary and sequential checklist UI using fulfillment state signals in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.html` +- [x] T015 [US1] Wire check-in CTA to `FulfillmentOrchestrator.checkInBooking` with optimistic updates in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts` +- [x] T016 [US1] Add "Tiếp tục xử lý" CTA in staff booking details overlay to navigate to fulfillment route in `src/app/features/staff/staff-dashboard/staff-dashboard.html` and `src/app/features/staff/staff-dashboard/staff-dashboard.ts` +- [x] T017 [US1] Set document title for fulfillment entry in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts` + **Checkpoint**: User Story 1 is fully functional and independently testable + +## Phase 4: User Story 2 - Prepare Rental Package (Priority: P2) + +**Goal**: Allow staff to create the rental, generate the contract, and capture the inspection while enforcing sequential dependencies. + +**Independent Validation**: Manual QA script covering rental/contract/inspection steps. + +### Validation for User Story 2 ⚠️ + +- [x] T019 [P] [US2] Document manual QA scenarios for rental package flow at `specs/001-staff-booking-flow/checklists/us2-rental-package.md` + +### Implementation for User Story 2 + +- [x] T021 [US2] Extend `FulfillmentOrchestrator` with rental and contract creation flows plus error handling in `src/app/core-logic/rental-fulfillment/fulfillment.service.ts` +- [x] T022 [P] [US2] Build inspection reactive form component meeting research requirements at `src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.ts|.html|.scss` +- [x] T023 [US2] Integrate rental, contract, and inspection steps with gating logic in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.html` +- [x] T024 [US2] Surface returned `rentalId`, `contractId`, and inspection reference in summary/timeline signals in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts` + **Checkpoint**: User Stories 1 and 2 operate independently with validated evidence + +--- + +## Phase 5: User Story 3 - Finalize Contract and Vehicle Handover (Priority: P3) + +**Goal**: Capture renter/staff signatures and confirm vehicle receipt so rentals transition to In Progress with full audit trail. + +**Independent Validation**: Stakeholder ride-along script, dual signature verification logs, and confirmation UI review. + +### Validation for User Story 3 ⚠️ + +- [x] T026 [P] [US3] Draft manual QA script for signature + vehicle receive flow at `specs/001-staff-booking-flow/checklists/us3-finalization.md` + +### Implementation for User Story 3 + +- [x] T028 [US3] Add renter/staff signature and vehicle receive orchestration with retry support in `src/app/core-logic/rental-fulfillment/fulfillment.service.ts` +- [x] T029 [P] [US3] Create signature step component handling dual submissions at `src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.ts|.html|.scss` +- [x] T030 [US3] Integrate signature gating and completion feedback in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.html` +- [x] T031 [US3] Render vehicle receipt confirmation with staff metadata in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts` +- [x] T032 [US3] Reconcile backend-completed steps into the UI timeline and badges in `src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts` + +**Checkpoint**: All fulfillment milestones complete with signatures and vehicle handover recorded + +--- + +## Final Phase: Polish & Cross-Cutting Concerns + +**Purpose**: Consolidate documentation, performance benchmarks, and release checks spanning all stories. + +- [ ] T033 [P] Update `specs/001-staff-booking-flow/quickstart.md` with links to recorded QA evidence +- [ ] T034 [P] Document render timing/profiling results (<100 ms target) in `src/app/features/staff/booking-fulfillment/README.md` +- [ ] T035 Run `pnpm lint`, `pnpm lint-style`, and `pnpm build` in workspace root ./ before handoff + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies—must complete before foundational scaffolding. +- **Foundational (Phase 2)**: Depends on Setup; blocks every user story until fulfillment types, store, and orchestrator exist. +- **User Stories (Phases 3–5)**: Each depends on Foundational completion. Implement in priority order (P1 → P2 → P3) unless team members tackle separate stories after ensuring shared signals are stable. +- **Polish (Final Phase)**: Runs after desired user stories ship; wraps quality gates and documentation. + +### User Story Dependencies + +- **US1**: Requires Tasks T004–T009; unlocks fulfillment routing and booking check-in. +- **US2**: Requires US1 state structure plus Tasks T021/T022 contract; introduces rental + inspection steps while remaining independently deployable. +- **US3**: Builds atop artifacts from US2 but can start once orchestrator exposes rental identifiers; centers on signatures and vehicle handover. + +### Within Each User Story + +- Draft validation plan before implementation; execute evidence tasks once UI/services are wired. +- Update `FulfillmentOrchestrator` and signal store before layering UI components that consume them. +- Preserve sequential API enforcement—never enable a step before its prerequisites reach `fulfilled` state. +- Capture outcome notes so QA can confirm expected behavior. + +--- + +## Parallel Execution Examples + +### User Story 1 + +```bash +# Documentation vs. implementation can proceed independently: +Task T010 [P] prepares manual QA checkpoints +Task T012 scaffolds booking-fulfillment routing in `booking-fulfillment.routes.ts` +Task T016 updates `staff-dashboard.html` to expose the CTA +``` + +### User Story 2 + +```bash +# Split work across service and UI tracks: +Task T021 hardens `fulfillment.service.ts` for rental/contract APIs +Task T022 [P] builds the inspection reactive form component +Task T024 surfaces returned identifiers for timeline signals +``` + +### User Story 3 + +```bash +# Parallelize signature UX and orchestration: +Task T028 upgrades orchestration for signatures & vehicle receive +Task T029 [P] implements the signature-step component UI +Task T031 finalizes vehicle receipt confirmation messaging +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Finish Setup and Foundational phases (Tasks T001–T009). +2. Deliver US1 (Tasks T010–T018) to unlock the fulfillment route and booking check-in. +3. Execute US1 validation plan and share validation evidence before expanding scope. + +### Incremental Delivery + +1. Ship MVP (US1) with validated navigation and check-in. +2. Layer US2 to enable rental package creation while maintaining independent deployability and validation. +3. Complete US3 to collect signatures and confirm handover, ensuring telemetry stays green. +4. Close with Polish tasks (T033–T035) before release. + +### Parallel Team Strategy + +- After Foundational tasks land, assign separate developers to US1, US2, and US3 using [P] tasks (T010, T022, T029) to avoid file contention. +- Sync on shared orchestrator interfaces to keep service/UX workstreams aligned. +- Merge frequently and rerun lint/build gates to keep zoneless change detection stable. + +--- + +## Notes + +- [P] tasks touch isolated files or documentation and can run alongside other workstreams. +- Every user story logs manual QA evidence in `specs/001-staff-booking-flow/checklists/*`. +- Signals must update via `.set()`/`.update()` only; avoid mutation to respect zoneless change detection. +- Commit after each task or logical bundle so rollbacks stay targeted. diff --git a/src/app/core-logic/bookings/bookings.service.ts b/src/app/core-logic/bookings/bookings.service.ts index 4f9a9f8..f6f695a 100644 --- a/src/app/core-logic/bookings/bookings.service.ts +++ b/src/app/core-logic/bookings/bookings.service.ts @@ -14,6 +14,8 @@ import { BookingDetailsDtoListApiResponse, BookingService, BookingStatus as BookingStatusEnum, + BookingVerificationStatus as BookingVerificationStatusEnum, + ContractDto, RentalDetailsDto, RentalDetailsDtoListApiResponse, RentalService, @@ -23,6 +25,10 @@ import { VehicleDetailsDto, VehicleDetailsDtoApiResponse, } from '../../../contract'; +import { + BookingFulfillmentSummary, + FulfillmentTimelineEvent, +} from '../rental-fulfillment/fulfillment.types'; export interface StaffBookingRecord { readonly bookingId: string; @@ -121,6 +127,25 @@ export class BookingsService { ); } + loadBookingFulfillmentSummary( + bookingId: string, + ): Observable { + const normalizedId = this._normalizeString(bookingId); + if (!normalizedId) { + return of(undefined); + } + + const cachedRecord = this._staffBookings().find((record) => record.bookingId === normalizedId); + if (cachedRecord) { + return of(this._toFulfillmentSummary(cachedRecord)); + } + + return this.loadStaffBookings().pipe( + map((records) => records.find((record) => record.bookingId === normalizedId)), + map((record) => (record ? this._toFulfillmentSummary(record) : undefined)), + ); + } + /** * Get bookings for a renter (customer view) */ @@ -250,6 +275,120 @@ export class BookingsService { return records.sort((a, b) => this._compareByDateDesc(a.bookingCreatedAt, b.bookingCreatedAt)); } + private _toFulfillmentSummary(record: StaffBookingRecord): BookingFulfillmentSummary { + const booking: BookingDetailsDto = { + bookingId: record.bookingId, + renterId: record.renterId, + vehicleAtStationId: record.vehicleAtStationId, + bookingCreatedAt: record.bookingCreatedAt, + startTime: record.startTime, + endTime: record.endTime, + status: record.status, + verificationStatus: record.verificationStatus, + verifiedByStaffId: record.verifiedByStaffId ?? null, + verifiedAt: record.verifiedAt ?? null, + cancelReason: record.cancelReason ?? null, + }; + + return { + bookingId: record.bookingId, + status: record.status, + verificationStatus: record.verificationStatus, + booking, + renterProfile: record.renterProfile, + vehicleDetails: record.vehicleDetails, + rental: record.rental, + timeline: this._buildFulfillmentTimeline(record), + }; + } + + private _buildFulfillmentTimeline(record: StaffBookingRecord): FulfillmentTimelineEvent[] { + const now = new Date().toISOString(); + const events: FulfillmentTimelineEvent[] = []; + + if (record.verificationStatus === BookingVerificationStatusEnum.Approved) { + const occurredAt = + this._normalizeString(record.verifiedAt) ?? + this._normalizeString(record.bookingCreatedAt) ?? + now; + + events.push({ + step: 'checkin', + title: 'Đặt xe đã được duyệt', + description: 'Nhân viên đã xác nhận đặt xe và chuyển sang chuẩn bị thuê.', + actor: 'staff', + occurredAt, + metadata: this._buildMetadata({ staffId: record.verifiedByStaffId }), + }); + } + + const rentalId = this._normalizeString(record.rental?.rentalId); + if (rentalId) { + const rentalStartAt = + this._normalizeString(record.rental?.startTime) ?? + this._normalizeString(record.rental?.booking?.startDate) ?? + now; + + events.push({ + step: 'create-rental', + title: 'Đơn thuê đã được tạo', + description: 'Đơn thuê được khởi tạo sau khi booking được phê duyệt.', + actor: 'staff', + occurredAt: rentalStartAt, + metadata: this._buildMetadata({ rentalId }), + }); + + const validContracts = (record.rental?.contracts ?? []).reduce( + (accumulator, contract) => { + if (contract?.contractId) { + accumulator.push(contract); + } + return accumulator; + }, + [], + ); + + if (validContracts.length > 0) { + const sortedContracts = [...validContracts].sort((first, second) => { + const firstTime = Date.parse(first.issuedAt ?? ''); + const secondTime = Date.parse(second.issuedAt ?? ''); + return firstTime - secondTime; + }); + + const latestContract = sortedContracts[sortedContracts.length - 1]; + const occurredAt = this._normalizeString(latestContract.issuedAt) ?? rentalStartAt; + + events.push({ + step: 'create-contract', + title: 'Hợp đồng đã được phát hành', + description: 'Hợp đồng điện tử gắn với đơn thuê đã sẵn sàng cho chữ ký.', + actor: 'staff', + occurredAt, + metadata: this._buildMetadata({ contractId: latestContract.contractId }), + }); + } + } + + return events.sort( + (first, second) => Date.parse(first.occurredAt) - Date.parse(second.occurredAt), + ); + } + + private _buildMetadata( + entries: Record, + ): Readonly> | undefined { + const metadata: Record = {}; + + for (const [key, value] of Object.entries(entries)) { + const normalized = this._normalizeString(value); + if (normalized) { + metadata[key] = normalized; + } + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; + } + private _resolveVehicleDetails( rental: RentalDetailsDto | undefined, vehicleDetailsMap: VehicleDetailsMap, diff --git a/src/app/core-logic/rental-fulfillment/fulfillment.analytics.ts b/src/app/core-logic/rental-fulfillment/fulfillment.analytics.ts new file mode 100644 index 0000000..5303eb7 --- /dev/null +++ b/src/app/core-logic/rental-fulfillment/fulfillment.analytics.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@angular/core'; +import { + FulfillmentAnalyticsFailurePayload, + FulfillmentAnalyticsPayload, + FulfillmentAnalyticsRoutePayload, + FulfillmentAnalyticsStartedPayload, + FulfillmentAnalyticsSuccessPayload, + FulfillmentStepId, +} from './fulfillment.types'; + +interface GlobalAnalyticsChannel { + readonly dataLayer?: unknown[]; + readonly gtag?: (...args: unknown[]) => void; +} + +@Injectable({ providedIn: 'root' }) +export class FulfillmentAnalyticsService { + routeEntered(bookingId: string): void { + if (!bookingId) { + return; + } + + const payload: FulfillmentAnalyticsRoutePayload = { + name: 'staff_booking_fulfillment_route_viewed', + bookingId, + }; + this._emit(payload); + } + + stepStarted(bookingId: string, step: FulfillmentStepId): void { + if (!bookingId || !step) { + return; + } + + const payload: FulfillmentAnalyticsStartedPayload = { + name: 'staff_booking_fulfillment_step_started', + bookingId, + step, + }; + this._emit(payload); + } + + stepCompleted(bookingId: string, step: FulfillmentStepId, durationMs?: number): void { + const payload: FulfillmentAnalyticsSuccessPayload = { + name: 'staff_booking_fulfillment_step_completed', + bookingId, + step, + status: 'success', + durationMs, + }; + this._emit(payload); + } + + stepFailed( + bookingId: string, + step: FulfillmentStepId, + errorCode?: string, + durationMs?: number, + ): void { + const payload: FulfillmentAnalyticsFailurePayload = { + name: 'staff_booking_fulfillment_step_failed', + bookingId, + step, + status: 'error', + durationMs, + errorCode, + }; + this._emit(payload); + } + + private _emit(payload: FulfillmentAnalyticsPayload): void { + if (!payload.bookingId) { + return; + } + + if ('step' in payload && !payload.step) { + return; + } + + const channel = this._resolveGlobalChannel(); + let emitted = false; + + if (channel?.dataLayer && Array.isArray(channel.dataLayer)) { + channel.dataLayer.push(payload); + emitted = true; + } + + if (typeof channel?.gtag === 'function') { + channel.gtag('event', payload.name, payload); + emitted = true; + } + + if (!emitted && typeof console !== 'undefined') { + console.info('[analytics] staff fulfillment event', payload); + } + + if (typeof window !== 'undefined') { + const customEvent = new CustomEvent('staff-booking-fulfillment-analytics', { + detail: payload, + }); + window.dispatchEvent(customEvent); + } + } + + private _resolveGlobalChannel(): GlobalAnalyticsChannel | undefined { + if (typeof window === 'undefined') { + return undefined; + } + + return window as unknown as GlobalAnalyticsChannel; + } +} diff --git a/src/app/core-logic/rental-fulfillment/fulfillment.service.ts b/src/app/core-logic/rental-fulfillment/fulfillment.service.ts new file mode 100644 index 0000000..43b7e0b --- /dev/null +++ b/src/app/core-logic/rental-fulfillment/fulfillment.service.ts @@ -0,0 +1,703 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + ApiResponse, + BookingService, + BookingStatus as BookingStatusEnum, + BookingVerificationStatus as BookingVerificationStatusEnum, + CheckinBookingRequest, + ContractDto, + ContractStatus, + CreateContractRequest, + CreateRentalRequest, + EsignProvider, + GuidApiResponse, + PartyRole, + ReceiveInspectionRequest, + ReceiveVehicleRequest, + RentalService, + SignContractRequest, + SignatureEvent, + SignatureType, +} from '../../../contract'; +import { BookingsService } from '../bookings/bookings.service'; +import { UserService } from '../user/user.service'; +import { TokenService } from '../token/token.service'; +import { + BookingFulfillmentSummary, + FulfillmentArtifact, + FulfillmentError, + FulfillmentStepId, + FulfillmentStepState, + FulfillmentTimelineEvent, +} from './fulfillment.types'; +import { FulfillmentAnalyticsService } from './fulfillment.analytics'; +import { FulfillmentStateStore } from './fulfillment.state'; +import { catchError, finalize, map, Observable, of, switchMap, tap, throwError } from 'rxjs'; + +export interface InspectionPayload { + readonly currentBatteryCapacityKwh: number; + readonly inspectedAt: string; + readonly evidenceUrl?: string | null; +} + +export interface SignaturePayload { + readonly role: 'renter' | 'staff'; + readonly signedAt: string; + readonly documentUrl?: string | null; + readonly documentHash?: string | null; + readonly signatureType?: SignatureType; + readonly eSignPayload?: SignContractRequest['eSignPayload']; +} + +export interface VehicleReceivePayload { + readonly receivedAt: string; +} + +@Injectable({ providedIn: 'root' }) +export class FulfillmentOrchestrator { + private readonly _bookingService = inject(BookingService); + private readonly _rentalService = inject(RentalService); + private readonly _bookingsService = inject(BookingsService); + private readonly _userService = inject(UserService); + private readonly _tokenService = inject(TokenService); + private readonly _state = inject(FulfillmentStateStore); + private readonly _analytics = inject(FulfillmentAnalyticsService); + + private _bookingId: string | undefined; + + readonly summary = this._state.summary; + readonly steps = this._state.steps; + readonly snapshot = this._state.snapshot; + readonly completionPercentage = this._state.completionPercentage; + readonly isBusy = this._state.isBusy; + readonly nextStep = this._state.nextStep; + + initialize(bookingId: string): Observable { + const normalizedId = this._normalizeString(bookingId); + if (!normalizedId) { + return throwError(() => new Error('Booking identifier is required.')); + } + + this._bookingId = normalizedId; + this._state.reset(); + this._state.setBusy(true); + + return this._bookingsService.loadBookingFulfillmentSummary(normalizedId).pipe( + tap((summary) => { + this._applySummary(summary); + }), + finalize(() => { + this._state.setBusy(false); + }), + map(() => void 0), + catchError((error) => { + this._state.setSummary(undefined); + return throwError(() => error); + }), + ); + } + + refresh(): Observable { + const bookingId = this._requireBookingId(); + return this._bookingsService.loadBookingFulfillmentSummary(bookingId).pipe( + tap((summary) => { + this._applySummary(summary); + }), + map(() => void 0), + catchError((error) => { + console.warn('Không thể cập nhật trạng thái fulfillment', error); + return of(void 0); + }), + ); + } + + checkInBooking(): Observable { + const bookingId = this._requireBookingId(); + const startedAt = this._now(); + const staffId = this._resolveStaffId(); + + if (!staffId) { + const error = new Error('Không xác định được nhân viên duyệt booking.'); + return this._handleStepError('checkin', error, startedAt); + } + + this._state.setBusy(true); + this._state.markStepInProgress('checkin'); + + const request: CheckinBookingRequest = { + bookingId, + verifiedByStaffId: staffId, + bookingVerificationStatus: BookingVerificationStatusEnum.Approved, + }; + + return this._bookingService.apiBookingCheckinPost(request).pipe( + tap(() => { + const completedAt = new Date().toISOString(); + this._state.markStepFulfilled('checkin', undefined, completedAt); + this._state.appendTimeline({ + step: 'checkin', + title: 'Đặt xe đã được duyệt', + description: 'Nhân viên đã xác nhận đặt xe.', + actor: 'staff', + occurredAt: completedAt, + metadata: this._buildMetadata({ staffId }), + }); + this._state.mergeSummary({ + bookingId, + verificationStatus: BookingVerificationStatusEnum.Approved, + }); + this._analytics.stepCompleted(bookingId, 'checkin', this._elapsed(startedAt)); + }), + switchMap(() => this.refresh()), + catchError((error) => this._handleStepError('checkin', error, startedAt)), + finalize(() => { + this._state.setBusy(false); + }), + ); + } + + createRental(): Observable { + const bookingId = this._requireBookingId(); + const startedAt = this._now(); + const booking = this._state.summary()?.booking; + + this._state.setBusy(true); + this._state.markStepInProgress('create-rental'); + + const request: CreateRentalRequest = { + bookingId, + startTime: this._normalizeString(booking?.startTime), + endTime: this._normalizeString(booking?.endTime), + }; + + return this._rentalService.apiRentalPost(request).pipe( + tap((response: GuidApiResponse) => { + const rentalId = this._normalizeString(response.data); + const artifact: FulfillmentArtifact | undefined = rentalId ? { rentalId } : undefined; + const completedAt = new Date().toISOString(); + this._state.markStepFulfilled('create-rental', artifact, completedAt); + if (rentalId) { + this._state.appendTimeline({ + step: 'create-rental', + title: 'Đơn thuê đã được tạo', + description: 'Đơn thuê được khởi tạo sau khi phê duyệt đặt xe.', + actor: 'staff', + occurredAt: completedAt, + metadata: this._buildMetadata({ rentalId }), + }); + } + this._analytics.stepCompleted(bookingId, 'create-rental', this._elapsed(startedAt)); + }), + switchMap(() => this.refresh()), + catchError((error) => this._handleStepError('create-rental', error, startedAt)), + finalize(() => { + this._state.setBusy(false); + }), + ); + } + + createContract(provider: EsignProvider): Observable { + const bookingId = this._requireBookingId(); + const rentalId = this._requireRentalId(); + const startedAt = this._now(); + + this._state.setBusy(true); + this._state.markStepInProgress('create-contract'); + + const request: CreateContractRequest = { + rentalId, + provider, + }; + + return this._rentalService.apiRentalContractPost(request).pipe( + tap((response: GuidApiResponse) => { + const contractId = this._normalizeString(response.data); + const artifact: FulfillmentArtifact | undefined = contractId ? { contractId } : undefined; + const completedAt = new Date().toISOString(); + this._state.markStepFulfilled('create-contract', artifact, completedAt); + if (contractId) { + this._state.appendTimeline({ + step: 'create-contract', + title: 'Hợp đồng đã được phát hành', + description: 'Hợp đồng điện tử gắn với đơn thuê đã sẵn sàng.', + actor: 'staff', + occurredAt: completedAt, + metadata: this._buildMetadata({ contractId }), + }); + } + this._analytics.stepCompleted(bookingId, 'create-contract', this._elapsed(startedAt)); + }), + switchMap(() => this.refresh()), + catchError((error) => this._handleStepError('create-contract', error, startedAt)), + finalize(() => { + this._state.setBusy(false); + }), + ); + } + + submitInspection(payload: InspectionPayload): Observable { + const bookingId = this._requireBookingId(); + const rentalId = this._requireRentalId(); + const staffId = this._resolveStaffId(); + const startedAt = this._now(); + + this._state.setBusy(true); + this._state.markStepInProgress('inspection'); + + const request: ReceiveInspectionRequest = { + rentalId, + currentBatteryCapacityKwh: payload.currentBatteryCapacityKwh, + inspectedAt: payload.inspectedAt, + inspectorStaffId: staffId, + url: payload.evidenceUrl ?? null, + }; + + return this._rentalService.apiRentalInspectionPost(request).pipe( + tap((response: GuidApiResponse) => { + const inspectionId = this._normalizeString(response.data); + const artifact: FulfillmentArtifact | undefined = inspectionId + ? { inspectionId } + : undefined; + const completedAt = payload.inspectedAt ?? new Date().toISOString(); + this._state.markStepFulfilled('inspection', artifact, completedAt); + if (inspectionId) { + this._state.appendTimeline({ + step: 'inspection', + title: 'Kiểm tra xe đã hoàn tất', + description: 'Biên bản giao nhận trước khi bàn giao xe đã được lưu.', + actor: 'staff', + occurredAt: completedAt, + metadata: this._buildMetadata({ inspectionId, staffId }), + }); + } + this._analytics.stepCompleted(bookingId, 'inspection', this._elapsed(startedAt)); + }), + switchMap(() => this.refresh()), + catchError((error) => this._handleStepError('inspection', error, startedAt)), + finalize(() => { + this._state.setBusy(false); + }), + ); + } + + signContract(payload: SignaturePayload): Observable { + const bookingId = this._requireBookingId(); + const contractId = this._requireContractId(); + const startedAt = this._now(); + + const stepId: FulfillmentStepId = payload.role === 'renter' ? 'sign-renter' : 'sign-staff'; + const actor: 'renter' | 'staff' = payload.role; + + this._state.setBusy(true); + this._state.markStepInProgress(stepId); + + const request: SignContractRequest = { + createSignaturePayloadDto: { + contractId, + documentUrl: payload.documentUrl ?? null, + documentHash: payload.documentHash ?? null, + role: payload.role === 'renter' ? PartyRole.Renter : PartyRole.Staff, + signatureEvent: SignatureEvent.Pickup, + type: payload.signatureType, + signedAt: payload.signedAt, + }, + eSignPayload: payload.eSignPayload, + }; + + return this._rentalService.apiRentalContractSignPost(request).pipe( + tap((response: GuidApiResponse) => { + const signatureId = this._normalizeString(response.data); + const artifact: FulfillmentArtifact | undefined = signatureId + ? payload.role === 'renter' + ? { renterSignatureId: signatureId } + : { staffSignatureId: signatureId } + : undefined; + const completedAt = payload.signedAt ?? new Date().toISOString(); + this._state.markStepFulfilled(stepId, artifact, completedAt); + if (signatureId) { + this._state.appendTimeline({ + step: stepId, + title: actor === 'renter' ? 'Khách thuê đã ký hợp đồng' : 'Nhân viên đã ký hợp đồng', + description: + actor === 'renter' + ? 'Chữ ký của khách thuê đã được ghi nhận.' + : 'Nhân viên đã xác nhận chữ ký trên hợp đồng.', + actor, + occurredAt: completedAt, + metadata: this._buildMetadata({ contractId, signatureId }), + }); + } + this._analytics.stepCompleted(bookingId, stepId, this._elapsed(startedAt)); + }), + switchMap(() => this.refresh()), + catchError((error) => this._handleStepError(stepId, error, startedAt)), + finalize(() => { + this._state.setBusy(false); + }), + ); + } + + confirmVehicleReceive(payload: VehicleReceivePayload): Observable { + const bookingId = this._requireBookingId(); + const rentalId = this._requireRentalId(); + const staffId = this._resolveStaffId(); + const startedAt = this._now(); + + this._state.setBusy(true); + this._state.markStepInProgress('vehicle-receive'); + + const request: ReceiveVehicleRequest = { + rentalId, + receivedAt: payload.receivedAt, + receivedByStaffId: staffId, + }; + + return this._rentalService.apiRentalVehicleReceivePost(request).pipe( + tap(() => { + const completedAt = payload.receivedAt ?? new Date().toISOString(); + const artifact: FulfillmentArtifact = { + vehicleReceipt: { + receivedAt: completedAt, + receivedByStaffId: staffId ?? '', + }, + }; + this._state.markStepFulfilled('vehicle-receive', artifact, completedAt); + this._state.appendTimeline({ + step: 'vehicle-receive', + title: 'Xe đã được bàn giao', + description: 'Nhân viên xác nhận khách thuê đã nhận xe.', + actor: 'staff', + occurredAt: completedAt, + metadata: this._buildMetadata({ rentalId, staffId }), + }); + this._analytics.stepCompleted(bookingId, 'vehicle-receive', this._elapsed(startedAt)); + }), + switchMap(() => this.refresh()), + catchError((error) => this._handleStepError('vehicle-receive', error, startedAt)), + finalize(() => { + this._state.setBusy(false); + }), + ); + } + + private _applySummary(summary: BookingFulfillmentSummary | undefined): void { + if (!summary) { + this._state.setSummary(undefined); + this._resetSteps(); + return; + } + + this._state.setSummary(summary); + this._resetSteps(); + + const checkinEvent = this._findTimelineEvent(summary.timeline, 'checkin'); + if ( + summary.verificationStatus === BookingVerificationStatusEnum.Approved || + summary.status === BookingStatusEnum.Verified || + summary.status === BookingStatusEnum.RentalCreated || + !!checkinEvent + ) { + this._state.markStepFulfilled('checkin', undefined, checkinEvent?.occurredAt); + } + + const rental = summary.rental; + if (rental?.rentalId) { + const rentalEvent = this._findTimelineEvent(summary.timeline, 'create-rental'); + const completedAt = rentalEvent?.occurredAt ?? rental.startTime ?? summary.booking?.startTime; + this._state.markStepFulfilled('create-rental', { rentalId: rental.rentalId }, completedAt); + } + + const latestContract = this._resolveLatestContract(summary); + if (latestContract?.contractId) { + const contractEvent = this._findTimelineEvent(summary.timeline, 'create-contract'); + const completedAt = contractEvent?.occurredAt ?? latestContract.issuedAt; + this._state.markStepFulfilled( + 'create-contract', + { contractId: latestContract.contractId }, + completedAt, + ); + + if (latestContract.status === ContractStatus.PartiallySigned) { + this._state.markStepFulfilled('sign-renter', undefined, contractEvent?.occurredAt); + } + + if (latestContract.status === ContractStatus.Signed) { + this._state.markStepFulfilled('sign-renter', undefined, contractEvent?.occurredAt); + this._state.markStepFulfilled('sign-staff', undefined, contractEvent?.occurredAt); + } + } + + const inspectionEvent = this._findTimelineEvent(summary.timeline, 'inspection'); + if (inspectionEvent) { + const inspectionId = inspectionEvent.metadata?.['inspectionId']; + const artifact: FulfillmentArtifact | undefined = inspectionId ? { inspectionId } : undefined; + this._state.markStepFulfilled('inspection', artifact, inspectionEvent.occurredAt); + } + + const renterSignEvent = this._findTimelineEvent(summary.timeline, 'sign-renter'); + if (renterSignEvent) { + const signatureId = renterSignEvent.metadata?.['signatureId']; + const artifact: FulfillmentArtifact | undefined = signatureId + ? { renterSignatureId: signatureId } + : undefined; + this._state.markStepFulfilled('sign-renter', artifact, renterSignEvent.occurredAt); + } + + const staffSignEvent = this._findTimelineEvent(summary.timeline, 'sign-staff'); + if (staffSignEvent) { + const signatureId = staffSignEvent.metadata?.['signatureId']; + const artifact: FulfillmentArtifact | undefined = signatureId + ? { staffSignatureId: signatureId } + : undefined; + this._state.markStepFulfilled('sign-staff', artifact, staffSignEvent.occurredAt); + } + + const vehicleReceiveEvent = this._findTimelineEvent(summary.timeline, 'vehicle-receive'); + if (vehicleReceiveEvent) { + const staffId = vehicleReceiveEvent.metadata?.['staffId'] ?? ''; + const artifact: FulfillmentArtifact = { + vehicleReceipt: { + receivedAt: vehicleReceiveEvent.occurredAt, + receivedByStaffId: staffId, + }, + }; + this._state.markStepFulfilled('vehicle-receive', artifact, vehicleReceiveEvent.occurredAt); + } + } + + private _resetSteps(): void { + const currentSteps = this._state.steps(); + for (const step of currentSteps) { + this._state.clearStep(step.step); + } + } + + private _handleStepError( + stepId: FulfillmentStepId, + error: unknown, + startedAt: number, + ): Observable { + const bookingId = this._requireBookingId(); + const failure = this._toFulfillmentError(error); + this._state.markStepError(stepId, failure); + this._analytics.stepFailed(bookingId, stepId, failure.code, this._elapsed(startedAt)); + this._state.setBusy(false); + return throwError(() => error); + } + + private _resolveRentalId(): string | undefined { + const step = this._getStep('create-rental'); + const fromStep = this._normalizeString(step?.artifact?.rentalId); + if (fromStep) { + return fromStep; + } + + const summaryRental = this._state.summary()?.rental; + return this._normalizeString(summaryRental?.rentalId); + } + + private _requireRentalId(): string { + const rentalId = this._resolveRentalId(); + if (!rentalId) { + throw new Error('Rental ID is required. Hãy tạo đơn thuê trước.'); + } + return rentalId; + } + + private _resolveContractId(): string | undefined { + const step = this._getStep('create-contract'); + const fromStep = this._normalizeString(step?.artifact?.contractId); + if (fromStep) { + return fromStep; + } + + const latestContract = this._resolveLatestContract(this._state.summary()); + return this._normalizeString(latestContract?.contractId); + } + + private _requireContractId(): string { + const contractId = this._resolveContractId(); + if (!contractId) { + throw new Error('Contract ID is required. Hãy tạo hợp đồng trước.'); + } + return contractId; + } + + private _resolveLatestContract( + summary: BookingFulfillmentSummary | undefined, + ): ContractDto | undefined { + const contracts = summary?.rental?.contracts; + if (!contracts || contracts.length === 0) { + return undefined; + } + + return [...contracts] + .filter((contract): contract is ContractDto => !!contract?.contractId) + .sort((first, second) => { + const firstTime = Date.parse(first.issuedAt ?? ''); + const secondTime = Date.parse(second.issuedAt ?? ''); + return firstTime - secondTime; + }) + .at(-1); + } + + private _getStep(stepId: FulfillmentStepId): FulfillmentStepState | undefined { + return this._state.steps().find((step) => step.step === stepId); + } + + private _findTimelineEvent( + timeline: readonly FulfillmentTimelineEvent[] | undefined, + step: FulfillmentStepId, + ): FulfillmentTimelineEvent | undefined { + if (!timeline) { + return undefined; + } + + for (let index = timeline.length - 1; index >= 0; index -= 1) { + const event = timeline[index]; + if (event?.step === step) { + return event; + } + } + return undefined; + } + + private _buildMetadata( + input: Record, + ): Readonly> | undefined { + const metadata: Record = {}; + + for (const [key, value] of Object.entries(input)) { + const normalized = this._normalizeString(value); + if (normalized) { + metadata[key] = normalized; + } + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; + } + + private _resolveStaffId(): string | undefined { + const userId = this._normalizeString(this._userService.user?.id ?? undefined); + if (userId) { + return userId; + } + + const tokenUserId = this._resolveTokenUserId(); + if (tokenUserId) { + return tokenUserId; + } + + const summary = this._state.summary(); + return this._normalizeString(summary?.booking?.verifiedByStaffId); + } + + private _requireBookingId(): string { + const bookingId = this._bookingId; + if (!bookingId) { + throw new Error('Booking ID chưa được thiết lập. Gọi initialize() trước.'); + } + return bookingId; + } + + private _now(): number { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); + } + + private _elapsed(start: number): number | undefined { + const end = this._now(); + const duration = Math.max(0, end - start); + if (!Number.isFinite(duration)) { + return undefined; + } + return Math.round(duration); + } + + private _normalizeString(value: string | null | undefined): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + private _resolveTokenUserId(): string | undefined { + const accessToken = this._tokenService.accessToken.token; + const token = this._normalizeString(accessToken); + if (!token) { + return undefined; + } + + try { + const payload = this._tokenService.decodeToken(token); + const staffId = this._normalizeString(payload.StaffId); + if (staffId) { + return staffId; + } + + const subject = this._normalizeString(payload.sub); + if (subject) { + return subject; + } + + const rawPayload = payload as Record; + const nameIdentifierKey = + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'; + const nameIdentifierClaim = this._normalizeString( + typeof rawPayload[nameIdentifierKey] === 'string' + ? (rawPayload[nameIdentifierKey] as string) + : undefined, + ); + + return nameIdentifierClaim; + } catch { + return undefined; + } + } + + private _toFulfillmentError(error: unknown): FulfillmentError { + if (error instanceof HttpErrorResponse) { + const rawError = error.error as ApiResponse | string | undefined; + const message = this._normalizeString( + typeof rawError === 'string' + ? rawError + : ((rawError as ApiResponse | undefined)?.message ?? error.message), + ); + const code = this._normalizeString( + typeof rawError === 'object' && rawError !== null + ? ((rawError as ApiResponse | undefined)?.data?.code ?? undefined) + : undefined, + ); + + return { + message: message ?? 'Không thể hoàn thành thao tác. Vui lòng thử lại.', + code, + detail: error, + }; + } + + if (error instanceof Error && error.message) { + return { + message: error.message, + detail: error, + }; + } + + if (typeof error === 'string' && error.length > 0) { + return { + message: error, + detail: error, + }; + } + + return { + message: 'Không thể hoàn thành thao tác. Vui lòng thử lại.', + detail: error, + }; + } +} diff --git a/src/app/core-logic/rental-fulfillment/fulfillment.state.ts b/src/app/core-logic/rental-fulfillment/fulfillment.state.ts new file mode 100644 index 0000000..7c827ec --- /dev/null +++ b/src/app/core-logic/rental-fulfillment/fulfillment.state.ts @@ -0,0 +1,248 @@ +import { computed, Injectable, signal } from '@angular/core'; +import { + BookingFulfillmentSummary, + FulfillmentArtifact, + FulfillmentError, + FulfillmentStateSnapshot, + FulfillmentStepId, + FulfillmentStepState, + FulfillmentTimelineEvent, + FULFILLMENT_STEP_IDS, +} from './fulfillment.types'; + +const STEP_DEPENDENCIES: Record = { + checkin: [], + 'create-rental': ['checkin'], + 'create-contract': ['checkin', 'create-rental'], + inspection: ['create-contract'], + 'sign-renter': ['inspection'], + 'sign-staff': ['inspection', 'sign-renter'], + 'vehicle-receive': ['sign-staff'], +}; + +function createInitialSteps(): FulfillmentStepState[] { + return FULFILLMENT_STEP_IDS.map((step, index) => ({ + step, + status: 'pending', + requires: STEP_DEPENDENCIES[step] ?? (index === 0 ? [] : [FULFILLMENT_STEP_IDS[index - 1]]), + })); +} + +@Injectable({ providedIn: 'root' }) +export class FulfillmentStateStore { + private readonly _steps = signal(createInitialSteps()); + private readonly _summary = signal(undefined); + private readonly _isBusy = signal(false); + private readonly _lastUpdatedAt = signal(null); + + readonly steps = computed(() => + this._steps().map((step) => ({ + ...step, + requires: step.requires ? [...step.requires] : undefined, + artifact: step.artifact ? { ...step.artifact } : undefined, + error: step.error ? { ...step.error } : undefined, + })), + ); + + readonly summary = computed(() => { + const summary = this._summary(); + if (!summary) { + return undefined; + } + + return { + ...summary, + timeline: summary.timeline.map((event) => ({ + ...event, + metadata: event.metadata ? { ...event.metadata } : undefined, + })), + }; + }); + + readonly nextStep = computed(() => + this._steps().find((step) => step.status !== 'fulfilled'), + ); + + readonly completionPercentage = computed(() => { + const steps = this._steps(); + if (steps.length === 0) { + return 0; + } + + const fulfilledCount = steps.filter((step) => step.status === 'fulfilled').length; + return Math.round((fulfilledCount / steps.length) * 100); + }); + + readonly isBusy = this._isBusy.asReadonly(); + + readonly snapshot = computed(() => ({ + summary: this.summary(), + steps: this.steps(), + nextStep: this.nextStep(), + completionPercentage: this.completionPercentage(), + isBusy: this._isBusy(), + })); + + readonly lastUpdatedAt = this._lastUpdatedAt.asReadonly(); + + reset(): void { + this._summary.set(undefined); + this._steps.set(createInitialSteps()); + this._isBusy.set(false); + this._lastUpdatedAt.set(new Date().toISOString()); + } + + setSummary(summary: BookingFulfillmentSummary | undefined): void { + this._summary.set(summary ? { ...summary, timeline: [...summary.timeline] } : undefined); + this._touch(); + } + + mergeSummary(partial: Partial): void { + const current = this._summary(); + if (!current && !partial.bookingId) { + return; + } + + const nextTimeline = partial.timeline ?? current?.timeline ?? []; + this._summary.set( + current + ? { + ...current, + ...partial, + timeline: [...nextTimeline], + } + : partial.bookingId + ? { + bookingId: partial.bookingId, + status: partial.status, + verificationStatus: partial.verificationStatus, + booking: partial.booking, + renterProfile: partial.renterProfile, + vehicleDetails: partial.vehicleDetails, + rental: partial.rental, + timeline: [...nextTimeline], + } + : undefined, + ); + this._touch(); + } + + setTimeline(events: readonly FulfillmentTimelineEvent[]): void { + this._summary.update((current) => { + if (!current) { + return current; + } + + const ordered = [...events].sort( + (first, second) => Date.parse(first.occurredAt) - Date.parse(second.occurredAt), + ); + + return { + ...current, + timeline: ordered.map((event) => ({ + ...event, + metadata: event.metadata ? { ...event.metadata } : undefined, + })), + }; + }); + this._touch(); + } + + appendTimeline(event: FulfillmentTimelineEvent): void { + this._summary.update((current) => { + if (!current) { + return current; + } + + const nextTimeline = [...current.timeline, event].sort( + (first, second) => Date.parse(first.occurredAt) - Date.parse(second.occurredAt), + ); + + return { + ...current, + timeline: nextTimeline.map((entry) => ({ + ...entry, + metadata: entry.metadata ? { ...entry.metadata } : undefined, + })), + }; + }); + this._touch(); + } + + setBusy(isBusy: boolean): void { + this._isBusy.set(isBusy); + this._touch(); + } + + markStepInProgress( + stepId: FulfillmentStepId, + startedAt: string = new Date().toISOString(), + ): void { + this._applyStepUpdate(stepId, { + status: 'in-progress', + startedAt, + error: undefined, + }); + } + + markStepFulfilled( + stepId: FulfillmentStepId, + artifact?: FulfillmentArtifact, + completedAt: string = new Date().toISOString(), + ): void { + this._applyStepUpdate(stepId, { + status: 'fulfilled', + completedAt, + artifact, + error: undefined, + }); + } + + markStepError(stepId: FulfillmentStepId, error: FulfillmentError): void { + this._applyStepUpdate(stepId, { + status: 'error', + error, + }); + } + + clearStep(stepId: FulfillmentStepId): void { + const requires = STEP_DEPENDENCIES[stepId]; + this._applyStepUpdate(stepId, { + status: 'pending', + startedAt: undefined, + completedAt: undefined, + artifact: undefined, + error: undefined, + requires, + }); + } + + private _applyStepUpdate( + stepId: FulfillmentStepId, + changes: Partial, + ): void { + this._steps.update((steps) => { + const nextSteps: FulfillmentStepState[] = []; + for (const step of steps) { + if (step.step !== stepId) { + nextSteps.push(step); + continue; + } + + nextSteps.push({ + ...step, + ...changes, + requires: changes.requires ?? step.requires, + artifact: changes.artifact ?? step.artifact, + error: changes.error, + }); + } + return nextSteps; + }); + this._touch(); + } + + private _touch(): void { + this._lastUpdatedAt.set(new Date().toISOString()); + } +} diff --git a/src/app/core-logic/rental-fulfillment/fulfillment.types.ts b/src/app/core-logic/rental-fulfillment/fulfillment.types.ts new file mode 100644 index 0000000..4b8722c --- /dev/null +++ b/src/app/core-logic/rental-fulfillment/fulfillment.types.ts @@ -0,0 +1,109 @@ +import type { + BookingDetailsDto, + BookingStatus, + BookingVerificationStatus, + RentalDetailsDto, + RenterProfileDto, + VehicleDetailsDto, +} from '../../../contract'; + +export const FULFILLMENT_STEP_IDS = [ + 'checkin', + 'create-rental', + 'create-contract', + 'inspection', + 'sign-renter', + 'sign-staff', + 'vehicle-receive', +] as const; + +export type FulfillmentStepId = (typeof FULFILLMENT_STEP_IDS)[number]; + +export type FulfillmentStepStatus = 'pending' | 'in-progress' | 'fulfilled' | 'error'; + +export interface FulfillmentError { + readonly message: string; + readonly code?: string; + readonly detail?: unknown; +} + +export interface FulfillmentArtifact { + 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 FulfillmentStepState { + readonly step: FulfillmentStepId; + readonly status: FulfillmentStepStatus; + readonly startedAt?: string; + readonly completedAt?: string; + readonly error?: FulfillmentError; + readonly artifact?: FulfillmentArtifact; + readonly requires?: readonly FulfillmentStepId[]; +} + +export interface FulfillmentTimelineEvent { + readonly step: FulfillmentStepId; + readonly title: string; + readonly description?: string; + readonly actor: 'staff' | 'renter' | 'system'; + readonly occurredAt: string; + readonly metadata?: Readonly>; +} + +export interface BookingFulfillmentSummary { + readonly bookingId: string; + readonly status?: BookingStatus; + readonly verificationStatus?: BookingVerificationStatus; + readonly booking?: BookingDetailsDto; + readonly renterProfile?: RenterProfileDto; + readonly vehicleDetails?: VehicleDetailsDto; + readonly rental?: RentalDetailsDto; + readonly timeline: readonly FulfillmentTimelineEvent[]; +} + +export interface FulfillmentStateSnapshot { + readonly summary?: BookingFulfillmentSummary; + readonly steps: readonly FulfillmentStepState[]; + readonly nextStep?: FulfillmentStepState; + readonly completionPercentage: number; + readonly isBusy: boolean; +} + +export interface FulfillmentAnalyticsSuccessPayload { + readonly name: 'staff_booking_fulfillment_step_completed'; + readonly bookingId: string; + readonly step: FulfillmentStepId; + readonly status: 'success'; + readonly durationMs?: number; +} + +export interface FulfillmentAnalyticsFailurePayload { + readonly name: 'staff_booking_fulfillment_step_failed'; + readonly bookingId: string; + readonly step: FulfillmentStepId; + readonly status: 'error'; + readonly durationMs?: number; + readonly errorCode?: string; +} + +export interface FulfillmentAnalyticsStartedPayload { + readonly name: 'staff_booking_fulfillment_step_started'; + readonly bookingId: string; + readonly step: FulfillmentStepId; +} + +export interface FulfillmentAnalyticsRoutePayload { + readonly name: 'staff_booking_fulfillment_route_viewed'; + readonly bookingId: string; +} + +export type FulfillmentAnalyticsPayload = + | FulfillmentAnalyticsSuccessPayload + | FulfillmentAnalyticsFailurePayload + | FulfillmentAnalyticsStartedPayload + | FulfillmentAnalyticsRoutePayload; diff --git a/src/app/core-logic/rental-fulfillment/index.ts b/src/app/core-logic/rental-fulfillment/index.ts new file mode 100644 index 0000000..5aa11b4 --- /dev/null +++ b/src/app/core-logic/rental-fulfillment/index.ts @@ -0,0 +1,4 @@ +export * from './fulfillment.types'; +export * from './fulfillment.state'; +export * from './fulfillment.service'; +export * from './fulfillment.analytics'; diff --git a/src/app/core-logic/token/token.type.ts b/src/app/core-logic/token/token.type.ts index fd96b56..cd67d46 100644 --- a/src/app/core-logic/token/token.type.ts +++ b/src/app/core-logic/token/token.type.ts @@ -11,5 +11,6 @@ export interface TokenInfo { Renter?: boolean; Admin?: boolean; Staff?: boolean; + StaffId?: string; RenterId?: string; } diff --git a/src/app/features/staff/booking-fulfillment/README.md b/src/app/features/staff/booking-fulfillment/README.md new file mode 100644 index 0000000..31525d6 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/README.md @@ -0,0 +1,23 @@ +# Staff Booking Fulfillment Feature + +This feature hosts the zoneless staff workflow for converting an approved booking into an active rental. All UI elements live under this folder and consume signal-backed orchestration from `core-logic/rental-fulfillment`. + +## Planned Structure + +- `booking-fulfillment.routes.ts` — lazy route definition guarded by staff roles and preloading booking context. +- `pages/fulfillment-page/` — shell page that renders the booking summary, milestone checklist, and action panels. +- `components/inspection-form/` — reactive form for pre-handover inspection capture (battery, odometer, notes, attachments). +- `components/signature-step/` — dual signature capture for renter and staff confirmation. +- `styles/` — scoped stylesheets for UI primitives shared across the page (kept minimal; prefer Tailwind utility classes where possible). + +## Dependencies + +- Consumes `FulfillmentStateStore` and `FulfillmentOrchestrator` signals for state, actions, and optimistic updates. +- Emits analytics events via `rental-fulfillment/fulfillment.analytics` helper. +- Reuses shared layout and toolbar provided by `layout/layout.ts` and the staff dashboard navigation signals. + +## Validation Notes + +- Route must set document title and move focus to the page heading on activation. +- All interactive elements require focus outlines and AXE validation records in `specs/001-staff-booking-flow/checklists/*`. +- Analytics payloads follow the `staff_booking_fulfillment_*` namespace with step identifiers from `FulfillmentStepId`. diff --git a/src/app/features/staff/booking-fulfillment/booking-fulfillment.routes.ts b/src/app/features/staff/booking-fulfillment/booking-fulfillment.routes.ts new file mode 100644 index 0000000..6ef18c1 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/booking-fulfillment.routes.ts @@ -0,0 +1,15 @@ +import { Routes } from '@angular/router'; +import { AuthGuard } from '../../../core-logic/auth/guards/auth.guard'; + +export default [ + { + path: '', + canActivate: [AuthGuard], + canActivateChild: [AuthGuard], + data: { + roles: ['staff', 'admin'], + }, + loadComponent: () => + import('./pages/fulfillment-page/fulfillment-page').then((module) => module.FulfillmentPage), + }, +] as Routes; diff --git a/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.html b/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.html new file mode 100644 index 0000000..a3d4df1 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.html @@ -0,0 +1,67 @@ +
+
+ + +

+ Nhập dung lượng pin thực tế của xe trước khi giao. +

+ @if (showError('batteryCapacity', 'required')) { + + } @else if (showError('batteryCapacity', 'min')) { + + } +
+ +
+ + + @if (showError('inspectedAt', 'required')) { + + } +
+ +
+ + + @if (showError('evidenceUrl', 'maxlength')) { + + } +
+ +
+ +
+
diff --git a/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.scss b/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.scss new file mode 100644 index 0000000..3af1326 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.scss @@ -0,0 +1,70 @@ +.inspection-form__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.inspection-form__field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.inspection-form__label { + font-weight: 600; + color: rgba(15, 23, 42, 0.85); +} + +.inspection-form__input { + padding: 0.5rem 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 0.75rem; + font: inherit; + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.inspection-form__input:focus { + border-color: rgba(37, 99, 235, 0.9); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); + outline: none; +} + +.inspection-form__help { + margin: 0; + font-size: 0.875rem; + color: rgba(15, 23, 42, 0.55); +} + +.inspection-form__error { + margin: 0; + padding: 0.375rem 0.75rem; + border-radius: 0.75rem; + background-color: rgba(248, 113, 113, 0.12); + color: #b91c1c; + font-size: 0.875rem; + font-weight: 600; +} + +.inspection-form__actions { + display: flex; + justify-content: flex-end; +} + +.inspection-form__submit { + padding: 0.625rem 1.5rem; + border-radius: 0.75rem; + background-color: #2563eb; + color: #fff; + font-weight: 600; + transition: background-color 150ms ease; +} + +.inspection-form__submit:hover, +.inspection-form__submit:focus-visible { + background-color: #1d4ed8; +} + +.inspection-form__submit[disabled] { + background-color: rgba(37, 99, 235, 0.4); + cursor: not-allowed; +} diff --git a/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.ts b/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.ts new file mode 100644 index 0000000..37ce70b --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/components/inspection-form/inspection-form.ts @@ -0,0 +1,156 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { InspectionPayload } from '../../../../../core-logic/rental-fulfillment'; + +interface InspectionFormControls { + readonly batteryCapacity: FormControl; + readonly inspectedAt: FormControl; + readonly evidenceUrl: FormControl; +} + +@Component({ + selector: 'app-inspection-form', + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './inspection-form.html', + styleUrl: './inspection-form.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'inspection-form block', + }, +}) +export class InspectionFormComponent { + private readonly _formBuilder = inject(FormBuilder); + private readonly _submitted = signal(false); + + readonly disabled = input(false); + readonly initialValue = input(null); + + readonly submitted = output(); + + private readonly _form: FormGroup = this._formBuilder.group({ + batteryCapacity: this._formBuilder.control(null, { + validators: [Validators.required, Validators.min(0)], + }), + inspectedAt: this._formBuilder.control(null, { + validators: [Validators.required], + }), + evidenceUrl: this._formBuilder.control(null, { + validators: [Validators.maxLength(2048)], + }), + }); + + readonly form = this._form; + + constructor() { + const defaultDateTime = this._toLocalDateTime(new Date().toISOString()); + this._form.patchValue({ inspectedAt: defaultDateTime }); + + effect(() => { + const nextValue = this.initialValue(); + if (!nextValue) { + return; + } + + this._form.reset({ + batteryCapacity: nextValue.currentBatteryCapacityKwh, + inspectedAt: this._toLocalDateTime(nextValue.inspectedAt), + evidenceUrl: nextValue.evidenceUrl ?? null, + }); + this._submitted.set(false); + }); + + effect(() => { + const isDisabled = this.disabled(); + if (isDisabled) { + this._form.disable({ emitEvent: false }); + } else { + this._form.enable({ emitEvent: false }); + } + }); + } + + onSubmit(): void { + this._submitted.set(true); + + if (this._form.invalid) { + this._form.markAllAsTouched(); + return; + } + + const rawCapacity = this._form.controls.batteryCapacity.value; + const rawInspectedAt = this._form.controls.inspectedAt.value; + const rawEvidenceUrl = this._form.controls.evidenceUrl.value; + + const capacity = rawCapacity !== null ? Number(rawCapacity) : null; + const inspectedAtIso = rawInspectedAt ? this._toIsoString(rawInspectedAt) : null; + const evidenceUrl = this._normalizeString(rawEvidenceUrl); + + if (capacity === null || inspectedAtIso === null || Number.isNaN(capacity)) { + this._form.markAllAsTouched(); + return; + } + + this.submitted.emit({ + currentBatteryCapacityKwh: capacity, + inspectedAt: inspectedAtIso, + evidenceUrl, + }); + } + + showError(control: keyof InspectionFormControls, error: string): boolean { + const field = this._form.controls[control]; + if (!field) { + return false; + } + + return field.hasError(error) && (field.touched || field.dirty || this._submitted()); + } + + private _toIsoString(value: string): string { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return new Date().toISOString(); + } + return new Date(parsed).toISOString(); + } + + private _toLocalDateTime(value: string): string { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return value; + } + + const date = new Date(parsed); + 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 _normalizeString(value: string | null): string | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } +} diff --git a/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.html b/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.html new file mode 100644 index 0000000..c13f0e6 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.html @@ -0,0 +1,70 @@ +
+
+ + + @if (showError('signedAt', 'required')) { + + } +
+ +
+ + + @if (showError('signatureType', 'required')) { + + } +
+ +
+ + + @if (showError('documentUrl', 'maxlength')) { + + } +
+ +
+ + + @if (showError('documentHash', 'maxlength')) { + + } +
+ +
+ +
+
diff --git a/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.scss b/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.scss new file mode 100644 index 0000000..fb19b9d --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.scss @@ -0,0 +1,70 @@ +.signature-step__form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.signature-step__field { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.signature-step__label { + font-weight: 600; + color: rgba(15, 23, 42, 0.85); +} + +.signature-step__input, +.signature-step__select { + padding: 0.5rem 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 0.75rem; + font: inherit; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.signature-step__input:focus, +.signature-step__select:focus { + border-color: rgba(37, 99, 235, 0.9); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); + outline: none; +} + +.signature-step__select { + min-width: 220px; +} + +.signature-step__error { + margin: 0; + padding: 0.375rem 0.75rem; + border-radius: 0.75rem; + background-color: rgba(248, 113, 113, 0.12); + color: #b91c1c; + font-size: 0.875rem; + font-weight: 600; +} + +.signature-step__actions { + display: flex; + justify-content: flex-end; +} + +.signature-step__submit { + padding: 0.625rem 1.5rem; + border-radius: 0.75rem; + background-color: #2563eb; + color: #fff; + font-weight: 600; + transition: background-color 150ms ease; +} + +.signature-step__submit:hover, +.signature-step__submit:focus-visible { + background-color: #1d4ed8; +} + +.signature-step__submit[disabled] { + background-color: rgba(37, 99, 235, 0.4); + cursor: not-allowed; +} diff --git a/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.ts b/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.ts new file mode 100644 index 0000000..b0d60d1 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/components/signature-step/signature-step.ts @@ -0,0 +1,177 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { SignaturePayload } from '../../../../../core-logic/rental-fulfillment'; +import { SignatureType as SignatureTypeEnum, type SignatureType } from '../../../../../../contract'; + +interface SignatureFormControls { + readonly signedAt: FormControl; + readonly signatureType: FormControl; + readonly documentUrl: FormControl; + readonly documentHash: FormControl; +} + +const SIGNATURE_TYPE_OPTIONS: readonly { readonly value: SignatureType; readonly label: string }[] = + [ + { value: SignatureTypeEnum.Drawn, label: 'Ký bằng tay' }, + { value: SignatureTypeEnum.Typed, label: 'Gõ chữ ký' }, + { value: SignatureTypeEnum.DigitalCert, label: 'Chữ ký số' }, + { value: SignatureTypeEnum.OnPaper, label: 'Tải bản giấy' }, + ]; + +@Component({ + selector: 'app-signature-step', + imports: [CommonModule, ReactiveFormsModule], + templateUrl: './signature-step.html', + styleUrl: './signature-step.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'signature-step block', + }, +}) +export class SignatureStepComponent { + private readonly _formBuilder = inject(FormBuilder); + private readonly _submitted = signal(false); + + readonly role = input.required<'renter' | 'staff'>(); + readonly disabled = input(false); + readonly initialValue = input(null); + + readonly submitted = output(); + readonly signatureTypes = SIGNATURE_TYPE_OPTIONS; + + private readonly _form: FormGroup = this._formBuilder.group({ + signedAt: this._formBuilder.control(null, { + validators: [Validators.required], + }), + signatureType: this._formBuilder.control(SignatureTypeEnum.Typed, { + validators: [Validators.required], + }), + documentUrl: this._formBuilder.control(null, { + validators: [Validators.maxLength(2048)], + }), + documentHash: this._formBuilder.control(null, { + validators: [Validators.maxLength(512)], + }), + }); + + readonly form = this._form; + + constructor() { + const defaultSignedAt = this._toLocalDateTime(new Date().toISOString()); + this._form.patchValue({ signedAt: defaultSignedAt }); + + effect(() => { + const nextValue = this.initialValue(); + if (!nextValue) { + return; + } + + this._form.reset({ + signedAt: this._toLocalDateTime(nextValue.signedAt), + signatureType: nextValue.signatureType ?? SignatureTypeEnum.Typed, + documentUrl: nextValue.documentUrl ?? null, + documentHash: nextValue.documentHash ?? null, + }); + this._submitted.set(false); + }); + + effect(() => { + const isDisabled = this.disabled(); + if (isDisabled) { + this._form.disable({ emitEvent: false }); + } else { + this._form.enable({ emitEvent: false }); + } + }); + } + + onSubmit(): void { + this._submitted.set(true); + + if (this._form.invalid) { + this._form.markAllAsTouched(); + return; + } + + const signedAtRaw = this._form.controls.signedAt.value; + const signatureType = this._form.controls.signatureType.value ?? undefined; + const documentUrl = this._normalizeString(this._form.controls.documentUrl.value); + const documentHash = this._normalizeString(this._form.controls.documentHash.value); + const signedAtIso = this._toIsoString(signedAtRaw); + + if (!signedAtIso) { + this._form.controls.signedAt.setErrors({ required: true }); + return; + } + + this.submitted.emit({ + role: this.role(), + signedAt: signedAtIso, + signatureType, + documentUrl, + documentHash, + }); + } + + showError(control: keyof SignatureFormControls, error: string): boolean { + const field = this._form.controls[control]; + if (!field) { + return false; + } + + return field.hasError(error) && (field.touched || field.dirty || this._submitted()); + } + + private _toIsoString(value: string | null): string | null { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return null; + } + + return new Date(timestamp).toISOString(); + } + + private _toLocalDateTime(value: string): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return value; + } + + 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 _normalizeString(value: string | null): string | null { + if (!value) { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } +} diff --git a/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.html b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.html new file mode 100644 index 0000000..270860d --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.html @@ -0,0 +1,312 @@ +
+
+ + + Quay lại danh sách booking + +

Quy trình xử lý đặt xe

+

Xử lý đặt xe

+

+ Hoàn tất tuần tự các bước bên dưới để chuyển đổi đặt xe thành đơn thuê hoạt động. +

+
+ + @if (initializationErrorMessage(); as errorMessage) { + + } @else { +
+
+
+

Thông tin booking

+ @if (summaryView(); as summary) { + {{ summary.bookingId }} + } +
+ + @if (summaryView(); as summary) { +
+
+
Trạng thái booking
+
{{ summary.bookingStatusLabel }}
+
+
+
Trạng thái xác thực
+
{{ summary.verificationStatusLabel }}
+
+
+
Khởi tạo
+
{{ summary.createdAtDisplay }}
+
+
+
Thời gian thuê
+
{{ summary.rentalWindowDisplay }}
+
+
+
Khách thuê
+
{{ summary.renterName }}
+
+
+
Giấy phép lái xe
+
{{ summary.renterLicense }}
+
+
+
Địa chỉ
+
{{ summary.renterAddress }}
+
+
+
Xe
+
{{ summary.vehicleLabel }}
+
+
+
Tiền cọc
+
{{ summary.depositDisplay }}
+
+ @if (summary.rentalId) { +
+
Mã đơn thuê
+
{{ summary.rentalId }}
+
+ } + @if (summary.contractId) { +
+
Mã hợp đồng
+
{{ summary.contractId }}
+
+ } + @if (summary.inspectionId) { +
+
Biên bản kiểm tra
+
{{ summary.inspectionId }}
+
+ } + @if (summary.renterSignatureId) { +
+
Chữ ký khách
+
{{ summary.renterSignatureId }}
+
+ } + @if (summary.staffSignatureId) { +
+
Chữ ký nhân viên
+
{{ summary.staffSignatureId }}
+
+ } + @if (summary.vehicleReceipt?.receivedAtDisplay) { +
+
Thời điểm bàn giao
+
{{ summary.vehicleReceipt?.receivedAtDisplay }}
+
+ } + @if (summary.vehicleReceipt?.receivedByStaffId) { +
+
Nhân viên bàn giao
+
{{ summary.vehicleReceipt?.receivedByStaffId }}
+
+ } +
+ } @else { +
+
+

Đang tải thông tin booking…

+
+ } + +
+ +
+
+ +
+
+
+

Checklist xử lý

+

Hoàn thành lần lượt từng bước để tiếp tục tiến trình.

+
+ {{ completionPercentage() }}% +
+ +
    + @for (step of stepViewModels(); track trackByStep($index, step)) { +
  1. + +
    +
    +

    {{ step.title }}

    +

    {{ step.description }}

    +
    + {{ statusLabel(step.status) }} + @if (step.completedAtDisplay && step.status === 'fulfilled') { + Hoàn tất: {{ step.completedAtDisplay }} + } + @if (step.errorMessage) { + + } + + @if (step.artifactEntries.length > 0) { +
    + @for (artifact of step.artifactEntries; track artifact.label) { +
    +
    {{ artifact.label }}
    +
    {{ artifact.value }}
    +
    + } +
    + } + + @if (step.status !== 'fulfilled') { + @if (step.action.kind === 'button' && step.canPerform) { + + } + + @if (step.action.kind === 'contract' && step.canPerform) { +
    + + + +
    + } + + @if (step.action.kind === 'inspection' && step.canPerform) { + + } + + @if (step.action.kind === 'signature' && step.canPerform) { + + } + + @if (step.action.kind === 'vehicle' && step.canPerform) { +
    + + + @if (vehicleReceiveError('required')) { + + } +
    + +
    +
    + } + } +
    +
  2. + } +
+ + @if (hasFailure()) { +
+ +

Hãy xử lý lỗi ở các bước thất bại trước khi tiếp tục.

+
+ } +
+ +
+
+

Nhật ký thực hiện

+

Theo dõi lịch sử hành động phục vụ kiểm toán.

+
+ + @if (timelineView().length === 0) { +
+ +

Chưa có sự kiện nào được ghi nhận.

+
+ } @else { +
    + @for (event of timelineView(); track trackByTimeline($index, event)) { +
  1. + +
    +

    {{ event.title }}

    + @if (event.description) { +

    {{ event.description }}

    + } +

    + {{ event.actorLabel }} + + {{ event.occurredAtDisplay }} +

    + @if (event.metadataEntries.length > 0) { + + } +
    +
  2. + } +
+ } +
+
+ } +
diff --git a/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.scss b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.scss new file mode 100644 index 0000000..ae2b328 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.scss @@ -0,0 +1,495 @@ +.fulfillment { + display: flex; + flex-direction: column; + gap: 2.5rem; +} + +.fulfillment__header { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.fulfillment__back { + display: inline-flex; + align-items: center; + gap: 0.5rem; + width: fit-content; + padding: 0.375rem 0.75rem; + border-radius: 9999px; + background-color: rgba(33, 150, 243, 0.12); + color: #1565c0; + font-weight: 600; + transition: background-color 150ms ease; +} + +.fulfillment__back:hover, +.fulfillment__back:focus-visible { + background-color: rgba(33, 150, 243, 0.2); +} + +.fulfillment__eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.75rem; + color: rgba(15, 23, 42, 0.72); + font-weight: 600; +} + +.fulfillment__description { + max-width: 48rem; + color: rgba(15, 23, 42, 0.72); +} + +.fulfillment__error { + display: flex; + gap: 1rem; + align-items: flex-start; + padding: 1.5rem; + border-radius: 1rem; + background-color: rgba(220, 38, 38, 0.08); + color: #991b1b; +} + +.fulfillment__error-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.fulfillment__error button { + align-self: flex-start; + padding: 0.5rem 1rem; + border-radius: 0.75rem; + background-color: #991b1b; + color: #fff; + font-weight: 600; +} + +.fulfillment__layout { + display: grid; + gap: 2rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.summary, +.steps, +.timeline { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.75rem; + border-radius: 1.25rem; + background-color: #fff; + box-shadow: 0 20px 40px -24px rgba(15, 23, 42, 0.32); +} + +.summary__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.summary__badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 999px; + background-color: rgba(59, 130, 246, 0.12); + color: #1d4ed8; + font-weight: 600; +} + +.summary__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.summary__grid dt { + font-size: 0.875rem; + color: rgba(15, 23, 42, 0.6); +} + +.summary__grid dd { + margin: 0.25rem 0 0; + font-weight: 600; + color: rgba(15, 23, 42, 0.92); +} + +.summary__loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2rem 1rem; + border-radius: 1rem; + background-color: rgba(15, 23, 42, 0.04); +} + +.summary__spinner { + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + border: 3px solid rgba(15, 23, 42, 0.16); + border-top-color: #2563eb; + animation: summary-spinner 1s linear infinite; +} + +@keyframes summary-spinner { + to { + transform: rotate(360deg); + } +} + +.summary__footer { + display: flex; + justify-content: flex-end; +} + +.summary__refresh { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.75rem; + background-color: rgba(59, 130, 246, 0.12); + color: #1d4ed8; + font-weight: 600; + transition: background-color 150ms ease; +} + +.summary__refresh:hover, +.summary__refresh:focus-visible { + background-color: rgba(59, 130, 246, 0.2); +} + +.summary__refresh[disabled] { + opacity: 0.6; + cursor: not-allowed; +} + +.steps__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.steps__progress { + padding: 0.25rem 0.75rem; + border-radius: 999px; + background-color: rgba(34, 197, 94, 0.12); + color: #15803d; + font-weight: 700; +} + +.steps__list { + display: flex; + flex-direction: column; + gap: 1rem; + list-style: none; + margin: 0; + padding: 0; +} + +.steps__item { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; + padding: 1.25rem; + border-radius: 1rem; + border: 1px solid rgba(15, 23, 42, 0.08); +} + +.steps__item--current { + border-color: rgba(37, 99, 235, 0.4); + box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.5); +} + +.steps__status { + width: 0.75rem; + border-radius: 999px; +} + +.steps__status--pending { + background-image: linear-gradient(90deg, rgba(148, 163, 184, 0.2), rgba(148, 163, 184, 0.4)); +} + +.steps__status--in-progress { + background-image: linear-gradient(90deg, rgba(59, 130, 246, 0.4), rgba(59, 130, 246, 0.7)); +} + +.steps__status--fulfilled { + background-image: linear-gradient(90deg, rgba(34, 197, 94, 0.4), rgba(34, 197, 94, 0.7)); +} + +.steps__status--error { + background-image: linear-gradient(90deg, rgba(248, 113, 113, 0.4), rgba(248, 113, 113, 0.7)); +} + +.steps__content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.steps__artifacts { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + margin: 0; +} + +.steps__artifacts dt { + font-weight: 600; + font-size: 0.875rem; + color: rgba(15, 23, 42, 0.65); +} + +.steps__artifacts dd { + margin: 0.25rem 0 0; + font-size: 0.875rem; + color: rgba(15, 23, 42, 0.95); +} + +.steps__titles h3 { + margin: 0; + font-size: 1.1rem; +} + +.steps__titles p { + margin: 0.25rem 0 0; + color: rgba(15, 23, 42, 0.65); +} + +.steps__state { + align-self: flex-start; + padding: 0.25rem 0.75rem; + border-radius: 999px; + background-color: rgba(100, 116, 139, 0.12); + color: rgba(30, 41, 59, 0.8); + font-weight: 600; + font-size: 0.875rem; +} + +.steps__timestamp { + font-size: 0.875rem; + color: rgba(30, 41, 59, 0.65); +} + +.steps__error { + margin: 0; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + background-color: rgba(248, 113, 113, 0.12); + color: #b91c1c; + font-weight: 600; +} + +.steps__cta { + align-self: flex-start; + padding: 0.625rem 1.25rem; + border-radius: 0.75rem; + background-color: #2563eb; + color: #fff; + font-weight: 600; + transition: background-color 150ms ease; +} + +.steps__cta:hover, +.steps__cta:focus-visible { + background-color: #1d4ed8; +} + +.steps__cta[disabled] { + background-color: rgba(37, 99, 235, 0.4); + cursor: not-allowed; +} + +.steps__form { + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: flex-start; +} + +.steps__label { + font-size: 0.875rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.8); +} + +.steps__select { + min-width: 220px; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 0.75rem; + background-color: #fff; + font: inherit; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.steps__select:focus { + border-color: rgba(37, 99, 235, 0.9); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); + outline: none; +} + +.steps__select[disabled] { + background-color: rgba(148, 163, 184, 0.12); + cursor: not-allowed; +} + +.vehicle-receive { + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: flex-start; +} + +.vehicle-receive__label { + font-weight: 600; + color: rgba(15, 23, 42, 0.85); +} + +.vehicle-receive__input { + padding: 0.5rem 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.5); + border-radius: 0.75rem; + font: inherit; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.vehicle-receive__input:focus { + border-color: rgba(37, 99, 235, 0.9); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); + outline: none; +} + +.vehicle-receive__error { + margin: 0; + padding: 0.375rem 0.75rem; + border-radius: 0.75rem; + background-color: rgba(248, 113, 113, 0.12); + color: #b91c1c; + font-size: 0.875rem; + font-weight: 600; +} + +.vehicle-receive__actions { + display: flex; + justify-content: flex-end; + width: 100%; +} + +.vehicle-receive__submit { + padding: 0.625rem 1.5rem; + border-radius: 0.75rem; + background-color: #2563eb; + color: #fff; + font-weight: 600; + transition: background-color 150ms ease; +} + +.vehicle-receive__submit:hover, +.vehicle-receive__submit:focus-visible { + background-color: #1d4ed8; +} + +.vehicle-receive__submit[disabled] { + background-color: rgba(37, 99, 235, 0.4); + cursor: not-allowed; +} + +.steps__hint { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 0.75rem; + background-color: rgba(251, 191, 36, 0.16); + color: #92400e; +} + +.timeline__list { + display: flex; + flex-direction: column; + gap: 1rem; + list-style: none; + margin: 0; + padding: 0; +} + +.timeline__item { + display: grid; + grid-template-columns: auto 1fr; + gap: 1rem; +} + +.timeline__marker { + width: 0.5rem; + background-image: linear-gradient(180deg, rgba(59, 130, 246, 0.4), rgba(37, 99, 235, 0.8)); + border-radius: 999px; +} + +.timeline__body { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.timeline__body:last-child { + border-bottom: none; +} + +.timeline__meta { + display: inline-flex; + gap: 0.5rem; + align-items: center; + font-size: 0.875rem; + color: rgba(30, 41, 59, 0.65); +} + +.timeline__metadata { + display: grid; + gap: 0.5rem; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + font-size: 0.875rem; +} + +.timeline__metadata dt { + font-weight: 600; + color: rgba(30, 41, 59, 0.7); +} + +.timeline__metadata dd { + margin: 0.25rem 0 0; + color: rgba(30, 41, 59, 0.9); +} + +.timeline__empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 2rem 1rem; + border-radius: 1rem; + background-color: rgba(15, 23, 42, 0.04); + color: rgba(30, 41, 59, 0.7); +} + +@media (max-width: 900px) { + .fulfillment__layout { + grid-template-columns: 1fr; + } + + .steps__item { + grid-template-columns: 0.5rem 1fr; + } + + .timeline__item { + grid-template-columns: 0.5rem 1fr; + } +} 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 new file mode 100644 index 0000000..2d27776 --- /dev/null +++ b/src/app/features/staff/booking-fulfillment/pages/fulfillment-page/fulfillment-page.ts @@ -0,0 +1,926 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EnvironmentInjector, + computed, + effect, + inject, + runInInjectionContext, + signal, + viewChild, + afterNextRender, +} from '@angular/core'; +import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { Title } from '@angular/platform-browser'; +import { map, take } from 'rxjs'; +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 { 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' }, +]; + +@Component({ + selector: 'app-fulfillment-page', + imports: [ + CommonModule, + MatIconModule, + RouterLink, + ReactiveFormsModule, + InspectionFormComponent, + SignatureStepComponent, + ], + templateUrl: './fulfillment-page.html', + styleUrl: './fulfillment-page.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'block min-h-[calc(100vh-120px)] bg-surface p-6 sm:p-10', + }, +}) +export class FulfillmentPage { + private readonly route = inject(ActivatedRoute); + private readonly orchestrator = inject(FulfillmentOrchestrator); + private readonly title = inject(Title); + private readonly analytics = inject(FulfillmentAnalyticsService); + private readonly formBuilder = inject(NonNullableFormBuilder); + private readonly cdr = inject(ChangeDetectorRef); + private readonly environmentInjector = inject(EnvironmentInjector); + private lastInitializedBookingId: string | null = null; + + private readonly headingRef = viewChild>('pageHeading'); + private readonly initializationError = signal(null); + private readonly routeEntered = signal(false); + + private readonly _contractProvider = signal< + (typeof EsignProviderEnum)[keyof typeof EsignProviderEnum] + >(EsignProviderEnum.Native); + readonly contractProviders = CONTRACT_PROVIDERS; + readonly selectedContractProvider = this._contractProvider.asReadonly(); + + private readonly vehicleReceiveSubmitAttempted = signal(false); + readonly vehicleReceiveForm = this.formBuilder.group({ + receivedAt: this.formBuilder.control(this._defaultDateTimeInput(), { + validators: [Validators.required], + }), + }); + + readonly summary = this.orchestrator.summary; + readonly steps = this.orchestrator.steps; + readonly nextStep = this.orchestrator.nextStep; + readonly completionPercentage = this.orchestrator.completionPercentage; + readonly isBusy = this.orchestrator.isBusy; + + private readonly bookingIdSignal = toSignal( + this.route.paramMap.pipe(map((params) => params.get('bookingId') ?? '')), + { + 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); + }); + + 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)); + }); + + readonly timelineView = computed(() => { + const summary = this.summary(); + if (!summary?.timeline) { + return []; + } + + return summary.timeline.map((event, index) => this._toTimelineViewModel(event, index)); + }); + + readonly hasFailure = computed(() => + this.stepViewModels().some((step) => step.status === 'error' && !!step.errorMessage), + ); + + readonly initializationErrorMessage = computed(() => this.initializationError()); + + constructor() { + effect(() => { + const rawBookingId = this.bookingIdSignal(); + const bookingId = rawBookingId.trim(); + if (!bookingId) { + this.lastInitializedBookingId = null; + return; + } + + if (bookingId === this.lastInitializedBookingId) { + return; + } + + this.lastInitializedBookingId = bookingId; + queueMicrotask(() => this._loadFulfillment(bookingId)); + }); + + effect(() => { + const shouldDisable = this.isBusy() || !this._canPerformStep('vehicle-receive'); + if (shouldDisable) { + this.vehicleReceiveForm.disable({ emitEvent: false }); + } else { + this.vehicleReceiveForm.enable({ emitEvent: false }); + } + }); + + effect(() => { + const vehicleStep = this.stepViewModels().find((step) => step.step === 'vehicle-receive'); + if (vehicleStep?.status !== 'fulfilled' || !this.vehicleReceiveSubmitAttempted()) { + return; + } + + queueMicrotask(() => this.vehicleReceiveSubmitAttempted.set(false)); + }); + + effect(() => { + if (!this.routeEntered()) { + return; + } + + queueMicrotask(() => this._focusHeading()); + }); + + effect(() => { + this.orchestrator.snapshot(); + this.initializationError(); + this.routeEntered(); + queueMicrotask(() => this.cdr.detectChanges()); + }); + } + + retry(): void { + const bookingId = this.bookingIdSignal(); + if (!bookingId) { + return; + } + + this._loadFulfillment(bookingId); + } + + refresh(): void { + this.orchestrator.refresh().pipe(take(1)).subscribe(); + } + + onCheckIn(): void { + if (this.isBusy() || !this._canPerformStep('checkin')) { + return; + } + + const bookingId = this._bookingId(); + if (bookingId) { + this.analytics.stepStarted(bookingId, 'checkin'); + } + + this.orchestrator.checkInBooking().pipe(take(1)).subscribe(); + } + + onCreateRental(): void { + if (this.isBusy() || !this._canPerformStep('create-rental')) { + return; + } + + const bookingId = this._bookingId(); + if (bookingId) { + this.analytics.stepStarted(bookingId, 'create-rental'); + } + + this.orchestrator.createRental().pipe(take(1)).subscribe(); + } + + onContractProviderChange(event: Event): void { + const select = event.target instanceof HTMLSelectElement ? event.target : null; + if (!select) { + 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); + } + + onCreateContract(): void { + if (this.isBusy() || !this._canPerformStep('create-contract')) { + return; + } + + const bookingId = this._bookingId(); + if (bookingId) { + this.analytics.stepStarted(bookingId, 'create-contract'); + } + + this.orchestrator.createContract(this._contractProvider()).pipe(take(1)).subscribe(); + } + + onSubmitInspection(payload: InspectionPayload): void { + if (this.isBusy() || !this._canPerformStep('inspection')) { + return; + } + + const bookingId = this._bookingId(); + if (bookingId) { + this.analytics.stepStarted(bookingId, 'inspection'); + } + + this.orchestrator.submitInspection(payload).pipe(take(1)).subscribe(); + } + + onStepButton(stepId: FulfillmentStepId): void { + switch (stepId) { + case 'checkin': + this.onCheckIn(); + return; + case 'create-rental': + this.onCreateRental(); + return; + default: + return; + } + } + + onSubmitSignature(payload: SignaturePayload): void { + const stepId: FulfillmentStepId = payload.role === 'renter' ? 'sign-renter' : 'sign-staff'; + if (this.isBusy() || !this._canPerformStep(stepId)) { + return; + } + + const bookingId = this._bookingId(); + if (bookingId) { + this.analytics.stepStarted(bookingId, stepId); + } + + this.orchestrator.signContract(payload).pipe(take(1)).subscribe(); + } + + onSubmitVehicleReceive(): void { + this.vehicleReceiveSubmitAttempted.set(true); + + if (this.isBusy() || !this._canPerformStep('vehicle-receive')) { + return; + } + + if (this.vehicleReceiveForm.invalid) { + this.vehicleReceiveForm.markAllAsTouched(); + return; + } + + const receivedAtInput = this.vehicleReceiveForm.controls.receivedAt.value; + const receivedAtIso = this._toIsoString(receivedAtInput); + if (!receivedAtIso) { + this.vehicleReceiveForm.controls.receivedAt.setErrors({ required: true }); + return; + } + + const bookingId = this._bookingId(); + if (bookingId) { + this.analytics.stepStarted(bookingId, 'vehicle-receive'); + } + + const payload: VehicleReceivePayload = { + receivedAt: receivedAtIso, + }; + + this.orchestrator + .confirmVehicleReceive(payload) + .pipe(take(1)) + .subscribe({ + next: () => { + this.vehicleReceiveSubmitAttempted.set(false); + this.vehicleReceiveForm.reset( + { receivedAt: this._defaultDateTimeInput() }, + { emitEvent: false }, + ); + }, + }); + } + + vehicleReceiveError(error: string): boolean { + const control = this.vehicleReceiveForm.controls.receivedAt; + return control.hasError(error) && (control.touched || this.vehicleReceiveSubmitAttempted()); + } + + trackByStep(_index: number, step: StepViewModel): string { + return step.step; + } + + trackByTimeline(_index: number, event: TimelineViewModel): string { + return event.key; + } + + 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'; + } + } + + private _loadFulfillment(rawBookingId: string): void { + const bookingId = rawBookingId.trim(); + if (!bookingId) { + this.initializationError.set('Booking ID không hợp lệ.'); + return; + } + + this.initializationError.set(null); + this.routeEntered.set(false); + + this.orchestrator + .initialize(bookingId) + .pipe(take(1)) + .subscribe({ + next: () => { + this._setPageTitle(bookingId); + this._emitRouteEntry(bookingId); + }, + error: (error) => { + const message = error instanceof Error && error.message ? error.message : null; + this.initializationError.set( + message ?? 'Không thể tải quy trình xử lý đặt xe. Vui lòng thử lại.', + ); + }, + }); + } + + 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)}`); + } + + private _focusHeading(): void { + runInInjectionContext(this.environmentInjector, () => { + afterNextRender(() => { + const heading = this.headingRef(); + heading?.nativeElement.focus(); + }); + }); + } + + private _emitRouteEntry(bookingId: string): void { + if (this.routeEntered()) { + return; + } + + this.analytics.routeEntered(bookingId); + this.routeEntered.set(true); + } + + private _shortIdentifier(value: string): string { + if (value.length <= 10) { + return value; + } + + return `${value.slice(0, 4)}…${value.slice(-4)}`; + } + + private _bookingId(): string { + return this.bookingIdSignal().trim(); + } +} diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.html b/src/app/features/staff/staff-dashboard/staff-dashboard.html index 973cd25..6b99514 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.html +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.html @@ -96,7 +96,7 @@

Booking Management

} @else { @if (cardViewModels().length > 0) {
- @for (card of cardViewModels(); track card.record.bookingId) { + @for (card of cardViewModels(); track card.key) {
@@ -306,6 +306,17 @@

Customer Profile

+ +
+ +
} diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.scss b/src/app/features/staff/staff-dashboard/staff-dashboard.scss index 00e5ea5..0984c67 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.scss +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.scss @@ -580,6 +580,35 @@ line-height: 1.45; } +.details-panel__actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.details-panel__action { + padding: 0.6rem 1.4rem; + border-radius: 999px; + border: 0; + background: var(--mat-sys-primary, #2563eb); + color: var(--mat-sys-on-primary, #fff); + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, transform 0.2s ease; +} + +.details-panel__action:hover, +.details-panel__action:focus-visible { + background: color-mix(in srgb, var(--mat-sys-primary, #2563eb) 90%, #fff); + transform: translateY(-1px); +} + +.details-panel__action[disabled] { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + @keyframes spin { to { transform: rotate(360deg); diff --git a/src/app/features/staff/staff-dashboard/staff-dashboard.ts b/src/app/features/staff/staff-dashboard/staff-dashboard.ts index a31d09d..5548727 100644 --- a/src/app/features/staff/staff-dashboard/staff-dashboard.ts +++ b/src/app/features/staff/staff-dashboard/staff-dashboard.ts @@ -1,11 +1,15 @@ import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, ElementRef, + EnvironmentInjector, ViewChild, afterNextRender, computed, + effect, inject, + runInInjectionContext, signal, } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; @@ -17,6 +21,7 @@ import { RentalStatus as RentalStatusEnum, } from '../../../../contract'; import type { BookingStatus, BookingVerificationStatus, RentalStatus } from '../../../../contract'; +import { Router } from '@angular/router'; type BookingTabKey = 'all' | 'pendingVerification' | 'verified' | 'cancelled'; @@ -42,6 +47,7 @@ interface StatusBadge { } interface BookingCardViewModel { + readonly key: string; readonly record: StaffBookingRecord; readonly customerDisplay: string; readonly bookingCreatedDisplay: string; @@ -147,6 +153,9 @@ const RENTAL_LINKED_BADGE: StatusBadge = { }) export class StaffDashboard { private readonly bookingsService = inject(BookingsService); + private readonly router = inject(Router); + private readonly cdr = inject(ChangeDetectorRef); + private readonly environmentInjector = inject(EnvironmentInjector); @ViewChild('detailPanel') private detailPanel?: ElementRef; private activeDetailTrigger: HTMLElement | null = null; @@ -156,6 +165,18 @@ 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()); @@ -205,6 +226,7 @@ export class StaffDashboard { readonly cardViewModels = computed(() => this.filteredRecords().map((record) => ({ + key: this._buildCardKey(record), record, customerDisplay: this._resolveCustomerLabel(record), bookingCreatedDisplay: this.formatDate(record.bookingCreatedAt), @@ -260,6 +282,13 @@ export class StaffDashboard { }); constructor() { + effect(() => { + this.bookingsService.staffBookings(); + this.bookingsService.staffBookingsLoading(); + this.bookingsService.staffBookingsError(); + queueMicrotask(() => this.cdr.detectChanges()); + }); + this.refresh(); } @@ -301,9 +330,6 @@ export class StaffDashboard { this.activeDetailTrigger = triggerEvent?.currentTarget instanceof HTMLElement ? triggerEvent.currentTarget : null; this.selectedBooking.set(record); - afterNextRender(() => { - this.detailPanel?.nativeElement.focus(); - }); } closeDetails(): void { @@ -317,6 +343,25 @@ export class StaffDashboard { } } + openFulfillment(record: StaffBookingRecord): void { + if (!record.bookingId) { + return; + } + + this.closeDetails(); + void this.router.navigate(['/staff/bookings', record.bookingId, 'fulfillment']); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + canOpenFulfillment(record: StaffBookingRecord): boolean { + return true; + // return ( + // record.verificationStatus === BookingVerificationStatusEnum.Approved || + // record.status === BookingStatusEnum.Verified || + // record.status === BookingStatusEnum.RentalCreated + // ); + } + onOverlayClick(event: MouseEvent): void { if (event.target === event.currentTarget) { this.closeDetails(); @@ -560,4 +605,13 @@ export class StaffDashboard { } 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.routes.ts b/src/app/features/staff/staff.routes.ts index d6ab9a7..e8764b1 100644 --- a/src/app/features/staff/staff.routes.ts +++ b/src/app/features/staff/staff.routes.ts @@ -10,6 +10,10 @@ export default [ pathMatch: 'full', redirectTo: 'bookings', }, + { + path: 'bookings/:bookingId/fulfillment', + loadChildren: () => import('./booking-fulfillment/booking-fulfillment.routes'), + }, { path: 'bookings', component: StaffDashboard, diff --git a/src/app/layout/layout.html b/src/app/layout/layout.html index 7244bc6..2d128a0 100644 --- a/src/app/layout/layout.html +++ b/src/app/layout/layout.html @@ -18,7 +18,7 @@ @if (isAuthenticated()) { - @for (item of navigation(); track item) { + @for (item of navigation(); track item.href) { - @for (item of userNavigation(); track item) { + @for (item of userNavigation(); track item.href) { } } @else { - @for (item of guestNavigation(); track item) { + @for (item of guestNavigation(); track item.href) {