diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8413bf71..7de5c5239 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ permissions: jobs: detect-changes: name: Detect changed services - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: @@ -52,7 +52,7 @@ jobs: NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" - GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" + GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then @@ -91,7 +91,7 @@ jobs: name: Deploy ${{ matrix.service }} needs: detect-changes if: ${{ needs.detect-changes.outputs.matrix != '[]' }} - runs-on: self-hosted + runs-on: ${{ fromJson(format('["self-hosted", "{0}"]', github.ref_name)) }} strategy: fail-fast: false matrix: diff --git a/.gitignore b/.gitignore index 0e3f0986f..ffdc4b78b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage/ .idea/ .vscode/ .npmrc +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 77973d357..9c1e290be 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -285,6 +285,7 @@ export class CompaniesService { async findCompanyById(id: string): Promise { const company = await this.companiesRepo.findById(id); if (!company) throw new NotFoundException(`Company ${id} not found`); + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id); return company; } diff --git a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts index bc0394eb6..e2535083e 100644 --- a/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/first-mile/dto/create-first-mile.dto.ts @@ -55,6 +55,7 @@ export class CreateFirstMileDto { nullable: true, }) @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts index 315e70447..161604e81 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.controller.ts @@ -55,6 +55,13 @@ export class FirstMileController { return this.firstMileService.findById(id); } + @Post('accept/:reference') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' }) + acceptBooking(@Param('reference') reference: string) { + return this.firstMileService.acceptBooking(reference); + } + @Post() @TrainSchedulingManage() @ApiOperation({ summary: 'Create a first-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts index b9b59845c..713efa52d 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; import { FirstMile } from './entities/first-mile.entity'; import { FirstMileController } from './first-mile.controller'; import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([FirstMile])], + imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule], controllers: [FirstMileController], providers: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService], diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index a1ee4d364..a4ead8c2a 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateFirstMileDto } from './dto/create-first-mile.dto'; import { UpdateFirstMileDto } from './dto/update-first-mile.dto'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; @@ -25,7 +26,32 @@ const SORTABLE_FIELDS: (keyof FirstMile)[] = [ @Injectable() export class FirstMileService { - constructor(private readonly firstMileRepository: FirstMileRepository) {} + constructor( + private readonly firstMileRepository: FirstMileRepository, + private readonly bookingsRepository: BookingsRepository, + ) {} + + /** + * Look up a booking by its human-readable reference and confirm it has been + * paid before any first-mile work proceeds. Throws if the reference is + * unknown or the booking has not reached PAID status. + */ + async acceptBooking(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + return null; + } + + if (booking.paymentStatus !== 'PAID') { + return null; + } + + return this.create({ + bookingId: booking.id, + advancedPayment: 0, + }); + } async findAll(filter: FirstMileListFilter = {}): Promise<{ data: FirstMile[]; @@ -45,7 +71,10 @@ export class FirstMileService { const [data, total] = await this.firstMileRepository.findAndCount({ where, - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, @@ -64,7 +93,10 @@ export class FirstMileService { async findById(id: string): Promise { const record = await this.firstMileRepository.findById(id, { - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, }); if (!record) { diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts index b47eb0479..4f6f5fc8f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/create-last-mile.dto.ts @@ -55,6 +55,7 @@ export class CreateLastMileDto { nullable: true, }) @IsOptional() + @Transform(({ value }) => (value === '' ? undefined : value)) @IsUUID() vehicleId?: string | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index e6cc1e7ee..ea1e29a3d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -55,6 +55,13 @@ export class LastMileController { return this.lastMileService.findById(id); } + @Post('accept/:reference') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) + acceptBooking(@Param('reference') reference: string) { + return this.lastMileService.acceptBooking(reference); + } + @Post() @TrainSchedulingManage() @ApiOperation({ summary: 'Create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index a3662debe..fa654f6ec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileRepository } from './last-mile.repository'; import { LastMileService } from './last-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([LastMile])], + imports: [TypeOrmModule.forFeature([LastMile]), BookingsModule], controllers: [LastMileController], providers: [LastMileRepository, LastMileService], exports: [LastMileRepository, LastMileService], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 55b8fee24..d25729324 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,6 +1,7 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FindOptionsWhere } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; @@ -25,7 +26,29 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ @Injectable() export class LastMileService { - constructor(private readonly lastMileRepository: LastMileRepository) {} + constructor( + private readonly lastMileRepository: LastMileRepository, + private readonly bookingsRepository: BookingsRepository, + ) {} + + async acceptBooking(bookingReference: string): Promise { + const booking = await this.bookingsRepository.findByReference(bookingReference); + + if (!booking) { + throw new NotFoundException(`Booking ${bookingReference} not found`); + } + + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking ${bookingReference} is not paid (payment status: ${booking.paymentStatus})`, + ); + } + + return this.create({ + bookingId: booking.id, + advancedPayment: booking.totalAmount, + }); + } async findAll(filter: LastMileListFilter = {}): Promise<{ data: LastMile[]; @@ -45,7 +68,10 @@ export class LastMileService { const [data, total] = await this.lastMileRepository.findAndCount({ where, - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, take: pageSize, @@ -64,7 +90,10 @@ export class LastMileService { async findById(id: string): Promise { const record = await this.lastMileRepository.findById(id, { - relations: { booking: true, vehicle: true }, + relations: { + booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true }, + vehicle: true, + }, }); if (!record) { diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1f95c6253..582e7467e 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -35,6 +35,7 @@ import { } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; /** Setting code holding the global ordering window (months) for general contracts. */ const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; @@ -60,6 +61,7 @@ export class PaymentService { @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, private readonly dropdownSettings: DropdownSettingsService, + private readonly firstMileService: FirstMileService, ) { } /** Configured general-contract ordering window in months (defaults to 3). */ @@ -342,6 +344,8 @@ export class PaymentService { ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } : { paymentStatus: "PAID", status: "PAID" }, ); + await this.firstMileService.acceptBooking(input.bookingId); + }); if (isGeneralContract) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 6cf009562..accc18be9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2272,6 +2272,7 @@ export class TrainSchedulingService { weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)), status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, + freightType: sb.booking?.freightType ?? null, })) ?? [], }; } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index d4694d78f..e4153c747 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -261,6 +261,54 @@ export class PricingDataSeeder { isActive: true, displayOrder: i + 1, })), + { + code: "GRAIN", + cargoTypeName: "Grain / Cereals", + showFreeTextBox: false, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: "FERTILIZER", + cargoTypeName: "Fertilizer", + showFreeTextBox: false, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + { + code: "CEMENT", + cargoTypeName: "Cement / Clinker", + showFreeTextBox: false, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 3, + }, + { + code: "STEEL", + cargoTypeName: "Steel / Rebar", + showFreeTextBox: false, + requiresDirectorApproval: true, + isActive: true, + displayOrder: 4, + }, + { + code: "MACHINERY", + cargoTypeName: "Heavy Machinery", + showFreeTextBox: false, + requiresDirectorApproval: true, + isActive: true, + displayOrder: 5, + }, + { + code: "OTHER_BULK", + cargoTypeName: "Other Bulk Cargo", + showFreeTextBox: false, + requiresDirectorApproval: false, + isActive: true, + displayOrder: 6, + }, ], { conflictPaths: { code: true } }, ); @@ -484,15 +532,29 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { slByCode: Map, cargoByCode: Map, ): Promise { - const djibouti = yardByCode.get("DJIBOUTI")!; - const addis = yardByCode.get("ADDIS_ABABA")!; - const railContainer = stByCode.get("RAIL_CONTAINER")!; - const railBulk = stByCode.get("RAIL_BULK")!; - const maersk = slByCode.get("MAERSK")!; - const grain = cargoByCode.get("GRAIN")!; - const twenty = ctByCode.get("20FT")!; - const forty = ctByCode.get("40FT")!; - const twentyReefer = ctByCode.get("20FT_REEFER")!; + const djibouti = yardByCode.get("DJIBOUTI"); + const addis = yardByCode.get("ADDIS_ABABA"); + const railContainer = stByCode.get("RAIL_CONTAINER"); + const railBulk = stByCode.get("RAIL_BULK"); + const maersk = slByCode.get("MAERSK"); + const grain = cargoByCode.get("GRAIN"); + const twenty = ctByCode.get("20FT"); + const forty = ctByCode.get("40FT"); + const twentyReefer = ctByCode.get("20FT_REEFER"); + + const missing: string[] = []; + if (!djibouti) missing.push("yard:DJIBOUTI"); + if (!addis) missing.push("yard:ADDIS_ABABA"); + if (!railContainer) missing.push("serviceType:RAIL_CONTAINER"); + if (!railBulk) missing.push("serviceType:RAIL_BULK"); + if (!grain) missing.push("cargoType:GRAIN"); + if (!twenty) missing.push("containerType:20FT"); + if (!forty) missing.push("containerType:40FT"); + if (!twentyReefer) missing.push("containerType:20FT_REEFER"); + if (missing.length > 0) { + this.logger.warn(`seedDraftBookings: skipping — missing reference data: ${missing.join(", ")}`); + return; + } const drafts = [ { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index e8c9d21e6..8f0e233b4 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -9,7 +9,7 @@ "preview": "vite preview --port 5183", "lint": "eslint src", "test": "vitest run", - "type-check": "tsc --noEmit" + "type-check": "tsc -b" }, "dependencies": { "@edr/types": "workspace:*", diff --git a/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx deleted file mode 100644 index f18389f9e..000000000 --- a/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx +++ /dev/null @@ -1,330 +0,0 @@ -// components/baselineRatematrix/RateMatrixForm.tsx -import React, { useState, useCallback } from 'react'; -// import { useForm } from 'react-hook-form'; -// import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { z } from 'zod'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react'; -import { RateTypeSection } from './RateTypeSection'; -import { ConfirmationDialog } from './ConfirmationDialog'; -import { ValidationSummary } from './ValidationSummary'; -import { LoadingScreen } from '@/ui/LoadingScreen'; -import { useRateMatrixAuth } from '@/auth/hooks/useAuth'; -import { useReferenceData } from '@/hooks/useReferenceData'; -import { queryKeys } from '@/constants/queryKeys'; -import { API_URLS } from '@/constants/apiUrls'; -import { - RATE_TYPES, - RATE_TYPE_LABELS, - REQUIRED_RATE_TYPES -} from '@/constants/rateMatrixConstants'; -import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules'; -import type { RateEntry } from './types'; - -const formSchema = z.object({ - matrixName: z.string().min(1, 'Matrix name is required').max(200), - effectiveDate: z.string().min(1, 'Effective date is required'), - expiryDate: z.string().optional(), - currency: z.string().min(1, 'Currency is required'), -}); - -type FormData = z.infer; - -const createInitialSections = (): RateEntry[] => { - return REQUIRED_RATE_TYPES.map(rateType => ({ - rateType, - entries: [{ - validFrom: '', - validTo: '', - }], - })); -}; - -export function RateMatrixForm() { - const [rateSections, setRateSections] = useState(createInitialSections()); - const [showConfirmation, setShowConfirmation] = useState(false); - const [savedMatrixId, setSavedMatrixId] = useState(null); - const [validationErrors, setValidationErrors] = useState([]); - - const { isDirector } = useRateMatrixAuth(); - const { data: referenceData, isLoading: isLoadingReference } = useReferenceData(); - const queryClient = useQueryClient(); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - matrixName: '', - effectiveDate: '', - expiryDate: '', - currency: 'USD', - }, - }); - - // Save draft mutation - const saveDraftMutation = useMutation({ - mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => { - const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); - if (!response.ok) throw new Error('Failed to save draft'); - return response.json(); - }, - onSuccess: (data) => { - setSavedMatrixId(data.id); - queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all }); - toast.success('Draft saved successfully'); - }, - onError: (error) => { - toast.error('Failed to save draft'); - }, - }); - - // Submit for approval mutation - const submitMutation = useMutation({ - mutationFn: async (matrixId: string) => { - const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), { - method: 'POST', - }); - if (!response.ok) throw new Error('Failed to submit'); - return response.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all }); - toast.success('Rate matrix submitted for executive approval and locked!'); - setShowConfirmation(false); - }, - onError: (error) => { - toast.error('Failed to submit for approval'); - setShowConfirmation(false); - }, - }); - - const handleValidate = useCallback(() => { - const validation = rateMatrixRulesEngine.validate(rateSections); - setValidationErrors([...validation.errors, ...validation.warnings]); - - if (validation.isValid) { - toast.success('All validations passed!'); - } - }, [rateSections]); - - const handleSaveDraft = async () => { - const formData = form.getValues(); - await saveDraftMutation.mutateAsync({ - ...formData, - rateSections, - }); - }; - - const handleSubmitClick = async () => { - const isFormValid = await form.trigger(); - if (!isFormValid) return; - - const validation = rateMatrixRulesEngine.validate(rateSections); - setValidationErrors([...validation.errors, ...validation.warnings]); - - if (!validation.isValid) { - toast.error('Please fix validation errors before submitting'); - return; - } - - setShowConfirmation(true); - }; - - const handleConfirmSubmit = async () => { - const formData = form.getValues(); - - try { - let matrixId = savedMatrixId; - - if (!matrixId) { - const draftResult = await saveDraftMutation.mutateAsync({ - ...formData, - rateSections, - }); - matrixId = draftResult.id; - } - - await submitMutation.mutateAsync(matrixId!); - } catch (error) { - // Error handling done in mutations - } - }; - - if (isLoadingReference) { - return ; - } - - if (!isDirector) { - return ( -
- - - Access Denied - - Only Directors can access the rate matrix registration. - - -
- ); - } - - return ( -
- {/* Header */} -
-

- Baseline Rate Matrix Registration -

-

- Submit a comprehensive rate matrix for executive approval -

-
- - {/* Director Warning */} - - - Director Notice - - Once submitted, this matrix will be locked pending Chief Executive approval. - No edits can be made by any user until authorization is granted. - - - -
e.preventDefault()}> - {/* Matrix Metadata */} - - - Matrix Information - - -
-
- - - {form.formState.errors.matrixName && ( -

- {form.formState.errors.matrixName.message} -

- )} -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- - {/* Rate Type Sections */} -
- {rateSections.map((section, index) => ( - { - const newSections = [...rateSections]; - newSections[index] = updatedSection; - setRateSections(newSections); - }} - referenceData={referenceData} - /> - ))} -
- - {/* Validation Errors */} - {validationErrors.length > 0 && ( -
- -
- )} - - {/* Form Actions */} -
- - - - - -
-
- - {/* Confirmation Dialog */} - -
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx index 4997f8ef8..88e522576 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingRequestsHeader.tsx @@ -21,20 +21,6 @@ import type { BookingListSummaryTabs, } from "@/services/bookings.service"; -/** Lifecycle stages for the pipeline distribution bar (in flow order). */ -const PIPELINE_STAGES: Array<{ - key: keyof BookingListSummaryTabs; - label: string; - color: string; -}> = [ - { key: "intake", label: "Intake", color: "#38bdf8" }, - { key: "in_approval", label: "Approval", color: "#f59e0b" }, - { key: "approved_contract", label: "Contract", color: "#8b5cf6" }, - { key: "payment", label: "Payment", color: "#fb923c" }, - { key: "operations", label: "Operations", color: "#14b8a6" }, - { key: "completed", label: "Completed", color: "#22c55e" }, -]; - const CARD_STYLE = { background: "var(--mantine-color-gray-0)", border: "1px solid var(--mantine-color-gray-2)", @@ -62,7 +48,12 @@ export function BookingRequestsHeader({ return ( - - @@ -285,4 +323,4 @@ const FleetFormDialog = ({ ); }; -export default FleetFormDialog; \ No newline at end of file +export default FleetFormDialog; diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx index 3ec37d092..9096f52a1 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetRecordActions.tsx @@ -1,5 +1,5 @@ import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react"; -import { ActionIcon, Group, Menu, MenuItem, Tooltip } from "@mantine/core"; +import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core"; import { useNavigate } from "react-router-dom"; import type { FleetResourceConfig } from "@/pages/fleet/config/resources"; @@ -37,30 +37,39 @@ const FleetRecordActions = ({ - + {isVehicle && onAssignDriver ? ( - onAssignDriver(record)} leftSection={}> + onAssignDriver(record)} + leftSection={} + > Assign Driver ) : null} - onEdit(record)} leftSection={}> + onEdit(record)} + leftSection={} + > Edit {showDetail ? ( - }> + } + > View details ) : null} - onRemove(record)} leftSection={}> + onRemove(record)} + leftSection={} + > {removeLabel} @@ -72,30 +81,39 @@ const FleetRecordActions = ({ - + {isVehicle && onAssignDriver ? ( - onAssignDriver(record)} leftSection={}> + onAssignDriver(record)} + leftSection={} + > Assign Driver ) : null} - onEdit(record)} leftSection={}> + onEdit(record)} + leftSection={} + > Edit {showDetail ? ( - }> + } + > View details ) : null} - onRemove(record)} leftSection={}> + onRemove(record)} + leftSection={} + > {removeLabel} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx index 0310ca328..392bf6dfb 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineCardGrid.tsx @@ -1,4 +1,4 @@ -import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import type { OnChangeFn, PaginationState } from "@edr/ui-common"; import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core"; import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; @@ -85,8 +85,15 @@ const RuleEngineCardGrid = ({ if (status === "error") { return ( - - Failed to load data + + + Failed to load data + Please refresh the page or try again later. @@ -118,8 +125,15 @@ const RuleEngineCardGrid = ({ if (status === "success" && rows.length === 0) { return ( - - {emptyMessage} + + + {emptyMessage} + Try adjusting your search or add a new record. @@ -197,7 +211,10 @@ const RuleEngineCardGrid = ({ {subtitle && ( - {presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}: + {presentation.subtitleKey === "stepOrder" + ? "Step" + : "Type"} + : {subtitle} @@ -207,7 +224,12 @@ const RuleEngineCardGrid = ({ {presentation.detailColumns.map((col) => { const displayValue = getSmartValue(record, col.accessorKey); return ( - + {col.header}: @@ -220,14 +242,19 @@ const RuleEngineCardGrid = ({ )} - + {})} - onDelete={onDelete ?? (() => {})} + onEdit={onEdit ?? (() => { })} + onDelete={onDelete ?? (() => { })} onViewChain={onViewChain} onSubmitRate={onSubmitRate} onApproveRate={onApproveRate} diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineListFooter.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineListFooter.tsx index ea8087bd2..f53d6cb57 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineListFooter.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineListFooter.tsx @@ -1,4 +1,4 @@ -import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; +import type { OnChangeFn, PaginationState } from "@edr/ui-common"; import { Group, Pagination, Select, Text } from "@mantine/core"; export interface RuleEngineListFooterProps { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx index 2243d1650..00df4700c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -11,7 +11,7 @@ import { import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { freightBrand } from "@/theme/freight-brand"; -type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; +type Wagon = NonNullable["wagons"][number]; export interface BookingDetailData { bookingId: string; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 297ecc48b..ee69fe145 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -11,7 +11,7 @@ import { import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { freightBrand } from "@/theme/freight-brand"; -type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; +type Wagon = NonNullable["wagons"][number]; type Locomotive = NonNullable["locomotive"]; interface InteractiveTrainConsistProps { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx index 151a796d8..4d2b1369c 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -1,7 +1,7 @@ import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core"; import type { TrainScheduleDetail } from "@/types/trainScheduling"; -type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number]; +type WagonWithAllocation = NonNullable["wagons"][number]; interface RemoveBookingModalProps { opened: boolean; @@ -21,7 +21,6 @@ export const RemoveBookingModal = ({ if (!wagon || !wagon.allocations?.[0]) return null; const allocation = wagon.allocations[0]; - const booking = allocation.booking; return ( @@ -32,12 +31,12 @@ export const RemoveBookingModal = ({ - Reference: {booking?.reference || "N/A"} + Reference: {allocation.bookingReference || "N/A"} Freight Type:{" "} - {booking?.freightType || "N/A"} + {allocation.loadType || "N/A"} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx index 2ebfbb8ff..6ece98ef2 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; import { freightBrand } from "@/theme/freight-brand"; -type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; +type Wagon = NonNullable["wagons"][number]; interface TrainConsistViewProps { scheduleDetail: TrainScheduleDetail; @@ -103,9 +103,9 @@ export const TrainConsistView = ({ diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index a3fb971e4..663ab2e6f 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -12,7 +12,7 @@ import type { TrainScheduleDetail } from "@/types/trainScheduling"; import { ContainerNumberInput } from "./ContainerNumberInput"; import { freightBrand } from "@/theme/freight-brand"; -type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; +type Wagon = NonNullable["wagons"][number]; interface WagonCardProps { wagon: Wagon; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index a1a00d2e9..5598870c4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,12 +1,15 @@ -import { useMemo } from 'react'; -import { ActionIcon, Badge, Button, Group, Text, Tooltip } from '@mantine/core'; -import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react'; -import { DataTable, type ColumnDef } from '@edr/ui-common'; +import { DataTable, type ColumnDef } from "@edr/ui-common"; +import { ActionIcon, Badge, Button, Group, Text, Tooltip } from "@mantine/core"; +import { ArrowRightLeft, ClipboardList, Coins, History } from "lucide-react"; +import { useMemo } from "react"; -import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse'; -import { getNextInventoryAction } from '@/types/warehouse'; -import { InventoryStatusBadge } from './badges'; -import { formatDate, formatNumber, humanizeEnum } from './options'; +import { + INVENTORY_NEXT_ACTION, + type InventoryAction, + type WarehouseInventoryItem, +} from "@/types/warehouse"; +import { InventoryStatusBadge } from "./badges"; +import { formatDate, formatNumber, humanizeEnum } from "./options"; interface WarehouseInventoryTableProps { items: WarehouseInventoryItem[]; @@ -27,21 +30,21 @@ interface WarehouseInventoryTableProps { } const itemKind = (item: WarehouseInventoryItem) => { - if (item.containerId) return { label: 'Container', color: 'blue' }; - if (item.cargoId) return { label: 'Cargo', color: 'grape' }; - if (item.goodsId) return { label: 'Goods', color: 'orange' }; - return { label: '—', color: 'gray' }; + if (item.containerId) return { label: "Container", color: "blue" }; + if (item.cargoId) return { label: "Cargo", color: "grape" }; + if (item.goodsId) return { label: "Goods", color: "orange" }; + return { label: "—", color: "gray" }; }; const actionColor: Record = { - store: 'blue', - reserve: 'grape', - 'ready-for-loading': 'cyan', - load: 'teal', - dispatch: 'edr-green', - 'ready-for-pickup': 'orange', - release: 'yellow', - deliver: 'green', + store: "blue", + reserve: "grape", + "ready-for-loading": "cyan", + load: "teal", + dispatch: "edr-green", + "ready-for-pickup": "orange", + release: "yellow", + deliver: "green", }; export function WarehouseInventoryTable({ @@ -52,18 +55,12 @@ export function WarehouseInventoryTable({ onHistory, onInspect, onFeePreview, - onLastMile, - selectedIds, - onToggleSelect, - onToggleSelectAll, - allSelected, - someSelected, }: WarehouseInventoryTableProps) { const columns = useMemo[]>( () => [ { - id: 'booking', - header: 'Booking', + id: "booking", + header: "Booking", cell: ({ row }) => row.original.bookingId ? ( @@ -78,16 +75,28 @@ export function WarehouseInventoryTable({ ), }, { - id: 'facility', - header: 'Facility', - cell: ({ row }) => row.original.warehouse?.facility?.name ?? '—', + id: "facility", + header: "Facility", + cell: ({ row }) => row.original.warehouse?.facility?.name ?? "—", }, - { id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' }, - { id: 'yard', header: 'Yard', cell: ({ row }) => row.original.yard?.code ?? '—' }, - { id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' }, { - id: 'item', - header: 'Item', + id: "warehouse", + header: "Warehouse", + cell: ({ row }) => row.original.warehouse?.code ?? "—", + }, + { + id: "yard", + header: "Yard", + cell: ({ row }) => row.original.yard?.code ?? "—", + }, + { + id: "zone", + header: "Zone", + cell: ({ row }) => row.original.zone?.code ?? "—", + }, + { + id: "item", + header: "Item", cell: ({ row }) => { const kind = itemKind(row.original); return ( @@ -97,27 +106,44 @@ export function WarehouseInventoryTable({ ); }, }, - { id: 'qty', header: 'Qty', cell: ({ row }) => formatNumber(row.original.quantity) }, - { id: 'weight', header: 'Weight', cell: ({ row }) => formatNumber(row.original.weight) }, { - id: 'status', - header: 'Status', - cell: ({ row }) => , + id: "qty", + header: "Qty", + cell: ({ row }) => formatNumber(row.original.quantity), }, { - id: 'arrived', - header: 'Arrived', - cell: ({ row }) => {formatDate(row.original.arrivedAt)}, + id: "weight", + header: "Weight", + cell: ({ row }) => formatNumber(row.original.weight), }, { - id: 'actions', - header: '', + id: "status", + header: "Status", + cell: ({ row }) => ( + + ), + }, + { + id: "arrived", + header: "Arrived", + cell: ({ row }) => ( + {formatDate(row.original.arrivedAt)} + ), + }, + { + id: "actions", + header: "", cell: ({ row }) => { const item = row.original; const busy = busyId === item.id; const nextAction = INVENTORY_NEXT_ACTION[item.status]; return ( - e.stopPropagation()}> + e.stopPropagation()} + > {nextAction && ( )} - {item.status !== 'DISPATCHED' && ( + {item.status !== "DISPATCHED" && ( - onMove(item)}> + onMove(item)} + > )} {onInspect && ( - onInspect(item)}> + onInspect(item)} + > )} {onFeePreview && ( - onFeePreview(item)}> + onFeePreview(item)} + > )} - onHistory(item)}> + onHistory(item)} + > diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx index 1808a78bb..aebbd4de4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/badges.tsx @@ -1,53 +1,80 @@ -import { Badge } from '@mantine/core'; +import { Badge } from "@mantine/core"; -import type { InventoryStatus, WarehouseStatus, WarehouseType } from '@/types/warehouse'; +import type { + InventoryStatus, + WarehouseStatus, + WarehouseType, +} from "@/types/warehouse"; const humanize = (value: string) => value .toLowerCase() - .split('_') + .split("_") .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); + .join(" "); const badgeStyle = { - fontSize: '0.7rem', - letterSpacing: '0.04em', - whiteSpace: 'nowrap' as const, + fontSize: "0.7rem", + letterSpacing: "0.04em", + whiteSpace: "nowrap" as const, }; export function WarehouseTypeBadge({ type }: { type: WarehouseType }) { - const color = type === 'CLOSED_WAREHOUSE' ? 'indigo' : 'teal'; + const color = type === "CLOSED_WAREHOUSE" ? "indigo" : "teal"; return ( - + {humanize(type)} ); } export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) { - const color = status === 'ACTIVE' ? 'edr-green' : 'gray'; + const color = status === "ACTIVE" ? "edr-green" : "gray"; return ( - + {status} ); } const inventoryStatusColor: Record = { - UNLOADED: 'indigo', - RECEIVED: 'yellow', - STORED: 'blue', - RESERVED: 'grape', - READY_FOR_LOADING: 'cyan', - LOADED: 'teal', - DISPATCHED: 'edr-green', - DELIVERED: 'edr-green', + UNLOADED: "indigo", + RECEIVED: "yellow", + STORED: "blue", + RESERVED: "grape", + READY_FOR_LOADING: "cyan", + LOADED: "teal", + READY_FOR_PICKUP: "teal", + DISPATCHED: "edr-green", + DELIVERED: "edr-green", }; export function InventoryStatusBadge({ status }: { status: InventoryStatus }) { - const color = inventoryStatusColor[status] ?? 'gray'; + const color = inventoryStatusColor[status] ?? "gray"; return ( - + {humanize(status)} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index aced8cda4..8e8b9f94c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -69,6 +69,18 @@ export const QUERY_KEYS = { list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, }, + FIRST_MILE: { + ROOT: ["first-mile"] as const, + list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const, + byId: (id: string) => ["first-mile", "detail", id] as const, + }, + + LAST_MILE: { + ROOT: ["last-mile"] as const, + list: (filter?: Record) => ["last-mile", "list", filter ?? {}] as const, + byId: (id: string) => ["last-mile", "detail", id] as const, + }, + RULE_ENGINE: { ROOT: ["rule-engine"] as const, list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 6e12dea39..fc3897fa6 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -362,6 +362,18 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/vehicles/${id}`, }, + FIRST_MILE: { + BASE: '/first-mile', + BY_ID: (id: string) => `/first-mile/${id}`, + ACCEPT: (reference: string) => `/first-mile/accept/${reference}`, + }, + + LAST_MILE: { + BASE: '/last-mile', + BY_ID: (id: string) => `/last-mile/${id}`, + ACCEPT: (reference: string) => `/last-mile/accept/${reference}`, + }, + DRIVERS: { BASE: '/drivers', BY_ID: (id: string) => `/drivers/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index a7c8670cf..030b051a1 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index d5b59d072..8f66cd124 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -64,7 +64,11 @@ export interface BookingActionDef { export type BookingActionContext = Pick< BookingDetail, - "status" | "paymentCurrency" | "approvalSteps" | "reference" | "schedulingStatus" + | "status" + | "paymentCurrency" + | "approvalSteps" + | "reference" + | "schedulingStatus" >; const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ @@ -76,7 +80,9 @@ const ALLOCATABLE_SCHEDULING_STATUSES = new Set([ "", ]); -export function canAllocateBooking(booking: Pick) { +export function canAllocateBooking( + booking: Pick, +) { return ( booking.status === "PAID" && ALLOCATABLE_SCHEDULING_STATUSES.has(booking.schedulingStatus ?? undefined) @@ -345,13 +351,21 @@ export function getBookingActions( actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION]; break; case "FULLY_EXECUTED": - actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }]; + actions = [ + { + ...VIEW_CONTRACT_ACTION, + label: "View executed contract", + primary: true, + }, + ]; break; case "OPERATION_REQUEST_PENDING": actions = withCancel(OPERATION_REVIEW_ACTIONS); break; case "PAID": - if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) { + if ( + canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus }) + ) { actions = [ { id: "allocateBooking", @@ -446,7 +460,7 @@ export function listRowHasActions( paymentCurrency: row.paymentCurrency, reference: "", approvalSteps: row.approvalSteps ?? undefined, - schedulingStatus: row.schedulingStatus, + schedulingStatus: row.status, }, user, ); diff --git a/apps/edr-freight-web/backoffice/src/lib/currentCustomer.ts b/apps/edr-freight-web/backoffice/src/lib/currentCustomer.ts deleted file mode 100644 index 0a68cd4f5..000000000 --- a/apps/edr-freight-web/backoffice/src/lib/currentCustomer.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { customers, type Customer } from "@/pages/customers/customers.mock"; -import { bookings, type Booking } from "@/pages/bookings/bookings.mock"; -import { - consignments, - type Consignment, -} from "@/pages/consignments/consignments.mock"; -import { - shipments, - type Shipment, -} from "@/pages/tracking/shipments.mock"; -import { invoices, type Invoice } from "@/pages/billing/invoices.mock"; - -/** - * Mock "logged-in customer". When auth integrates, replace this with the value - * pulled from `@edr/iamui-common` / the JWT context. - */ -const CURRENT_CUSTOMER_ID = 1; - -export function getCurrentCustomer(): Customer { - return ( - customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ?? - (customers[0] as Customer) - ); -} - -export function getMyBookings(): Booking[] { - const me = getCurrentCustomer(); - return bookings.filter((b) => b.customerId === me.id); -} - -export function getMyConsignments(): Consignment[] { - const me = getCurrentCustomer(); - const myBookingIds = new Set(getMyBookings().map((b) => b.id)); - return consignments.filter((c) => myBookingIds.has(c.bookingId)); -} - -export function getMyShipments(): Shipment[] { - const myBookingIds = new Set(getMyBookings().map((b) => b.id)); - return shipments.filter((s) => myBookingIds.has(s.bookingId)); -} - -export function getMyInvoices(): Invoice[] { - const me = getCurrentCustomer(); - return invoices.filter((inv) => inv.customerId === me.id); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx deleted file mode 100644 index 723821936..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixApproval.tsx +++ /dev/null @@ -1,117 +0,0 @@ -// pages/admin/rateMatrix/RateMatrixApproval.tsx -import React from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { LoadingScreen } from '@/ui/LoadingScreen'; -import { useRateMatrixAuth } from '@/auth/hooks/useAuth'; -import { queryKeys } from '../../../constants/QUERY_KEYS'; -import { API_URLS } from '@/constants/URL_CONSTANTS'; -//import { MATRIX_STATUS } from '@/constants/rateMatrixConstants'; -import { toast } from 'sonner'; -import { Navigate } from 'react-router-dom'; - -export default function RateMatrixApprovalPage() { - const { isChiefExecutive } = useRateMatrixAuth(); - const queryClient = useQueryClient(); - const pendingMatricesQueryKey = [...queryKeys.rateMatrix.all, 'pending-approval']; - - const { data: pendingMatrices, isLoading } = useQuery({ - queryKey: pendingMatricesQueryKey, - queryFn: async () => { - const response = await fetch(`${API_URLS.RATE_MATRIX.LIST}?status=pending_approval`); - return response.json(); - }, - }); - - const authorizeMutation = useMutation({ - mutationFn: async ({ matrixId, signature }: { matrixId: string; signature: string }) => { - const response = await fetch(API_URLS.RATE_MATRIX.AUTHORIZE(matrixId), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ digitalSignature: signature }), - }); - if (!response.ok) throw new Error('Authorization failed'); - return response.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: pendingMatricesQueryKey }); - toast.success('Rate matrix authorized successfully!'); - }, - onError: () => { - toast.error('Failed to authorize rate matrix'); - }, - }); - - if (!isChiefExecutive) { - return ; - } - - if (isLoading) return ; - - return ( -
-

Pending Rate Matrix Approvals

- -
- {pendingMatrices?.map((matrix: any) => ( - - - - {matrix.matrixName} - {matrix.status} - - - -
-
-
-

Effective Date

-

{matrix.effectiveDate}

-
-
-

Submitted By

-

{matrix.createdBy}

-
-
- -
-

Rate Types Included:

-
- {matrix.rateEntries?.map((entry: any) => ( - - {entry.rateType} - - ))} -
-
- -
- - -
-
-
-
- ))} -
-
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx deleted file mode 100644 index 6f151f992..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/admin/rateMatrix/RateMatrixRegistration.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// pages/admin/rateMatrix/RateMatrixRegistration.tsx -import React from 'react'; -import { RateMatrixForm } from '@/components/baselineRatematrix/RateMatrixForm'; -import { useRateMatrixAuth } from '@/auth/useAuth'; -import { Navigate } from 'react-router-dom'; -// Local lightweight fallback for LoadingScreen to avoid import errors -const LoadingScreen: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => ( -
-
{message}
-
-); - -export default function RateMatrixRegistrationPage() { - const { isDirector, isLoading } = useRateMatrixAuth(); - - if (isLoading) { - return ; - } - - if (!isDirector) { - return ; - } - - return ; -} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx index 481eeb407..0497f0ce8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -104,7 +104,6 @@ const BookingDetailPage = () => { const approvedCount = approvalSteps.filter( (s) => s.status === "APPROVED", ).length; - const totalSteps = approvalSteps.length; return (
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 2e5b9ee0f..901226436 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -34,7 +34,10 @@ import { WarehouseInfoCard } from "@/components/warehouses"; import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { downloadBookingFile } from "@/services/files.service"; -import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings"; +import { + useBookingDetail, + useBookingMutations, +} from "@/hooks/bookings/useBookings"; import toast from "react-hot-toast"; // Signature / generated-contract files are surfaced on the contract page, not @@ -49,7 +52,13 @@ const SIGNATURE_FILE_CODES = new Set([ export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id); + const { + data: booking, + isLoading, + isError, + refetch, + isFetching, + } = useBookingDetail(id); const mutations = useBookingMutations(id ?? ""); const handleDownloadFile = async (file: BookingFileView) => { @@ -79,7 +88,13 @@ export default function BookingRequestDetailPage() { return ( - +
- navigate("/dashboard/booking-requests")} - onRefresh={() => refetch()} - isFetching={isFetching} - /> + navigate("/dashboard/booking-requests")} + onRefresh={() => refetch()} + isFetching={isFetching} + /> - + - {booking.status === "PENDING_CONSOLIDATION" && ( - - )} + {booking.status === "PENDING_CONSOLIDATION" && ( + + )} - - {/* LEFT — primary content */} - - - - - - {booking.contractSummary && ( - + + {/* LEFT — primary content */} + + + + + + {booking.contractSummary && ( + + )} + !SIGNATURE_FILE_CODES.has(f.code ?? ""), )} - !SIGNATURE_FILE_CODES.has(f.code ?? ""), - )} - onDownload={handleDownloadFile} - /> - - + onDownload={handleDownloadFile} + /> + + - {/* RIGHT — sticky action / summary rail */} - - - - - - - - {showContractButton && ( - - )} - {showApprovalCard && ( - - )} - - - - + {/* RIGHT — sticky action / summary rail */} + + + + + + + + {showContractButton && ( + + )} + {showApprovalCard && ( + + )} + + + + ); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx index b3c9db35d..735f95c07 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -662,7 +662,7 @@ export default function NewBookingPage() { placeholder="Select bulk cargo type" data={cargoData} value={cargoTypeId} - onChange={setCargoTypeId} + onChange={(value) => setCargoTypeId(value as string | null)} searchable disabled={isLoading} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 8bcec226c..427787146 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -93,7 +93,7 @@ const OverviewPage = () => { const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => { if (!summary?.kpis) return 0; - const group = summary.kpis[tab.kpiKey] as Record; + const group = summary.kpis[tab.kpiKey] as unknown as Record; return group[tab.metricKey] ?? 0; }; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx index 9895ae36a..55a4df00e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/PositionTypesPage.tsx @@ -140,30 +140,38 @@ const PositionTypesPage = () => { const [loadingOrganizations, setLoadingOrganizations] = useState(true); const [loadingUnits, setLoadingUnits] = useState(false); const [loadingPositionTypes, setLoadingPositionTypes] = useState(false); - const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = useState(false); + const [loadingPermissionsCatalog, setLoadingPermissionsCatalog] = + useState(false); const [submitting, setSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); - const [selectedPositionType, setSelectedPositionType] = useState(null); - const [positionTypePermissions, setPositionTypePermissions] = useState([]); + const [selectedPositionType, setSelectedPositionType] = + useState(null); const [allPermissions, setAllPermissions] = useState([]); const [permissionsLoading, setPermissionsLoading] = useState(false); const [permissionsError, setPermissionsError] = useState(null); const [permissionSearch, setPermissionSearch] = useState(""); - const [selectedPermissionIds, setSelectedPermissionIds] = useState([]); + const [selectedPermissionIds, setSelectedPermissionIds] = useState( + [], + ); const [isCreateOpen, setIsCreateOpen] = useState(false); const [createForm, setCreateForm] = useState(emptyCreateForm); const [createPermissionSearch, setCreatePermissionSearch] = useState(""); const [createPermissionIds, setCreatePermissionIds] = useState([]); const [createError, setCreateError] = useState(null); - const [editForm, setEditForm] = useState(emptyEditForm); + const [editForm, setEditForm] = + useState(emptyEditForm); - const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin")); + const isSuperAdmin = Boolean( + user?.roles?.some((role) => role.key === "super_admin"), + ); const allowedOrgIds = useMemo( () => new Set( (user?.employee ?? []) .map((employee) => employee.organizationId) - .filter((organizationId): organizationId is string => Boolean(organizationId)), + .filter((organizationId): organizationId is string => + Boolean(organizationId), + ), ), [user?.employee], ); @@ -173,14 +181,21 @@ const PositionTypesPage = () => { return organizations; } - return organizations.filter((organization) => allowedOrgIds.has(organization.id)); + return organizations.filter((organization) => + allowedOrgIds.has(organization.id), + ); }, [allowedOrgIds, isSuperAdmin, organizations]); const selectedOrganization = - visibleOrganizations.find((organization) => organization.id === selectedOrgId) ?? null; + visibleOrganizations.find( + (organization) => organization.id === selectedOrgId, + ) ?? null; const selectedUnit = units.find((unit) => unit.id === selectedUnitId) ?? null; const availableCopySources = useMemo( - () => positionTypes.filter((positionType) => positionType.id !== selectedPositionType?.id), + () => + positionTypes.filter( + (positionType) => positionType.id !== selectedPositionType?.id, + ), [positionTypes, selectedPositionType?.id], ); const filteredPermissions = useMemo(() => { @@ -191,8 +206,13 @@ const PositionTypesPage = () => { return true; } - const label = getLocaleLabel(permission.name, permission.key).toLowerCase(); - return label.includes(query) || permission.key.toLowerCase().includes(query); + const label = getLocaleLabel( + permission.name, + permission.key, + ).toLowerCase(); + return ( + label.includes(query) || permission.key.toLowerCase().includes(query) + ); }); }, [allPermissions, permissionSearch]); const filteredCreatePermissions = useMemo(() => { @@ -203,18 +223,29 @@ const PositionTypesPage = () => { return true; } - const label = getLocaleLabel(permission.name, permission.key).toLowerCase(); - return label.includes(query) || permission.key.toLowerCase().includes(query); + const label = getLocaleLabel( + permission.name, + permission.key, + ).toLowerCase(); + return ( + label.includes(query) || permission.key.toLowerCase().includes(query) + ); }); }, [allPermissions, createPermissionSearch]); - const allFilteredPermissionIds = filteredPermissions.map((permission) => permission.id); - const allFilteredCreatePermissionIds = filteredCreatePermissions.map((permission) => permission.id); + const allFilteredPermissionIds = filteredPermissions.map( + (permission) => permission.id, + ); + const allFilteredCreatePermissionIds = filteredCreatePermissions.map( + (permission) => permission.id, + ); const areAllFilteredPermissionsSelected = allFilteredPermissionIds.length > 0 && allFilteredPermissionIds.every((id) => selectedPermissionIds.includes(id)); const areAllFilteredCreatePermissionsSelected = allFilteredCreatePermissionIds.length > 0 && - allFilteredCreatePermissionIds.every((id) => createPermissionIds.includes(id)); + allFilteredCreatePermissionIds.every((id) => + createPermissionIds.includes(id), + ); const loadPositionTypes = async (unitId: string) => { const response = await api.get>( @@ -247,7 +278,8 @@ const PositionTypesPage = () => { setErrorMessage(null); try { - const response = await api.get>("/organizations"); + const response = + await api.get>("/organizations"); if (!isMounted) { return; @@ -261,7 +293,7 @@ const PositionTypesPage = () => { setErrorMessage( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load organizations." + ? (error.response?.data?.message ?? "Unable to load organizations.") : "Unable to load organizations.", ); } finally { @@ -285,12 +317,15 @@ const PositionTypesPage = () => { setLoadingPermissionsCatalog(true); try { - const response = await api.get>("/permissions", { - params: { - skip: 0, - take: 2000, + const response = await api.get>( + "/permissions", + { + params: { + skip: 0, + take: 2000, + }, }, - }); + ); if (!isMounted) { return; @@ -326,7 +361,12 @@ const PositionTypesPage = () => { return; } - if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) { + if ( + selectedOrgId && + visibleOrganizations.some( + (organization) => organization.id === selectedOrgId, + ) + ) { return; } @@ -350,7 +390,9 @@ const PositionTypesPage = () => { setPositionTypes([]); try { - const response = await api.get>(`/units/list/${selectedOrgId}`); + const response = await api.get>( + `/units/list/${selectedOrgId}`, + ); const items = getItems(response.data); if (!isMounted) { @@ -367,7 +409,7 @@ const PositionTypesPage = () => { setUnits([]); setErrorMessage( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load units." + ? (error.response?.data?.message ?? "Unable to load units.") : "Unable to load units.", ); } finally { @@ -412,7 +454,8 @@ const PositionTypesPage = () => { setPositionTypes([]); setErrorMessage( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load position types." + ? (error.response?.data?.message ?? + "Unable to load position types.") : "Unable to load position types.", ); } finally { @@ -431,7 +474,6 @@ const PositionTypesPage = () => { useEffect(() => { if (!selectedPositionType) { - setPositionTypePermissions([]); setSelectedPermissionIds([]); setEditForm(emptyEditForm); setPermissionsError(null); @@ -453,23 +495,24 @@ const PositionTypesPage = () => { setPermissionsError(null); try { - const items = await loadPermissionsForPositionType(selectedPositionType.id); + const items = await loadPermissionsForPositionType( + selectedPositionType.id, + ); if (!isMounted) { return; } - setPositionTypePermissions(items); setSelectedPermissionIds(items.map((permission) => permission.id)); } catch (error) { if (!isMounted) { return; } - setPositionTypePermissions([]); setPermissionsError( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to load position type permissions." + ? (error.response?.data?.message ?? + "Unable to load position type permissions.") : "Unable to load position type permissions.", ); } finally { @@ -499,13 +542,16 @@ const PositionTypesPage = () => { setPositionTypes(items); if (selectedPositionType) { - const nextSelected = items.find((item) => item.id === selectedPositionType.id) ?? selectedPositionType; + const nextSelected = + items.find((item) => item.id === selectedPositionType.id) ?? + selectedPositionType; setSelectedPositionType(nextSelected); } } catch (error) { setErrorMessage( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to refresh position types." + ? (error.response?.data?.message ?? + "Unable to refresh position types.") : "Unable to refresh position types.", ); } finally { @@ -522,7 +568,10 @@ const PositionTypesPage = () => { }; const handleSelectCopySource = async (positionTypeId: string) => { - setCreateForm((current) => ({ ...current, copyPermissionFromId: positionTypeId })); + setCreateForm((current) => ({ + ...current, + copyPermissionFromId: positionTypeId, + })); if (!positionTypeId) { setCreatePermissionIds([]); @@ -530,18 +579,23 @@ const PositionTypesPage = () => { } try { - const copiedPermissions = await loadPermissionsForPositionType(positionTypeId); - setCreatePermissionIds(copiedPermissions.map((permission) => permission.id)); + const copiedPermissions = + await loadPermissionsForPositionType(positionTypeId); + setCreatePermissionIds( + copiedPermissions.map((permission) => permission.id), + ); } catch (error) { setCreateError( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to copy permissions." + ? (error.response?.data?.message ?? "Unable to copy permissions.") : "Unable to copy permissions.", ); } }; - const handleCreatePositionType = async (event: React.FormEvent) => { + const handleCreatePositionType = async ( + event: React.FormEvent, + ) => { event.preventDefault(); if (!selectedUnitId) { @@ -576,7 +630,7 @@ const PositionTypesPage = () => { } catch (error) { setCreateError( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to create position type." + ? (error.response?.data?.message ?? "Unable to create position type.") : "Unable to create position type.", ); } finally { @@ -611,21 +665,26 @@ const PositionTypesPage = () => { const [refreshedPermissions, refreshedPositionTypes] = await Promise.all([ loadPermissionsForPositionType(selectedPositionType.id), - selectedUnitId ? loadPositionTypes(selectedUnitId) : Promise.resolve(positionTypes), + selectedUnitId + ? loadPositionTypes(selectedUnitId) + : Promise.resolve(positionTypes), ]); - setPositionTypePermissions(refreshedPermissions); - setSelectedPermissionIds(refreshedPermissions.map((permission) => permission.id)); + setSelectedPermissionIds( + refreshedPermissions.map((permission) => permission.id), + ); setPositionTypes(refreshedPositionTypes); - const refreshedSelected = refreshedPositionTypes.find((item) => item.id === selectedPositionType.id); + const refreshedSelected = refreshedPositionTypes.find( + (item) => item.id === selectedPositionType.id, + ); if (refreshedSelected) { setSelectedPositionType(refreshedSelected); } } catch (error) { setPermissionsError( isAxiosError(error) - ? error.response?.data?.message ?? "Unable to update position type." + ? (error.response?.data?.message ?? "Unable to update position type.") : "Unable to update position type.", ); } finally { @@ -633,34 +692,6 @@ const PositionTypesPage = () => { } }; - const handleSavePermissions = async () => { - if (!selectedPositionType) { - return; - } - - setSubmitting(true); - setPermissionsError(null); - - try { - await api.post("/position-type-permissions/assign-seconds-for-first", { - firstId: selectedPositionType.id, - secondIds: selectedPermissionIds, - }); - - const items = await loadPermissionsForPositionType(selectedPositionType.id); - setPositionTypePermissions(items); - setSelectedPermissionIds(items.map((permission) => permission.id)); - } catch (error) { - setPermissionsError( - isAxiosError(error) - ? error.response?.data?.message ?? "Unable to update position type permissions." - : "Unable to update position type permissions.", - ); - } finally { - setSubmitting(false); - } - }; - return (
@@ -670,9 +701,12 @@ const PositionTypesPage = () => {

-

Position Type

+

+ Position Type +

- Browse position types for a selected organization unit, add new ones, and manage their permissions. + Browse position types for a selected organization unit, add new + ones, and manage their permissions.

@@ -712,7 +746,13 @@ const PositionTypesPage = () => { disabled={loadingOrganizations || !visibleOrganizations.length} > - + {visibleOrganizations.map((organization) => ( @@ -734,7 +774,11 @@ const PositionTypesPage = () => { disabled={!selectedOrgId || loadingUnits || !units.length} > - + {units.map((unit) => ( @@ -800,7 +844,9 @@ const PositionTypesPage = () => { {getLocaleLabel(positionType.name, positionType.key)} - {positionType.key} + + {positionType.key} + {positionType.isSystem ? "System" : "Unit"} @@ -833,7 +879,10 @@ const PositionTypesPage = () => { {selectedPositionType - ? getLocaleLabel(selectedPositionType.name, selectedPositionType.key) + ? getLocaleLabel( + selectedPositionType.name, + selectedPositionType.key, + ) : "Position type details"} @@ -851,7 +900,10 @@ const PositionTypesPage = () => { Position type

- {getLocaleLabel(selectedPositionType.name, selectedPositionType.key)} + {getLocaleLabel( + selectedPositionType.name, + selectedPositionType.key, + )}

@@ -883,23 +935,33 @@ const PositionTypesPage = () => {
{selectedPositionType.isSystem ? (

- System position types keep their name and key, but you can still manage permissions here. + System position types keep their name and key, but you can + still manage permissions here.

) : null}
-

Permissions

+

+ Permissions +

{selectedPermissionIds.length} permissions selected
@@ -942,7 +1010,9 @@ const PositionTypesPage = () => { setPermissionSearch(event.target.value)} + onChange={(event) => + setPermissionSearch(event.target.value) + } placeholder="Search permissions by name or key" /> @@ -960,7 +1030,9 @@ const PositionTypesPage = () => { ); }} /> - Select all + + Select all + {loadingPermissionsCatalog ? ( @@ -976,20 +1048,29 @@ const PositionTypesPage = () => { > { setSelectedPermissionIds((current) => event.target.checked ? [...current, permission.id] - : current.filter((item) => item !== permission.id), + : current.filter( + (item) => item !== permission.id, + ), ); }} />
- {getLocaleLabel(permission.name, permission.key)} + {getLocaleLabel( + permission.name, + permission.key, + )} +
+
+ {permission.key}
-
{permission.key}
))} @@ -1030,30 +1111,44 @@ const PositionTypesPage = () => { Create position type - Add a new position type for the selected unit and optionally copy permissions from an existing one. + Add a new position type for the selected unit and optionally copy + permissions from an existing one. -
void handleCreatePositionType(event)}> + void handleCreatePositionType(event)} + >
@@ -1064,13 +1159,18 @@ const PositionTypesPage = () => { className={inputClassName} value={createForm.key} onChange={(event) => - setCreateForm((current) => ({ ...current, key: event.target.value })) + setCreateForm((current) => ({ + ...current, + key: event.target.value, + })) } /> ))} @@ -1163,7 +1271,8 @@ const PositionTypesPage = () => {
- The new position type will inherit permissions from the selected source. + The new position type will inherit permissions from the + selected source.
) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts index 923efad0e..ec27b5c76 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/iamConfig.ts @@ -2,7 +2,6 @@ import type { DesignConfig } from "@tria-plc/iamui"; import { FREIGHT_BRAND, - FREIGHT_BRAND_DARK, FREIGHT_BRAND_LIGHT, freightBrand, } from "@/theme/freight-brand"; @@ -48,7 +47,7 @@ export const iamConfig: DesignConfig = { }, layout: { userManagementView: "classic", - showTopBar: true, + showTopBar: true as any, sidebarWidth: "280px", sidebarCollapsedWidth: "80px", headerHeight: "80px", diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx index 4370adac1..af69d4991 100644 --- a/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/documents/EditFileUploadSettingDialog.tsx @@ -17,15 +17,7 @@ import { Textarea } from "@/components/ui/textarea"; import { FileUploadEntity } from "@edr/types/freight"; import { useMutation } from "@tanstack/react-query"; import { api } from "@/services/api"; - -// import type { -// FileUploadEntity, -// FileUploadSetting, -// } from "@/types/fileUploadSettings"; -// import { -// useCreateFileUploadSetting, -// useUpdateFileUploadSetting, -// } from "@/hooks/useFileUploadSettings"; +import { FileUploadSetting } from "@/types/fileUploadSettings"; export interface EditFileUploadSettingDialogProps { mode?: "create" | "edit"; @@ -33,19 +25,6 @@ export interface EditFileUploadSettingDialogProps { children: ReactNode; } -const selectClass = - "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"; - -// const ENTITIES: FileUploadEntity[] = [ -// "customer", -// "booking", -// "consignment", -// "shipment", -// "invoice", -// "train", -// "other", -// ]; - export default function EditFileUploadSettingDialog({ mode = "create", setting, @@ -62,8 +41,12 @@ export default function EditFileUploadSettingDialog({ const [description, setDescription] = useState(setting?.description ?? ""); const [error, setError] = useState(null); - const createMutation = useMutation(api.fileUploadSettings.create.mutationOptions()); - const updateMutation = useMutation(api.fileUploadSettings.update.mutationOptions()); + const createMutation = useMutation( + api.fileUploadSettings.create.mutationOptions(), + ); + const updateMutation = useMutation( + api.fileUploadSettings.update.mutationOptions(), + ); const pending = createMutation.isPending || updateMutation.isPending; const reset = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx index cd846ada7..c8cbaa453 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetCrudPages.tsx @@ -1,49 +1,49 @@ -import { FormEvent, ReactNode, useMemo, useState } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react'; +import { FormEvent, ReactNode, useMemo, useState } from 'react'; import { api } from '@/services/api'; import { - ActionIcon, - Badge as MantineBadge, - Box, - Button as MantineButton, - Group, - Modal, - NumberInput, - Pagination, - Paper, - ScrollArea, - Select as MantineSelect, - SimpleGrid, - Stack, - Table as MantineTable, - Text, - TextInput, - Title, + ActionIcon, + Box, + Group, + Badge as MantineBadge, + Button as MantineButton, + Select as MantineSelect, + Table as MantineTable, + Modal, + NumberInput, + Pagination, + Paper, + ScrollArea, + SimpleGrid, + Stack, + Text, + TextInput, + Title, } from '@mantine/core'; +import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useToast } from '@/hooks/use-toast'; import type { Cargo } from '@/services/cargoService'; -import { DeliverCargoDialog } from '@/components/cargoes/DeliverCargoDialog'; import type { Container } from '@/services/containerService'; import type { Locomotive } from '@/services/locomotives.service'; import type { Train } from '@/services/trains.service'; -import type { Wagon } from '@/services/wagon.service'; import type { WagonType } from '@/services/wagon-types.service'; +import type { Wagon } from '@/services/wagon.service'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common'; type FormValue = string | number | boolean | string[]; @@ -331,7 +331,7 @@ function FleetCrudPage({ {columns.map((column) => ( - {column.render ? column.render(item) : String((item as Record)[column.key] ?? '-')} + {column.render ? column.render(item) : String((item as Record)[String(column.key)] ?? '-')} ))} @@ -410,7 +410,7 @@ function FleetCrudPage({ ...current, [field.key]: selectedValue, ...(field.onValueChange?.(selectedValue, current) ?? {}), - })) + }) as Record) } > @@ -480,12 +480,6 @@ function FleetCrudPage({ const statusBadge = (status?: string) => {status ?? '-'}; -const activeBadge = (isActive?: boolean) => ( - - {isActive === false ? 'Inactive' : 'Active'} - -); - const optionLabel = (options: { value: string; label: string }[], value?: string | null) => options.find((option) => option.value === value)?.label ?? value ?? '-'; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx index 6db31e931..5be9f1e56 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -1,19 +1,10 @@ import type { ColumnDef } from "@edr/ui-common"; -import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; -import { - Archive, - Circle, - CircleCheck, - CircleSlash, - Layers, - Link2, - Plus, - Wrench, - type LucideIcon, -} from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { Plus } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Navigate, useLocation } from "react-router-dom"; @@ -23,37 +14,20 @@ import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; -import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { useToast } from "@/hooks/use-toast"; import { - FLEET_SELECT_NONE, - getFleetResource, - getFleetSlugFromPath, - type FleetFormFieldDef, - type FleetResourceSlug, + FLEET_SELECT_NONE, + getFleetResource, + getFleetSlugFromPath, + type FleetFormFieldDef, + type FleetResourceSlug, } from "@/pages/fleet/config/resources"; import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; -const FLEET_STATUS_META: Record< - string, - { label: string; icon: LucideIcon; color: string } -> = { - AVAILABLE: { label: "Available", icon: CircleCheck, color: "edr-green" }, - ASSIGNED: { label: "Assigned", icon: Link2, color: "blue" }, - MAINTENANCE: { label: "Maintenance", icon: Wrench, color: "yellow" }, - OUT_OF_SERVICE: { label: "Out of service", icon: CircleSlash, color: "red" }, - RETIRED: { label: "Retired", icon: Archive, color: "gray" }, -}; - -const humanizeStatus = (status: string) => { - const text = status.replace(/_/g, " ").toLowerCase(); - return text.charAt(0).toUpperCase() + text.slice(1); -}; - const FleetResourcePage = () => { const location = useLocation(); const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; @@ -113,6 +87,9 @@ const FleetResourcePage = () => { const { data: yards = [], isLoading: yardsLoading } = useQuery( api.routes.yards.queryOptions(), ); + const { data: drivers = [] } = useQuery( + api.fleet.list.queryOptions({ input: { slug: "drivers" } }), + ); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); @@ -301,39 +278,6 @@ const FleetResourcePage = () => { const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; - const kpiItems = useMemo(() => { - const items = [ - { - label: `Total ${config?.label.toLowerCase() ?? ""}`, - value: allRows.length, - icon: Layers, - color: "edr-green", - }, - ]; - if (hasStatusColumn) { - const counts = new Map(); - for (const row of allRows) { - const status = String( - (row as unknown as Record).status ?? "", - ); - if (status) counts.set(status, (counts.get(status) ?? 0) + 1); - } - const top = [...counts.entries()] - .sort((a, b) => b[1] - a[1]) - .slice(0, 4); - for (const [status, count] of top) { - const meta = FLEET_STATUS_META[status]; - items.push({ - label: meta?.label ?? humanizeStatus(status), - value: count, - icon: meta?.icon ?? Circle, - color: meta?.color ?? "gray", - }); - } - } - return items.slice(0, 5); - }, [allRows, hasStatusColumn, config?.label]); - if (!config) { return ; } @@ -376,7 +320,7 @@ const FleetResourcePage = () => { const handleAssignDriver = async () => { if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return; try { - const selectedDriverRecord = (drivers as Array>).find( + const selectedDriverRecord = (drivers as unknown as Array>).find( (d) => String(d.id) === selectedDriver ); if (!selectedDriverRecord) return; @@ -384,6 +328,7 @@ const FleetResourcePage = () => { const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`; await update.mutateAsync({ + slug, id: String(assigningDriver.id), data: { assignedDriverId: selectedDriver, @@ -608,7 +553,7 @@ const FleetResourcePage = () => { clearable value={selectedDriver} onChange={(value) => setSelectedDriver(value || "")} - data={(drivers as Array>).map((driver) => ({ + data={(drivers as unknown as Array>).map((driver) => ({ value: String(driver.id || ""), label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`, }))} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 68ce805df..7842adfeb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -7,6 +7,7 @@ import { RefreshCw, Truck, } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -28,29 +29,17 @@ import { } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; - -type FirstMileStatus = "UNASSIGNED" | "ASSIGNED"; -type PickupStatus = "PAYMENT_PENDING" | "READY_FOR_PICKUP" | "PICKED_UP"; - -interface FirstMileJob { - id: string; - bookingRef: string; - customer: string; - pickup: string; - cargo: string; - status: FirstMileStatus; - pickupStatus: PickupStatus; - assignedVehicle: string | null; - // Booking info shown in the Assign / View Detail modals. - serviceType: string; - weight: string; - price: number; - destinationYard: string; - contactName: string; - contactPhone: string; - requestedDate: string; -} +import { + FIRST_MILE_STATUSES, + type FirstMileApiStatus, + type FirstMileRecord, + firstMileService, +} from "@/services/first-mile.service"; +import { bookingsService } from "@/services/bookings.service"; +import { vehiclesService } from "@/services/vehicles.service"; +import type { BookingDetail } from "@/types/booking"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -58,97 +47,60 @@ const formatPrice = (amount: number) => maximumFractionDigits: 2, })}`; -const PICKUP_STATUS_META: Record< - PickupStatus, - { label: string; color: string } -> = { +const STATUS_META: Record = { PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" }, - READY_FOR_PICKUP: { label: "Ready for Pickup", color: "blue" }, - PICKED_UP: { label: "Picked Up", color: "green" }, + READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" }, + IN_TRANSIT: { label: "In Transit", color: "indigo" }, + RECEIVED_TO_PORT: { label: "Received to Port", color: "green" }, }; -// Forward-only lifecycle: Payment Pending → Ready for Pickup → Picked Up. -const NEXT_PICKUP_STATUS: Partial> = { - PAYMENT_PENDING: "READY_FOR_PICKUP", - READY_FOR_PICKUP: "PICKED_UP", +const NEXT_STATUS: Partial> = { + PAYMENT_PENDING: "READY_TO_TRANSIT", + READY_TO_TRANSIT: "IN_TRANSIT", + IN_TRANSIT: "RECEIVED_TO_PORT", }; -// Single filter covering both the pickup lifecycle and assignment state. -type StatusFilter = - | "ALL" - | PickupStatus - | FirstMileStatus; +type AssignmentStatus = "ASSIGNED" | "UNASSIGNED"; +type StatusFilter = "ALL" | FirstMileApiStatus | AssignmentStatus; const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: "ALL", label: "All statuses" }, - { value: "PAYMENT_PENDING", label: "Payment Pending" }, - { value: "READY_FOR_PICKUP", label: "Ready for Pickup" }, - { value: "PICKED_UP", label: "Picked Up" }, + { value: "ALL", label: "All" }, + ...FIRST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })), { value: "ASSIGNED", label: "Assigned" }, { value: "UNASSIGNED", label: "Unassigned" }, ]; -// Placeholder data — replace with a real first-mile service once the API exists. -const PLACEHOLDER_JOBS: FirstMileJob[] = [ - { - id: "1", - bookingRef: "BK-10242", - customer: "Awash Trading PLC", - pickup: "Kera Warehouse, Addis Ababa", - cargo: "20ft container · Electronics", - status: "UNASSIGNED", - pickupStatus: "PAYMENT_PENDING", - assignedVehicle: null, - serviceType: "Door-to-terminal (First Mile)", - weight: "12.4 t", - price: 4200, - destinationYard: "Indode Dry Port", - contactName: "Selam Bekele", - contactPhone: "+251 911 234 567", - requestedDate: "2026-06-22", - }, - { - id: "2", - bookingRef: "BK-10239", - customer: "Dire Logistics", - pickup: "Factory Gate 4, Dire Dawa", - cargo: "Bulk · 18t Cement", - status: "ASSIGNED", - pickupStatus: "READY_FOR_PICKUP", - assignedVehicle: "Isuzu FVR (3-AA-45821)", - serviceType: "Door-to-terminal (First Mile)", - weight: "18.0 t", - price: 3000, - destinationYard: "Dire Dawa Terminal", - contactName: "Yonas Tadesse", - contactPhone: "+251 912 887 010", - requestedDate: "2026-06-21", - }, - { - id: "3", - bookingRef: "BK-10235", - customer: "Horizon Imports", - pickup: "Lebu Industrial Park, Addis Ababa", - cargo: "40ft container · Machinery", - status: "UNASSIGNED", - pickupStatus: "PICKED_UP", - assignedVehicle: null, - serviceType: "Door-to-terminal (First Mile)", - weight: "24.7 t", - price: 6500, - destinationYard: "Mojo Dry Port", - contactName: "Hanna Girma", - contactPhone: "+251 913 445 221", - requestedDate: "2026-06-23", - }, -]; +const vehicleLabel = (record: FirstMileRecord) => { + if (!record.vehicle) return null; + const v = record.vehicle; + return `${v.manufacturer} ${v.model} (${v.plateNumber})`; +}; -// Placeholder vehicle options — replace with the vehicles service. -const VEHICLE_OPTIONS = [ - { value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" }, - { value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" }, - { value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" }, -]; +const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId); + +// Map API record → display fields used in modals and trip slip +const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId; +const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; +const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; +const cargoDesc = (r: FirstMileRecord) => { + const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean); + if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`); + return parts.join(" · ") || "—"; +}; +const priceAmount = (r: FirstMileRecord) => + r.booking?.totalAmount ?? r.advancedPayment; +const destinationYardName = (r: FirstMileRecord) => + r.booking?.destinationYard?.name ?? "—"; +const contactPersonName = (r: FirstMileRecord) => + r.booking?.company?.contactPersonName ?? "—"; +const contactPhone = (r: FirstMileRecord) => + r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—"; +const requestedDate = (r: FirstMileRecord) => { + const d = r.booking?.scheduledDate; + return d ? new Date(d).toISOString().slice(0, 10) : "—"; +}; +const serviceTypeName = (r: FirstMileRecord) => + r.booking?.serviceType?.name ?? "—"; const InfoRow = ({ label, value }: { label: string; value: string }) => ( @@ -159,57 +111,51 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( ); -const BookingInfo = ({ job }: { job: FirstMileJob }) => ( +const BookingInfo = ({ record }: { record: FirstMileRecord }) => ( - {job.bookingRef} + {bookingRef(record)} - - {PICKUP_STATUS_META[job.pickupStatus].label} + + {STATUS_META[record.status].label} - {job.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + {isAssigned(record) ? "Assigned" : "Unassigned"} - - - - - - - - - - - + + + + + + + + + + ); -const tripSlipRows = (job: FirstMileJob): [string, string][] => [ - ["Customer", job.customer], - ["Service", job.serviceType], - ["Pickup location", job.pickup], - ["Destination yard", job.destinationYard], - ["Cargo", job.cargo], - ["Weight", job.weight], - ["Price", formatPrice(job.price)], - ["Vehicle", job.assignedVehicle ?? "Unassigned"], - ["Contact", `${job.contactName} · ${job.contactPhone}`], - ["Requested date", job.requestedDate], - ["Pickup status", PICKUP_STATUS_META[job.pickupStatus].label], +const tripSlipRows = (record: FirstMileRecord): [string, string][] => [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Pickup location", pickupLocation(record)], + ["Destination yard", destinationYardName(record)], + ["Cargo", cargoDesc(record)], + ["Price", formatPrice(priceAmount(record))], + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], ]; const SampleStamp = () => ( @@ -261,52 +207,34 @@ const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) {title} - - Name: - + Name: - - Signature: - + Signature: {stamp && ( - + {stamp} )} ); -const TripSlipDocument = ({ job }: { job: FirstMileJob }) => ( +const TripSlipDocument = ({ record }: { record: FirstMileRecord }) => ( EDR Freight - - First Mile Trip Slip - + First Mile Trip Slip - - {job.bookingRef} - - - {job.requestedDate} - + {bookingRef(record)} + {requestedDate(record)} - {tripSlipRows(job).map(([label, value]) => ( + {tripSlipRows(record).map(([label, value]) => ( ))} @@ -318,32 +246,22 @@ const TripSlipDocument = ({ job }: { job: FirstMileJob }) => ( ); -const escapeHtml = (value: string) => - value - .replace(/&/g, "&") - .replace(//g, ">"); +const escapeHtml = (v: string) => + v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (job: FirstMileJob) => { - const rows = tripSlipRows(job) - .map( - ([label, value]) => - `${escapeHtml(label)}${escapeHtml(value)}`, - ) +const buildTripSlipHtml = (record: FirstMileRecord) => { + const rows = tripSlipRows(record) + .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); - const signature = (title: string, withStamp: boolean) => ` + const sig = (title: string, withStamp: boolean) => `
${title}
Name:
Signature:
- ${ - withStamp - ? '
EDR FREIGHTAPPROVEDOPERATIONS
' - : "" - } + ${withStamp ? '
EDR FREIGHTAPPROVEDOPERATIONS
' : ""}
`; return ` - Trip Slip ${escapeHtml(job.bookingRef)} + Trip Slip ${escapeHtml(bookingRef(record))}

EDR Freight

First Mile Trip Slip

-
${escapeHtml(job.bookingRef)}${escapeHtml(job.requestedDate)}
+
${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
Acknowledgement
-
${signature("Driver", false)}${signature("Operator", true)}
+
${sig("Driver", false)}${sig("Operator", true)}
`; }; const FirstMilePage = () => { const { toast } = useToast(); + const qc = useQueryClient(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [jobs, setJobs] = useState(PLACEHOLDER_JOBS); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); const [rowSelection, setRowSelection] = useState>({}); @@ -388,13 +306,81 @@ const FirstMilePage = () => { const [bulkMode, setBulkMode] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); - const [tripSlipJob, setTripSlipJob] = useState(null); - const [activeJobId, setActiveJobId] = useState(null); + const [tripSlipRecord, setTripSlipRecord] = useState(null); + const [activeId, setActiveId] = useState(null); const [vehicleValue, setVehicleValue] = useState(null); - const activeJob = useMemo( - () => jobs.find((job) => job.id === activeJobId) ?? null, - [jobs, activeJobId], + const [acceptOpen, setAcceptOpen] = useState(false); + const [acceptStep, setAcceptStep] = useState<1 | 2>(1); + const [selectedBooking, setSelectedBooking] = useState(null); + const [acceptVehicleValue, setAcceptVehicleValue] = useState(null); + const [bookingSearch, setBookingSearch] = useState(""); + + const { data: listData, isLoading } = useQuery({ + queryKey: QUERY_KEYS.FIRST_MILE.list(), + queryFn: async () => { + const res = await firstMileService.list(); + return res.data; + }, + }); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "list"], + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE" }); + return res.data; + }, + }); + + const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({ + queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }), + queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }), + enabled: acceptOpen, + }); + const paidBookings = paidBookingsData?.items ?? []; + + const records = listData?.data ?? []; + + const vehicleOptions = useMemo( + () => + (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ + value: v.id, + label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, + })), + [vehiclesData], + ); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) => + firstMileService.update(id, data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + + const acceptMutation = useMutation({ + mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => { + const res = await firstMileService.accept(reference); + const created = res.data; + if (vehicleId) await firstMileService.update(created.id, { vehicleId }); + return created; + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + toast({ title: "Booking accepted", description: "First-mile leg created successfully." }); + closeAccept(); + }, + onError: () => { + toast({ title: "Accept failed", variant: "destructive" }); + }, + }); + + const activeRecord = useMemo( + () => records.find((r) => r.id === activeId) ?? null, + [records, activeId], ); const selectedIds = useMemo( @@ -402,160 +388,161 @@ const FirstMilePage = () => { [rowSelection], ); - const matchesStatusFilter = (job: FirstMileJob) => { + const filteredPaidBookings = useMemo(() => { + const term = bookingSearch.trim().toLowerCase(); + if (!term) return paidBookings; + return paidBookings.filter((b) => + [b.reference, b.company?.name, b.company?.companyName] + .join(" ") + .toLowerCase() + .includes(term), + ); + }, [paidBookings, bookingSearch]); + + const openAccept = () => { + setAcceptOpen(true); + setAcceptStep(1); + setSelectedBooking(null); + setAcceptVehicleValue(null); + setBookingSearch(""); + }; + + const closeAccept = () => { + setAcceptOpen(false); + setAcceptStep(1); + setSelectedBooking(null); + setAcceptVehicleValue(null); + setBookingSearch(""); + }; + + const handleAcceptConfirm = () => { + if (!selectedBooking) return; + acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue }); + }; + + const matchesFilter = (r: FirstMileRecord) => { switch (statusFilter) { - case "ALL": - return true; - case "ASSIGNED": - case "UNASSIGNED": - return job.status === statusFilter; - default: - return job.pickupStatus === statusFilter; + case "ALL": return true; + case "ASSIGNED": return isAssigned(r); + case "UNASSIGNED": return !isAssigned(r); + default: return r.status === statusFilter; } }; const statusCounts = useMemo(() => { const counts: Record = { - ALL: jobs.length, + ALL: records.length, PAYMENT_PENDING: 0, - READY_FOR_PICKUP: 0, - PICKED_UP: 0, + READY_TO_TRANSIT: 0, + IN_TRANSIT: 0, + RECEIVED_TO_PORT: 0, ASSIGNED: 0, UNASSIGNED: 0, }; - for (const job of jobs) { - counts[job.pickupStatus] += 1; - counts[job.status] += 1; + for (const r of records) { + counts[r.status] = (counts[r.status] ?? 0) + 1; + if (isAssigned(r)) counts.ASSIGNED += 1; + else counts.UNASSIGNED += 1; } return counts; - }, [jobs]); + }, [records]); - const filteredJobs = useMemo(() => { + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); - return jobs.filter((job) => { - if (!matchesStatusFilter(job)) return false; + return records.filter((r) => { + if (!matchesFilter(r)) return false; if (!term) return true; - return [job.bookingRef, job.customer, job.pickup, job.cargo] + return [bookingRef(r), customerName(r), pickupLocation(r), cargoDesc(r)] .join(" ") .toLowerCase() .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jobs, search, statusFilter]); + }, [records, search, statusFilter]); - const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize)); - const pagedJobs = useMemo(() => { + const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); + const pagedRecords = useMemo(() => { const start = pagination.pageIndex * pagination.pageSize; - return filteredJobs.slice(start, start + pagination.pageSize); - }, [filteredJobs, pagination.pageIndex, pagination.pageSize]); + return filteredRecords.slice(start, start + pagination.pageSize); + }, [filteredRecords, pagination]); - const openAssign = (jobId: string | null) => { - const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null; + const openAssign = (id: string | null) => { + const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; setBulkMode(false); - setActiveJobId(resolved); + setActiveId(resolved); setVehicleValue(null); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); setAssignOpen(true); }; - const openDetail = (jobId: string) => { - setActiveJobId(jobId); - setDetailOpen(true); - }; - const closeAssign = () => { setAssignOpen(false); setBulkMode(false); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); }; - const closeDetail = () => { - setDetailOpen(false); - setActiveJobId(null); - }; - const handleAssign = () => { if (!vehicleValue) { - toast({ - title: "Select a vehicle", - description: "Choose a vehicle to assign to this pickup.", - variant: "destructive", - }); + toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); return; } - const vehicleLabel = - VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue; - const targetIds = bulkMode ? selectedIds - : [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter( - (id): id is string => Boolean(id), - ); + : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const targetSet = new Set(targetIds); - setJobs((current) => - current.map((job) => - targetSet.has(job.id) - ? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel } - : job, - ), - ); + const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - toast({ - title: "Vehicle assigned", - description: bulkMode - ? `${targetIds.length} pickups → ${vehicleLabel}` - : vehicleLabel, - }); - if (bulkMode) setRowSelection({}); - closeAssign(); + Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + .then(() => { + toast({ + title: "Vehicle assigned", + description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel, + }); + if (bulkMode) setRowSelection({}); + closeAssign(); + }) + .catch(() => void 0); }; - const handleAdvanceStatus = (job: FirstMileJob) => { - const next = NEXT_PICKUP_STATUS[job.pickupStatus]; + const handleAdvanceStatus = (record: FirstMileRecord) => { + const next = NEXT_STATUS[record.status]; if (!next) return; - setJobs((current) => - current.map((item) => - item.id === job.id ? { ...item, pickupStatus: next } : item, - ), + updateMutation.mutate( + { id: record.id, data: { status: next } }, + { + onSuccess: () => + toast({ title: "Status updated", description: `${bookingRef(record)} → ${STATUS_META[next].label}` }), + }, ); - toast({ - title: "Status updated", - description: `${job.bookingRef} → ${PICKUP_STATUS_META[next].label}`, - }); }; - const handlePrintTripSlip = (job: FirstMileJob) => { - setTripSlipJob(job); + const handlePrintTripSlip = (record: FirstMileRecord) => { + setTripSlipRecord(record); setTripSlipOpen(true); }; const printTripSlip = () => { - if (!tripSlipJob) return; + if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); if (!win) { - toast({ - title: "Pop-up blocked", - description: "Allow pop-ups to print the trip slip.", - variant: "destructive", - }); + toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipJob)); + win.document.write(buildTripSlipHtml(tripSlipRecord)); win.document.close(); }; - const columns = useMemo((): ColumnDef[] => { + const columns = useMemo((): ColumnDef[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ @@ -567,9 +554,7 @@ const FirstMilePage = () => { table.toggleAllPageRowsSelected(e.currentTarget.checked)} /> ), @@ -586,67 +571,54 @@ const FirstMilePage = () => { id: "bookingRef", header: "Booking", meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {row.original.bookingRef} - - ), + cell: ({ row }) => {bookingRef(row.original)}, }, { id: "customer", header: "Customer", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.customer, + cell: ({ row }) => customerName(row.original), }, { id: "pickup", header: "Pickup", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.pickup, + cell: ({ row }) => pickupLocation(row.original), }, { id: "cargo", header: "Cargo", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.cargo, + cell: ({ row }) => cargoDesc(row.original), }, { id: "price", header: "Price", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.price), + cell: ({ row }) => formatPrice(priceAmount(row.original)), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - row.original.assignedVehicle ?? , - }, - { - id: "pickupStatus", - header: "Status", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => { - const meta = PICKUP_STATUS_META[row.original.pickupStatus]; - return ( - - {meta.label} - - ); - }, + cell: ({ row }) => vehicleLabel(row.original) ?? , }, { id: "status", + header: "Status", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return {meta.label}; + }, + }, + { + id: "assignment", header: "Assignment", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( - - {row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + {isAssigned(row.original) ? "Assigned" : "Unassigned"} ), }, @@ -655,11 +627,9 @@ const FirstMilePage = () => { header: "Actions", meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { - const isAssigned = row.original.status === "ASSIGNED"; - const nextStatus = NEXT_PICKUP_STATUS[row.original.pickupStatus]; - const canPrintTripSlip = - row.original.pickupStatus === "READY_FOR_PICKUP" || - row.original.pickupStatus === "PICKED_UP"; + const assigned = isAssigned(row.original); + const nextStatus = NEXT_STATUS[row.original.status]; + const canPrint = row.original.status !== "PAYMENT_PENDING"; return ( @@ -674,21 +644,19 @@ const FirstMilePage = () => { disabled={!nextStatus} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus - ? `Mark ${PICKUP_STATUS_META[nextStatus].label}` - : "Picked Up"} + {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} } - disabled={isAssigned} + disabled={assigned} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!isAssigned} + disabled={!assigned} onClick={() => openAssign(row.original.id)} > Reassign @@ -696,11 +664,11 @@ const FirstMilePage = () => { } - onClick={() => openDetail(row.original.id)} + onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }} > View detail - {canPrintTripSlip && ( + {canPrint && ( } onClick={() => handlePrintTripSlip(row.original)} @@ -715,18 +683,12 @@ const FirstMilePage = () => { }, }, ]; - }, []); - - const tableStatus = "success" as const; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicleOptions]); return ( - + @@ -739,18 +701,11 @@ const FirstMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -768,7 +723,7 @@ const FirstMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - {option.label} ({statusCounts[option.value]}) + {option.label} ({statusCounts[option.value] ?? 0}) ); })} @@ -778,14 +733,14 @@ const FirstMilePage = () => { { onRowSelectionChange: setRowSelection, }} containerClassName="border-0 shadow-none bg-transparent" - footer={({ table, pagination: footerPagination }) => ( - + footer={({ table, pagination: fp }) => ( + )} /> + {/* Assign / Reassign modal */} { {bulkMode ? ( Assigning a vehicle to{" "} - - {selectedIds.length} - {" "} + {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "pickup" : "pickups"}. - ) : activeJob ? ( - + ) : activeRecord ? ( + ) : ( - - No unassigned pickups available. - + No unassigned pickups available. )} + + + + + + + + + )} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index cd5191c5c..a5ad12c5b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -7,6 +7,7 @@ import { RefreshCw, Truck, } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { @@ -28,29 +29,15 @@ import { } from "@mantine/core"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useToast } from "@/hooks/use-toast"; - -type LastMileStatus = "UNASSIGNED" | "ASSIGNED"; -type DeliveryStatus = "PAYMENT_PENDING" | "READY_TO_TRANSIT" | "DELIVERED"; - -interface LastMileJob { - id: string; - bookingRef: string; - customer: string; - destination: string; - cargo: string; - status: LastMileStatus; - deliveryStatus: DeliveryStatus; - assignedVehicle: string | null; - // Booking info shown in the Assign / View Detail modals. - serviceType: string; - weight: string; - price: number; - originYard: string; - contactName: string; - contactPhone: string; - requestedDate: string; -} +import { + LAST_MILE_STATUSES, + type LastMileApiStatus, + type LastMileRecord, + lastMileService, +} from "@/services/last-mile.service"; +import { vehiclesService } from "@/services/vehicles.service"; const formatPrice = (amount: number) => `ETB ${amount.toLocaleString("en-US", { @@ -58,158 +45,108 @@ const formatPrice = (amount: number) => maximumFractionDigits: 2, })}`; -const DELIVERY_STATUS_META: Record< - DeliveryStatus, - { label: string; color: string } -> = { +const STATUS_META: Record = { PAYMENT_PENDING: { label: "Payment Pending", color: "yellow" }, READY_TO_TRANSIT: { label: "Ready to Transit", color: "blue" }, + IN_TRANSIT: { label: "In Transit", color: "indigo" }, DELIVERED: { label: "Delivered", color: "green" }, }; -// Forward-only lifecycle: Payment Pending → Ready to Transit → Delivered. -const NEXT_DELIVERY_STATUS: Partial> = { +const NEXT_STATUS: Partial> = { PAYMENT_PENDING: "READY_TO_TRANSIT", - READY_TO_TRANSIT: "DELIVERED", + READY_TO_TRANSIT: "IN_TRANSIT", + IN_TRANSIT: "DELIVERED", }; -// Single filter covering both the delivery lifecycle and assignment state. -type StatusFilter = - | "ALL" - | DeliveryStatus - | LastMileStatus; +type AssignmentStatus = "ASSIGNED" | "UNASSIGNED"; +type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus; const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: "ALL", label: "All statuses" }, - { value: "PAYMENT_PENDING", label: "Payment Pending" }, - { value: "READY_TO_TRANSIT", label: "Ready to Transit" }, - { value: "DELIVERED", label: "Delivered" }, + { value: "ALL", label: "All" }, + ...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })), { value: "ASSIGNED", label: "Assigned" }, { value: "UNASSIGNED", label: "Unassigned" }, ]; -// Placeholder data — replace with a real last-mile service once the API exists. -const PLACEHOLDER_JOBS: LastMileJob[] = [ - { - id: "1", - bookingRef: "BK-10241", - customer: "Awash Trading PLC", - destination: "Bole Sub-city, Addis Ababa", - cargo: "20ft container · Electronics", - status: "UNASSIGNED", - deliveryStatus: "PAYMENT_PENDING", - assignedVehicle: null, - serviceType: "Door-to-door (Last Mile)", - weight: "12.4 t", - price: 4500, - originYard: "Indode Dry Port", - contactName: "Selam Bekele", - contactPhone: "+251 911 234 567", - requestedDate: "2026-06-22", - }, - { - id: "2", - bookingRef: "BK-10238", - customer: "Dire Logistics", - destination: "Industry Zone, Dire Dawa", - cargo: "Bulk · 18t Cement", - status: "ASSIGNED", - deliveryStatus: "READY_TO_TRANSIT", - assignedVehicle: "Isuzu FVR (3-AA-45821)", - serviceType: "Terminal-to-door (Last Mile)", - weight: "18.0 t", - price: 3200, - originYard: "Dire Dawa Terminal", - contactName: "Yonas Tadesse", - contactPhone: "+251 912 887 010", - requestedDate: "2026-06-21", - }, - { - id: "3", - bookingRef: "BK-10233", - customer: "Horizon Imports", - destination: "Kality Terminal, Addis Ababa", - cargo: "40ft container · Machinery", - status: "UNASSIGNED", - deliveryStatus: "DELIVERED", - assignedVehicle: null, - serviceType: "Door-to-door (Last Mile)", - weight: "24.7 t", - price: 6800, - originYard: "Mojo Dry Port", - contactName: "Hanna Girma", - contactPhone: "+251 913 445 221", - requestedDate: "2026-06-23", - }, -]; +const vehicleLabel = (record: LastMileRecord) => { + if (!record.vehicle) return null; + const v = record.vehicle; + return `${v.manufacturer} ${v.model} (${v.plateNumber})`; +}; -// Placeholder vehicle options — replace with the vehicles service. -const VEHICLE_OPTIONS = [ - { value: "isuzu-fvr-45821", label: "Isuzu FVR (3-AA-45821)" }, - { value: "sino-howo-12044", label: "Sinotruk Howo (3-AA-12044)" }, - { value: "mercedes-actros-90113", label: "Mercedes Actros (3-AA-90113)" }, -]; +const isAssigned = (record: LastMileRecord) => Boolean(record.vehicleId); + +const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId; +const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; +const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; +const cargoDesc = (r: LastMileRecord) => { + const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean); + if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`); + return parts.join(" · ") || "—"; +}; +const priceAmount = (r: LastMileRecord) => + r.booking?.totalAmount ?? r.advancedPayment; +const originYardName = (r: LastMileRecord) => + r.booking?.originYard?.name ?? "—"; +const contactPersonName = (r: LastMileRecord) => + r.booking?.company?.contactPersonName ?? "—"; +const contactPhone = (r: LastMileRecord) => + r.booking?.company?.contactPersonPhone ?? r.booking?.company?.phone ?? "—"; +const requestedDate = (r: LastMileRecord) => { + const d = r.booking?.scheduledDate; + return d ? new Date(d).toISOString().slice(0, 10) : "—"; +}; +const serviceTypeName = (r: LastMileRecord) => + r.booking?.serviceType?.name ?? "—"; const InfoRow = ({ label, value }: { label: string; value: string }) => ( - - {label} - + {label} {value} ); -const BookingInfo = ({ job }: { job: LastMileJob }) => ( +const BookingInfo = ({ record }: { record: LastMileRecord }) => ( - {job.bookingRef} + {bookingRef(record)} - - {DELIVERY_STATUS_META[job.deliveryStatus].label} + + {STATUS_META[record.status].label} - - {job.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + {isAssigned(record) ? "Assigned" : "Unassigned"} - - - - - - - - - - - + + + + + + + + + + ); -const tripSlipRows = (job: LastMileJob): [string, string][] => [ - ["Customer", job.customer], - ["Service", job.serviceType], - ["Origin yard", job.originYard], - ["Destination", job.destination], - ["Cargo", job.cargo], - ["Weight", job.weight], - ["Price", formatPrice(job.price)], - ["Vehicle", job.assignedVehicle ?? "Unassigned"], - ["Contact", `${job.contactName} · ${job.contactPhone}`], - ["Requested date", job.requestedDate], - ["Delivery status", DELIVERY_STATUS_META[job.deliveryStatus].label], +const tripSlipRows = (record: LastMileRecord): [string, string][] => [ + ["Customer", customerName(record)], + ["Service", serviceTypeName(record)], + ["Origin yard", originYardName(record)], + ["Destination", deliveryLocation(record)], + ["Cargo", cargoDesc(record)], + ["Price", formatPrice(priceAmount(record))], + ["Vehicle", vehicleLabel(record) ?? "Unassigned"], + ["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`], + ["Requested date", requestedDate(record)], + ["Status", STATUS_META[record.status].label], ]; const SampleStamp = () => ( @@ -241,15 +178,9 @@ const SampleStamp = () => ( lineHeight: 1.1, }} > - - EDR FREIGHT - - - APPROVED - - - OPERATIONS - + EDR FREIGHT + APPROVED + OPERATIONS @@ -257,56 +188,36 @@ const SampleStamp = () => ( const SignatureBlock = ({ title, stamp }: { title: string; stamp?: ReactNode }) => ( - - {title} - + {title} - - Name: - + Name: - - Signature: - + Signature: {stamp && ( - + {stamp} )} ); -const TripSlipDocument = ({ job }: { job: LastMileJob }) => ( +const TripSlipDocument = ({ record }: { record: LastMileRecord }) => ( EDR Freight - - Last Mile Trip Slip - + Last Mile Trip Slip - - {job.bookingRef} - - - {job.requestedDate} - + {bookingRef(record)} + {requestedDate(record)} - {tripSlipRows(job).map(([label, value]) => ( + {tripSlipRows(record).map(([label, value]) => ( ))} @@ -318,32 +229,22 @@ const TripSlipDocument = ({ job }: { job: LastMileJob }) => ( ); -const escapeHtml = (value: string) => - value - .replace(/&/g, "&") - .replace(//g, ">"); +const escapeHtml = (v: string) => + v.replace(/&/g, "&").replace(//g, ">"); -const buildTripSlipHtml = (job: LastMileJob) => { - const rows = tripSlipRows(job) - .map( - ([label, value]) => - `${escapeHtml(label)}${escapeHtml(value)}`, - ) +const buildTripSlipHtml = (record: LastMileRecord) => { + const rows = tripSlipRows(record) + .map(([l, v]) => `${escapeHtml(l)}${escapeHtml(v)}`) .join(""); - const signature = (title: string, withStamp: boolean) => ` + const sig = (title: string, withStamp: boolean) => `
${title}
Name:
Signature:
- ${ - withStamp - ? '
EDR FREIGHTAPPROVEDOPERATIONS
' - : "" - } + ${withStamp ? '
EDR FREIGHTAPPROVEDOPERATIONS
' : ""}
`; return ` - Trip Slip ${escapeHtml(job.bookingRef)} + Trip Slip ${escapeHtml(bookingRef(record))}

EDR Freight

Last Mile Trip Slip

-
${escapeHtml(job.bookingRef)}${escapeHtml(job.requestedDate)}
+
${escapeHtml(bookingRef(record))}${escapeHtml(requestedDate(record))}
${rows}
Acknowledgement
-
${signature("Driver", false)}${signature("Operator", true)}
+
${sig("Driver", false)}${sig("Operator", true)}
`; }; const LastMilePage = () => { const { toast } = useToast(); + const qc = useQueryClient(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [jobs, setJobs] = useState(PLACEHOLDER_JOBS); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("ALL"); const [rowSelection, setRowSelection] = useState>({}); @@ -388,13 +289,51 @@ const LastMilePage = () => { const [bulkMode, setBulkMode] = useState(false); const [detailOpen, setDetailOpen] = useState(false); const [tripSlipOpen, setTripSlipOpen] = useState(false); - const [tripSlipJob, setTripSlipJob] = useState(null); - const [activeJobId, setActiveJobId] = useState(null); + const [tripSlipRecord, setTripSlipRecord] = useState(null); + const [activeId, setActiveId] = useState(null); const [vehicleValue, setVehicleValue] = useState(null); - const activeJob = useMemo( - () => jobs.find((job) => job.id === activeJobId) ?? null, - [jobs, activeJobId], + const { data: listData, isLoading } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE.list(), + queryFn: async () => { + const res = await lastMileService.list(); + return res.data; + }, + }); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "list"], + queryFn: async () => { + const res = await vehiclesService.getAll({ status: "ACTIVE" }); + return res.data; + }, + }); + + const records = listData?.data ?? []; + + const vehicleOptions = useMemo( + () => + (Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => ({ + value: v.id, + label: `${v.manufacturer} ${v.model} (${v.plateNumber})`, + })), + [vehiclesData], + ); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: { status?: LastMileApiStatus; vehicleId?: string | null } }) => + lastMileService.update(id, data), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); + }, + onError: () => { + toast({ title: "Update failed", variant: "destructive" }); + }, + }); + + const activeRecord = useMemo( + () => records.find((r) => r.id === activeId) ?? null, + [records, activeId], ); const selectedIds = useMemo( @@ -402,160 +341,129 @@ const LastMilePage = () => { [rowSelection], ); - const matchesStatusFilter = (job: LastMileJob) => { + const matchesFilter = (r: LastMileRecord) => { switch (statusFilter) { - case "ALL": - return true; - case "ASSIGNED": - case "UNASSIGNED": - return job.status === statusFilter; - default: - return job.deliveryStatus === statusFilter; + case "ALL": return true; + case "ASSIGNED": return isAssigned(r); + case "UNASSIGNED": return !isAssigned(r); + default: return r.status === statusFilter; } }; const statusCounts = useMemo(() => { const counts: Record = { - ALL: jobs.length, + ALL: records.length, PAYMENT_PENDING: 0, READY_TO_TRANSIT: 0, + IN_TRANSIT: 0, DELIVERED: 0, ASSIGNED: 0, UNASSIGNED: 0, }; - for (const job of jobs) { - counts[job.deliveryStatus] += 1; - counts[job.status] += 1; + for (const r of records) { + counts[r.status] = (counts[r.status] ?? 0) + 1; + if (isAssigned(r)) counts.ASSIGNED += 1; + else counts.UNASSIGNED += 1; } return counts; - }, [jobs]); + }, [records]); - const filteredJobs = useMemo(() => { + const filteredRecords = useMemo(() => { const term = search.trim().toLowerCase(); - return jobs.filter((job) => { - if (!matchesStatusFilter(job)) return false; + return records.filter((r) => { + if (!matchesFilter(r)) return false; if (!term) return true; - return [job.bookingRef, job.customer, job.destination, job.cargo] + return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)] .join(" ") .toLowerCase() .includes(term); }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [jobs, search, statusFilter]); + }, [records, search, statusFilter]); - const pageCount = Math.max(1, Math.ceil(filteredJobs.length / pagination.pageSize)); - const pagedJobs = useMemo(() => { + const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize)); + const pagedRecords = useMemo(() => { const start = pagination.pageIndex * pagination.pageSize; - return filteredJobs.slice(start, start + pagination.pageSize); - }, [filteredJobs, pagination.pageIndex, pagination.pageSize]); + return filteredRecords.slice(start, start + pagination.pageSize); + }, [filteredRecords, pagination]); - const openAssign = (jobId: string | null) => { - const resolved = jobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id ?? null; + const openAssign = (id: string | null) => { + const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; setBulkMode(false); - setActiveJobId(resolved); + setActiveId(resolved); setVehicleValue(null); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); setAssignOpen(true); }; - const openDetail = (jobId: string) => { - setActiveJobId(jobId); - setDetailOpen(true); - }; - const closeAssign = () => { setAssignOpen(false); setBulkMode(false); - setActiveJobId(null); + setActiveId(null); setVehicleValue(null); }; - const closeDetail = () => { - setDetailOpen(false); - setActiveJobId(null); - }; - const handleAssign = () => { if (!vehicleValue) { - toast({ - title: "Select a vehicle", - description: "Choose a vehicle to assign to this delivery.", - variant: "destructive", - }); + toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" }); return; } - const vehicleLabel = - VEHICLE_OPTIONS.find((option) => option.value === vehicleValue)?.label ?? vehicleValue; - const targetIds = bulkMode ? selectedIds - : [activeJobId ?? filteredJobs.find((job) => job.status === "UNASSIGNED")?.id].filter( - (id): id is string => Boolean(id), - ); + : [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id)); if (!targetIds.length) return; - const targetSet = new Set(targetIds); - setJobs((current) => - current.map((job) => - targetSet.has(job.id) - ? { ...job, status: "ASSIGNED", assignedVehicle: vehicleLabel } - : job, - ), - ); + const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue; - toast({ - title: "Vehicle assigned", - description: bulkMode - ? `${targetIds.length} deliveries → ${vehicleLabel}` - : vehicleLabel, - }); - if (bulkMode) setRowSelection({}); - closeAssign(); + Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } }))) + .then(() => { + toast({ + title: "Vehicle assigned", + description: bulkMode ? `${targetIds.length} deliveries → ${selectedLabel}` : selectedLabel, + }); + if (bulkMode) setRowSelection({}); + closeAssign(); + }) + .catch(() => void 0); }; - const handleAdvanceStatus = (job: LastMileJob) => { - const next = NEXT_DELIVERY_STATUS[job.deliveryStatus]; + const handleAdvanceStatus = (record: LastMileRecord) => { + const next = NEXT_STATUS[record.status]; if (!next) return; - setJobs((current) => - current.map((item) => - item.id === job.id ? { ...item, deliveryStatus: next } : item, - ), + updateMutation.mutate( + { id: record.id, data: { status: next } }, + { + onSuccess: () => + toast({ title: "Status updated", description: `${bookingRef(record)} → ${STATUS_META[next].label}` }), + }, ); - toast({ - title: "Status updated", - description: `${job.bookingRef} → ${DELIVERY_STATUS_META[next].label}`, - }); }; - const handlePrintTripSlip = (job: LastMileJob) => { - setTripSlipJob(job); + const handlePrintTripSlip = (record: LastMileRecord) => { + setTripSlipRecord(record); setTripSlipOpen(true); }; const printTripSlip = () => { - if (!tripSlipJob) return; + if (!tripSlipRecord) return; const win = window.open("", "_blank", "width=820,height=920"); if (!win) { - toast({ - title: "Pop-up blocked", - description: "Allow pop-ups to print the trip slip.", - variant: "destructive", - }); + toast({ title: "Pop-up blocked", description: "Allow pop-ups to print the trip slip.", variant: "destructive" }); return; } - win.document.write(buildTripSlipHtml(tripSlipJob)); + win.document.write(buildTripSlipHtml(tripSlipRecord)); win.document.close(); }; - const columns = useMemo((): ColumnDef[] => { + const columns = useMemo((): ColumnDef[] => { const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; return [ @@ -567,9 +475,7 @@ const LastMilePage = () => { table.toggleAllPageRowsSelected(e.currentTarget.checked)} /> ), @@ -586,67 +492,54 @@ const LastMilePage = () => { id: "bookingRef", header: "Booking", meta: { headerClassName, cellClassName }, - cell: ({ row }) => ( - - {row.original.bookingRef} - - ), + cell: ({ row }) => {bookingRef(row.original)}, }, { id: "customer", header: "Customer", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.customer, + cell: ({ row }) => customerName(row.original), }, { id: "destination", header: "Destination", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.destination, + cell: ({ row }) => deliveryLocation(row.original), }, { id: "cargo", header: "Cargo", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.cargo, + cell: ({ row }) => cargoDesc(row.original), }, { id: "price", header: "Price", meta: { headerClassName, cellClassName }, - cell: ({ row }) => formatPrice(row.original.price), + cell: ({ row }) => formatPrice(priceAmount(row.original)), }, { id: "vehicle", header: "Vehicle", meta: { headerClassName, cellClassName }, - cell: ({ row }) => - row.original.assignedVehicle ?? , - }, - { - id: "deliveryStatus", - header: "Status", - meta: { headerClassName, cellClassName }, - cell: ({ row }) => { - const meta = DELIVERY_STATUS_META[row.original.deliveryStatus]; - return ( - - {meta.label} - - ); - }, + cell: ({ row }) => vehicleLabel(row.original) ?? , }, { id: "status", + header: "Status", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return {meta.label}; + }, + }, + { + id: "assignment", header: "Assignment", meta: { headerClassName, cellClassName }, cell: ({ row }) => ( - - {row.original.status === "ASSIGNED" ? "Assigned" : "Unassigned"} + + {isAssigned(row.original) ? "Assigned" : "Unassigned"} ), }, @@ -655,11 +548,9 @@ const LastMilePage = () => { header: "Actions", meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => { - const isAssigned = row.original.status === "ASSIGNED"; - const nextStatus = NEXT_DELIVERY_STATUS[row.original.deliveryStatus]; - const canPrintTripSlip = - row.original.deliveryStatus === "READY_TO_TRANSIT" || - row.original.deliveryStatus === "DELIVERED"; + const assigned = isAssigned(row.original); + const nextStatus = NEXT_STATUS[row.original.status]; + const canPrint = row.original.status !== "PAYMENT_PENDING"; return ( @@ -674,21 +565,19 @@ const LastMilePage = () => { disabled={!nextStatus} onClick={() => handleAdvanceStatus(row.original)} > - {nextStatus - ? `Mark ${DELIVERY_STATUS_META[nextStatus].label}` - : "Delivered"} + {nextStatus ? `Mark ${STATUS_META[nextStatus].label}` : STATUS_META[row.original.status].label} } - disabled={isAssigned} + disabled={assigned} onClick={() => openAssign(row.original.id)} > Assign } - disabled={!isAssigned} + disabled={!assigned} onClick={() => openAssign(row.original.id)} > Reassign @@ -696,11 +585,11 @@ const LastMilePage = () => { } - onClick={() => openDetail(row.original.id)} + onClick={() => { setActiveId(row.original.id); setDetailOpen(true); }} > View detail - {canPrintTripSlip && ( + {canPrint && ( } onClick={() => handlePrintTripSlip(row.original)} @@ -715,18 +604,12 @@ const LastMilePage = () => { }, }, ]; - }, []); - - const tableStatus = "success" as const; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vehicleOptions]); return ( - + @@ -739,18 +622,11 @@ const LastMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -768,7 +644,7 @@ const LastMilePage = () => { setPagination((p) => ({ ...p, pageIndex: 0 })); }} > - {option.label} ({statusCounts[option.value]}) + {option.label} ({statusCounts[option.value] ?? 0}) ); })} @@ -778,14 +654,14 @@ const LastMilePage = () => { { onRowSelectionChange: setRowSelection, }} containerClassName="border-0 shadow-none bg-transparent" - footer={({ table, pagination: footerPagination }) => ( - + footer={({ table, pagination: fp }) => ( + )} /> + {/* Assign / Reassign modal */} { {bulkMode ? ( Assigning a vehicle to{" "} - - {selectedIds.length} - {" "} + {selectedIds.length}{" "} selected {selectedIds.length === 1 ? "delivery" : "deliveries"}. - ) : activeJob ? ( - + ) : activeRecord ? ( + ) : ( - - No unassigned deliveries available. - + No unassigned deliveries available. )} ; - }, -); - export interface PhoneFieldProps { label?: string; value?: string; @@ -63,7 +51,7 @@ export function PhoneField({ label, value, onChange, - onBlur, + // onBlur, error, required, disabled, @@ -75,10 +63,10 @@ export function PhoneField({ required={required} error={error} styles={{ - label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 }, + label: { fontWeight: 600, fontSize: 14, color: "#10202F", }, }} > -
+
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 17fde6528..3628d7403 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,8 +1,33 @@ -import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { + Box, + Button, + Group, + Modal, + ScrollArea, + Stack, + Text, + Title, +} from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + ArrowRight, + Building2, + CheckCircle2, + FileText, + Globe2, + UploadCloud, + User, + UserCheck, +} from "lucide-react"; +import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; import useAuth from "@/hooks/useAuth"; +import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; +import NationalitySelect from "@/pages/settings/NationalitySelect"; +import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; import { api } from "@/services/api"; import type { CompanyNationality, @@ -12,12 +37,8 @@ import type { import { companiesService } from "@/services/companies.service"; import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; -import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; -import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; -import NationalitySelect from "@/pages/settings/NationalitySelect"; -import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; -/** Form steps shared by CompanyProfileForm and ForwarderForm. */ +/** Form steps rendered by CompanyProfileForm. */ type FormStep = | "company" | "personnel" @@ -34,6 +55,58 @@ const FORM_STEPS: FormStep[] = [ "additional", ]; +/** The full onboarding journey: the two pre-form phases + the form steps. */ +type WizardStep = "nationality" | "role" | FormStep; +const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS]; + +/** Icon + title + description shown in the global dialog header per step. */ +const STEP_META: Record< + WizardStep, + { icon: ReactNode; title: string; description: string } +> = { + nationality: { + icon: , + title: "Where is your company registered?", + description: "This determines the documents we'll ask you to provide.", + }, + role: { + icon: , + title: "What does your company do?", + description: + "Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.", + }, + company: { + icon: , + title: "Company Information", + description: "Tell us about your company and its registration details.", + }, + personnel: { + icon: , + title: "General Manager", + description: "Who is the general manager of the company?", + }, + contact: { + icon: , + title: "Contact Person", + description: "Who should we reach out to about this account?", + }, + poa: { + icon: , + title: "Power of Attorney", + description: "Optionally add a representative with power of attorney.", + }, + documents: { + icon: , + title: "Upload Documents", + description: "Provide the required company documents.", + }, + additional: { + icon: , + title: "Business License", + description: "Upload a business license for each operational profile.", + }, +}; + interface OnboardingWizardDialogProps { opened: boolean; /** Dismiss the dialog (user clicked the close icon). */ @@ -98,6 +171,9 @@ export default function OnboardingWizardDialog({ // Newly-selected business-license files per company_profile id. const [licenseFiles, setLicenseFiles] = useState>({}); const [startError, setStartError] = useState(null); + // Mirror of CompanyProfileForm's active step so the global header + progress + // pill can reflect it (the form no longer renders its own stepper). + const [formStep, setFormStep] = useState(resumeFormStep); // Saved profile data, for rehydrating the form fields after a refresh. const profileQuery = useQuery( @@ -164,6 +240,15 @@ export default function OnboardingWizardDialog({ api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); + // Mirror the form's step locally (for the header/pill) and persist it. + const handleStepChange = useCallback( + (step: string) => { + setFormStep(step as FormStep); + persistStep(step); + }, + [persistStep], + ); + // The company query may resolve AFTER this dialog mounts (it's kept mounted by // the gate), so the phase/roles/nationality initial state can be stale — a // draft that already exists would otherwise leave us stuck on the first @@ -239,12 +324,10 @@ export default function OnboardingWizardDialog({ existingFiles: p.licenseFiles ?? [], })); - const titleHint = - phase === "nationality" - ? "Where is your company registered?" - : phase === "role" - ? "Tell us what your company does to get started." - : "Set up your company profile to finish."; + // The active step across the whole journey, driving the header + progress pill. + const activeStep: WizardStep = phase === "form" ? formStep : phase; + const stepMeta = STEP_META[activeStep]; + const activeIdx = WIZARD_STEPS.indexOf(activeStep); const formProps = { documentSettingCode: documentSettingCode(effectiveNationality), @@ -257,7 +340,7 @@ export default function OnboardingWizardDialog({ hideFirstStepBack: true, initialStep: resumeFormStep, resyncOpen: opened, - onStepChange: persistStep, + onStepChange: handleStepChange, onSaveStep: saveStep, rehydrate: profileQuery.data ?? null, roleProfiles, @@ -279,63 +362,98 @@ export default function OnboardingWizardDialog({ keepMounted scrollAreaComponent={ScrollArea.Autosize} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} + styles={{ + header: { + alignItems:"flex-start" + }, + title: { + flex: 1 + } + }} title={ - - - Complete your onboarding - - - {titleHint} - + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + } > - {phase === "nationality" ? ( - - - - - ) : phase === "role" ? ( - - - {startError && ( - - {startError} - - )} - - - ) : ( - - )} + + + {phase === "nationality" ? ( + + + + + + + ) : phase === "role" ? ( + + + {startError && ( + + {startError} + + )} + + + + + + ) : ( + + )} + ); } -function RoleContinueBar({ - disabled, - loading, - onClick, -}: { - disabled: boolean; - loading?: boolean; - onClick: () => void; -}) { +/** + * Continuous progress pill: a single rounded track that fills left-to-right as + * the user advances, with faint ticks marking each step boundary. + */ +function ProgressPill({ current, total }: { current: number; total: number }) { + const pct = total > 0 ? ((current + 1) / total) * 100 : 0; return ( - + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/phone-field.css b/apps/edr-freight-web/portal/src/components/phone-field.css index 2fd037b99..3a0396ffd 100644 --- a/apps/edr-freight-web/portal/src/components/phone-field.css +++ b/apps/edr-freight-web/portal/src/components/phone-field.css @@ -11,9 +11,9 @@ .edr-phone-wrapper .PhoneInputCountry { margin: 0; padding: 0 10px; - height: 44px; - border: 1px solid #e6ecf2; - border-radius: 10px; + height: 2.25rem; + border: 0.0625rem solid #b0bfce; + border-radius: 6px; background: #fff; display: flex; align-items: center; diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index fc9f57a58..dce8aad63 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 6822124fc..ab7cb0f40 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,6 +1,5 @@ import { Alert, - Box, Button, Checkbox, Divider, @@ -10,7 +9,6 @@ import { Stack, Text, TextInput, - ThemeIcon, } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; @@ -18,13 +16,7 @@ import { AlertCircle, ArrowLeft, ArrowRight, - Building2, - CheckCircle2, - ChevronLeft, - FileText, - UploadCloud, - User, - UserCheck, + // UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -559,7 +551,6 @@ export default function CompanyProfileForm({ "documents", "additional", ]; - const totalSteps = stepOrder.length; const currentIdx = stepOrder.indexOf(step); /** Validate + persist the current step, returning whether we may advance. */ @@ -617,79 +608,8 @@ export default function CompanyProfileForm({ // selection); otherwise always available. const showBack = !(hideFirstStepBack && step === "company"); - const STEP_ICONS: Record = { - company: , - personnel: , - contact: , - poa: , - documents: , - additional: , - }; - - const STEP_TITLES: Record = { - company: "Company Information", - personnel: "General Manager", - contact: "Contact Person", - poa: "Power of Attorney (Optional)", - documents: "Upload Documents", - additional: "Business License", - }; - - const stepLabel = `Step ${currentIdx + 1} of ${totalSteps} — ${STEP_TITLES[step]}`; - return ( <> - - - - - - {stepOrder.map((key, i) => { - const done = i < currentIdx; - const active = i === currentIdx; - return done || active ? ( - - {done ? : STEP_ICONS[key]} - - ) : ( - - {STEP_ICONS[key]} - - ); - })} - - - - {stepLabel} - - - e.preventDefault()}> {step === "company" && ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx deleted file mode 100644 index e13ec2282..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ /dev/null @@ -1,580 +0,0 @@ -import { Alert, Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - Building2, - CheckCircle2, - ChevronLeft, - FileText, - UploadCloud, - User, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import type { AuthUser } from "@/types/auth"; -import type { CreateCompanyPayload } from "@/services/companies.service"; -import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; -import { SmartFileInput } from "@edr/ui-common"; -import { api } from "@/services/api"; -import RoleLicenseStep, { - type RoleLicenseProfile, -} from "@/components/onboarding/RoleLicenseStep"; - -type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional"; - -const forwarderSchema = z.object({ - companyName: z.string().min(1, "Company name is required"), - companyEmail: z.string().email("Invalid email address"), - companyPhone: z - .string() - .min(1, "Company phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - companyLocation: z.string().min(1, "Location is required"), - companyAddress: z.string().min(1, "Address is required"), - tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), - vatNumber: z.string().min(1, "VAT number is required").length(10, "VAT number must be exactly 10 digits"), - fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), - contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z - .string() - .min(1, "Contact person phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - generalManagerName: z.string().min(1, "GM name is required"), - generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z - .string() - .min(1, "GM phone is required") - .refine(isValidPhone, "Enter a valid phone number"), - poaName: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), - poaEmail: z.string().optional(), - poaLocation: z.string().optional(), -}); - -type FormData = z.infer; - -const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], - personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"], - poa: [], - documents: [], - additional: [], -}; - -function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { - return { - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: data.companyPhone, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - vatNumber: data.vatNumber, - fanNumber: data.fanNumber, - attributes: { - contactPersonName: data.contactPersonName, - contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - }, - }; -} - -/** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: ForwarderStep, d: FormData): Partial { - switch (step) { - case "company": - return { - companyName: d.companyName, - companyEmail: d.companyEmail, - companyPhone: d.companyPhone, - companyLocation: d.companyLocation, - companyAddress: d.companyAddress, - tin: d.tinNumber, - vatNumber: d.vatNumber, - fanNumber: d.fanNumber, - }; - case "personnel": - return { - contactPersonName: d.contactPersonName, - contactPersonPhone: d.contactPersonPhone, - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, - }; - case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; - default: - return {}; - } -} - -/** Seed the form from previously-saved profile data. */ -function toFormValues(p: ProfileResponse): FormData { - const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; - return { - companyName: p.companyName ?? "", - companyEmail: p.companyEmail ?? "", - companyPhone: p.companyPhone ?? "", - companyLocation: p.companyLocation ?? "", - companyAddress: p.companyAddress ?? "", - tinNumber: tin, - vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", - contactPersonName: p.contactPersonName ?? "", - contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", - poaName: p.poaName ?? "", - poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", - poaEmail: p.poaEmail ?? "", - poaLocation: p.poaLocation ?? "", - }; -} - -export default function ForwarderForm({ - documentSettingCode, - documentFiles: controlledFiles, - onDocumentFilesChange, - user, - onSubmit, - isPending, - onBack, - initialStep, - resyncOpen, - hideFirstStepBack, - onStepChange, - onSaveStep, - rehydrate, - roleProfiles, - licenseFiles, - onLicenseChange, -}: { - documentSettingCode: string; - documentFiles?: Record; - onDocumentFilesChange?: (files: Record) => void; - user: AuthUser; - onSubmit: (data: CreateCompanyPayload) => void; - isPending: boolean; - onBack: () => void; - /** Step to resume at (defaults to "company"). */ - initialStep?: ForwarderStep; - /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ - resyncOpen?: boolean; - /** Hide the Back button on the first step (onboarding can't go back to role pick). */ - hideFirstStepBack?: boolean; - /** Reports the active step so the parent can persist resume progress. */ - onStepChange?: (step: ForwarderStep) => void; - /** Persist the current step's data before advancing; returns an error to show. */ - onSaveStep?: ( - data: Partial, - ) => Promise<{ ok: true } | { ok: false; error: string }>; - /** Saved profile to seed the form with (rehydration after refresh). */ - rehydrate?: ProfileResponse | null; - /** Operational profiles for the final per-role license step. */ - roleProfiles?: RoleLicenseProfile[]; - /** Newly-selected license files per profile id. */ - licenseFiles?: Record; - onLicenseChange?: (value: Record) => void; -}) { - const [step, setStep] = useState(initialStep ?? "company"); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - - // Report each step change up so the wizard can persist it for resume. - useEffect(() => { - onStepChange?.(step); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [step]); - - // On reopen, jump to the furthest step reached so progress never resets. - const wasOpen = useRef(resyncOpen); - useEffect(() => { - if (resyncOpen && !wasOpen.current && initialStep) { - setStep(initialStep); - setSaveError(null); - } - wasOpen.current = resyncOpen; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [resyncOpen]); - const [internalFiles, setInternalFiles] = useState>({}); - const documentFiles = controlledFiles ?? internalFiles; - const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; - - const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), - ); - - const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ - resolver: zodResolver(forwarderSchema), - defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", - companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", - contactPersonName: "", contactPersonPhone: "", - generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", - poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "", - }, - // Rehydrate from previously-saved data (RHF re-syncs when `values` change). - values: rehydrate ? toFormValues(rehydrate) : undefined, - }); - - const hasDocuments = Boolean(uploadSetting?.fields?.length); - const totalSteps = 5; - - /** Validate + persist the current step, returning whether we may advance. */ - const saveCurrentStep = async (): Promise => { - setSaveError(null); - const isValid = await trigger(stepFields[step]); - if (!isValid) return false; - if (!onSaveStep) return true; - setSaving(true); - try { - const res = await onSaveStep(stepPayload(step, watch())); - if (!res.ok) { - setSaveError(res.error); - return false; - } - return true; - } finally { - setSaving(false); - } - }; - - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - - const nextStep = async () => { - if (step === "additional") { - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - if (step === "documents") { setStep("additional"); return; } - const ok = await saveCurrentStep(); - if (!ok) return; - setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); - }; - - const skipDocuments = () => setStep("additional"); - - const prevStep = () => { - setSaveError(null); - if (step === "company") onBack(); - else if (step === "personnel") setStep("company"); - else if (step === "poa") setStep("personnel"); - else if (step === "documents") setStep("poa"); - else setStep("documents"); - }; - - const showBack = !(hideFirstStepBack && step === "company"); - - const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [ - { key: "company", icon: }, - { key: "personnel", icon: }, - { key: "poa", icon: }, - { key: "documents", icon: }, - { key: "additional", icon: }, - ]; - - const STEP_LABELS: Record = { - company: `Step 1 of ${totalSteps} — Company Information`, - personnel: `Step 2 of ${totalSteps} — Personnel Details`, - poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, - documents: `Step 4 of ${totalSteps} — Upload Documents`, - additional: `Step 5 of ${totalSteps} — Business License`, - }; - - const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"]; - const currentIdx = stepOrder.indexOf(step); - - return ( - <> - - - - - - {STEPS.map(({ key, icon }, i) => { - const done = i < currentIdx; - const active = i === currentIdx; - return done || active ? ( - - {done ? : icon} - - ) : ( - - {icon} - - ); - })} - - - - {STEP_LABELS[step]} - - - - e.preventDefault()}> - - {step === "company" && ( - <> - - - - - - - - - - - - - - - - )} - - {step === "personnel" && ( - <> - Contact Person - - - - - - - - General Manager - - - - - - - )} - - {step === "poa" && ( - <> - - Power of Attorney details are optional. Fill them in if you have them, or skip to continue. - - - - - - - - - - - - )} - - {step === "documents" && ( - <> - {loadingDocuments ? ( - - - - ) : !uploadSetting ? ( - - No document requirements found for your account type. - - ) : ( - - )} - - )} - - {step === "additional" && ( - {})} - /> - )} - - {saveError && ( - } - title={step === "additional" ? "Business license required" : "Couldn't save this step"} - > - {saveError} - - )} - - - {showBack ? ( - - ) : ( - - )} - - {step === "documents" && ( - - )} - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx deleted file mode 100644 index 861fbbb75..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { - Box, - Group, - SimpleGrid, - Stack, - Text, - ThemeIcon, - UnstyledButton, -} from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowDownToLine, - ArrowUpFromLine, - Building2, - ChevronRight, -} from "lucide-react"; -import { useState } from "react"; - -import AuthLayout from "@/components/auth/AuthLayout"; -import useAuth from "@/hooks/useAuth"; -import { api } from "@/services/api"; -import type { CreateCompanyPayload } from "@/services/companies.service"; -import { companiesService } from "@/services/companies.service"; -import CompanyProfileForm from "./CompanyProfileForm"; -import DjiboutiAgentForm from "./DjiboutiAgentForm"; -import ForwarderForm from "./ForwarderForm"; -import TransporterForm from "./TransporterForm"; -import type { OnboardingUserType } from "./types"; - -const USER_TYPE_CARDS: { - id: OnboardingUserType; - label: string; - description: string; - icon: React.ReactNode; -}[] = [ - { - id: "importer", - label: "Importer", - description: "Import goods into Ethiopia via the railway corridor.", - icon: , - }, - { - id: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, - { - id: "freight-forwarder-et", - label: "Freight Forwarder (Ethiopia)", - description: "Ethiopian freight forwarding company handling client cargo.", - icon: , - }, - // { - // id: "freight-forwarder-dj", - // label: "FF Agent (Djibouti)", - // description: "Djibouti-based agent coordinating cross-border logistics.", - // icon: , - // }, - // { - // id: "transporter", - // label: "Transporter", - // description: "Trucking company providing first/last-mile services.", - // icon: , - // }, - ]; - -const USER_TYPE_LEFT_MAP: Record< - OnboardingUserType, - { badge: string; title: string; description: string } -> = { - importer: { - badge: "Importer Registration", - title: "Register as an Importer", - description: - "Set up your company profile to manage imports, track shipments, and streamline customs clearance across the Ethiopia-Djibouti corridor.", - }, - exporter: { - badge: "Exporter Registration", - title: "Register as an Exporter", - description: - "Set up your company profile to manage exports, coordinate outbound logistics, and access rail transport services.", - }, - "freight-forwarder-et": { - badge: "Freight Forwarder Registration (Ethiopia)", - title: "Register Your Forwarding Company", - description: - "Complete your company profile and Power of Attorney to handle cargo on behalf of importers and exporters.", - }, - "freight-forwarder-dj": { - badge: "FF Agent Registration (Djibouti)", - title: "Register as a Djibouti Agent", - description: - "Register your company details and representative information to coordinate cross-border freight operations.", - }, - transporter: { - badge: "Transporter Registration", - title: "Register Your Transport Services", - description: - "Provide your vehicle and fleet details to offer first-mile and last-mile trucking services integrated with rail.", - }, -}; - -const PREFLIGHT_LEFT = { - badge: "Get Started", - title: "Choose your account type", - description: - "Select the profile that best matches your role in the logistics chain. Each account type provides a tailored onboarding experience.", - features: [ - "Importers & Exporters", - "Freight Forwarders (Ethiopia & Djibouti)", - "Transporters & Fleet Operators", - ], - stats: { - label: "Active Customers", - value: "500+", - footer: "And growing", - progress: "w-[95%]", - }, -}; - -const DOCUMENT_SETTING_CODE_MAP: Record = { - importer: "company_onboarding_documents_customer", - exporter: "company_onboarding_documents_customer", - "freight-forwarder-et": "company_onboarding_documents_forwarder", - "freight-forwarder-dj": "company_onboarding_documents_forwarder_dj", - transporter: "company_onboarding_documents_transporter", -}; - -export default function OnboardingPage() { - const queryClient = useQueryClient(); - const { user } = useAuth(); - const [userType, setUserType] = useState(null); - const [documentFiles, setDocumentFiles] = useState< - Record - >({}); - - const COMPANY_TYPE_MAP: Record = { - importer: "customer", - exporter: "customer", - "freight-forwarder-et": "forwarder", - "freight-forwarder-dj": "forwarder", - transporter: "transporter", - }; - - const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), - onSuccess: async (data) => { - const hasFiles = Object.values(documentFiles).some( - (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), - ); - if (hasFiles) { - await companiesService.uploadDocuments(data.company.id, documentFiles); - } - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); - }, - }); - - if (!user) return null; - - const handleSubmit = (payload: CreateCompanyPayload) => { - const enriched: CreateCompanyPayload = { - ...payload, - companyType: COMPANY_TYPE_MAP[userType!], - }; - createCompanyMutation.mutate(enriched); - }; - - const handleSelectType = (type: OnboardingUserType) => setUserType(type); - const handleBack = () => setUserType(null); - - if (!userType) { - return ( - - - - - Select Account Type - - - Choose the account type that fits your role. - - - - - {USER_TYPE_CARDS.map((card) => ( - handleSelectType(card.id)} - className="group block rounded-lg shadow-lg! border! border-edr-border! bg-edr-card! p-5! text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft hover:shadow-[0_12px_28px_-14px_rgba(14,163,83,0.55)]" - > - - - {card.icon} - - - - {card.label} - - - {card.description} - - - - - - ))} - - - - ); - } - - const leftConfig = USER_TYPE_LEFT_MAP[userType]; - const leftProps = { - ...leftConfig, - features: - userType === "transporter" - ? [ - "Vehicle & fleet registration", - "TIN & FAN verification", - "First-mile / Last-mile eligibility", - ] - : userType === "freight-forwarder-dj" - ? [ - "Company details", - "Representative information", - "Cross-border operations", - ] - : [ - "Company registration details", - "Contact and management personnel", - "Power of Attorney (optional)", - ], - stats: { - label: "Active Customers", - value: "500+", - footer: "And growing", - progress: "w-[95%]", - }, - }; - - return ( - - {userType === "transporter" ? ( - - ) : userType === "freight-forwarder-dj" ? ( - - ) : userType === "freight-forwarder-et" ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 1d1dbb5c4..5b779847a 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -7,6 +7,8 @@ import RoleCard from "./RoleCard"; interface NationalitySelectProps { value: CompanyNationality | null; onChange: (next: CompanyNationality) => void; + /** Render only the option grid — the wizard supplies its own header/card. */ + embedded?: boolean; } /** @@ -18,7 +20,29 @@ interface NationalitySelectProps { export default function NationalitySelect({ value, onChange, + embedded = false, }: NationalitySelectProps) { + const grid = ( + + } + selected={value === "ethiopian"} + onClick={() => onChange("ethiopian")} + /> + } + selected={value === "foreign"} + onClick={() => onChange("foreign")} + /> + + ); + + if (embedded) return grid; + return ( @@ -28,23 +52,7 @@ export default function NationalitySelect({ This determines the documents we'll ask you to provide. - - - } - selected={value === "ethiopian"} - onClick={() => onChange("ethiopian")} - /> - } - selected={value === "foreign"} - onClick={() => onChange("foreign")} - /> - + {grid} ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx index d5196d024..ca4afb9b9 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -7,6 +7,8 @@ interface OnboardingRoleSelectProps { /** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */ value: string[]; onChange: (next: string[]) => void; + /** Render only the option grid — the wizard supplies its own header/card. */ + embedded?: boolean; } /** @@ -19,6 +21,7 @@ interface OnboardingRoleSelectProps { export default function OnboardingRoleSelect({ value, onChange, + embedded = false, }: OnboardingRoleSelectProps) { const selected = new Set(value); @@ -29,6 +32,23 @@ export default function OnboardingRoleSelect({ onChange([...next]); }; + const grid = ( + + {CUSTOMER_ROLES.map((role) => ( + toggleRole(role.type)} + /> + ))} + + ); + + if (embedded) return grid; + return ( @@ -39,19 +59,7 @@ export default function OnboardingRoleSelect({ Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license. - - - {CUSTOMER_ROLES.map((role) => ( - toggleRole(role.type)} - /> - ))} - + {grid} ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 1f49baf7c..995acc104 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -1,27 +1,26 @@ -import { useMemo, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; -import { - Card, - Group, - Stack, - Title, - Text, - TextInput, - Button, - Grid, -} from "@mantine/core"; -import { api } from "@/services/api"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; -import type { ProfileResponse } from "@/types/profile"; +import { api } from "@/services/api"; import type { - CreateCompanyPayload, - CompanyProfileInput, + CompanyProfileInput, + CreateCompanyPayload, } from "@/services/companies.service"; -import CompanyRolesCard from "./CompanyRolesCard"; +import type { ProfileResponse } from "@/types/profile"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Button, + Card, + Grid, + Group, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = z.object({ @@ -143,9 +142,7 @@ export default function TabCompanyProfile({ value={selectedRoles} onChange={setSelectedRoles} /> - ) : ( - profile && - )} + ) : null} {showForm && ( diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index e3b585596..a8f76a1bf 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -297,8 +297,11 @@ export const api = { getAvailableDays: endpoint< { originYardId?: string; destinationYardId?: string }, string[] - >("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) => - bookingsService.getAvailableDays({ originYardId, destinationYardId }), + >( + "train-scheduling", + "availableDays", + ({ originYardId, destinationYardId }) => + bookingsService.getAvailableDays({ originYardId, destinationYardId }), ), }, diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 856d417cc..e1de1889a 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,40 +1,40 @@ import { URL_CONSTANTS } from "@/constants/URLS"; +import type { + AuthUser, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, +} from "@/types/auth"; import { client } from "@/utils/api"; import { ApiResponse } from "@edr/types"; -import type { - AuthUser, - GenerateVerificationCodePayload, - LoginPayload, - LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, - SignupPayload, - SignupResponse, -} from "@/types/auth"; export const authService = { login: async (body: LoginPayload) => { - const res = await client.post>( + const res = await client.post( URL_CONSTANTS.AUTH.LOGIN, body, ); - return res.data.data; + return res.data; }, createUser: async (body: SignupPayload) => { - const res = await client.post>( + const res = await client.post> ( URL_CONSTANTS.USERS.SIGN_UP, body, ); - return res.data.data; + return res.data; }, getMyInfo: async () => { - const res = await client.get>( + const res = await client.get( URL_CONSTANTS.USERS.ME, ); - return res.data.data; + return res.data; }, generateVerificationCode: async (body: GenerateVerificationCodePayload) => { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 54dcea344..285e38bf0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -412,6 +412,17 @@ export class BookingsController { return this.service.create(dto); } + @Get(':id/usage') + @ApiOperation({ + summary: 'Check if booking is in use', + description: 'Returns list of modules/data that reference this booking' + }) + @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 404, description: 'Booking not found' }) + checkUsage(@Param('id') id: string) { + return this.service.checkBookingUsage(id); + } + @Get(':bookingRef') @ApiOperation({ summary: 'Get booking details by reference (no auth required)', @@ -423,17 +434,6 @@ export class BookingsController { return this.service.getByRef(ref); } - @Patch(':id') - @ApiOperation({ - summary: 'Update booking details', - description: 'Updates booking information for admin/agent operations' - }) - @ApiResponse({ status: 200, description: 'Booking updated successfully' }) - @ApiResponse({ status: 404, description: 'Booking not found' }) - update(@Param('id') id: string, @Body() dto: any) { - return this.service.update(id, dto); - } - @Patch(':bookingRef/modify') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -458,17 +458,17 @@ export class BookingsController { return this.service.delete(id); } - @Get(':id/usage') + @Patch(':id') @ApiOperation({ - summary: 'Check if booking is in use', - description: 'Returns list of modules/data that reference this booking' + summary: 'Update booking details', + description: 'Updates booking information for admin/agent operations' }) - @ApiResponse({ status: 200, description: 'Usage information retrieved' }) + @ApiResponse({ status: 200, description: 'Booking updated successfully' }) @ApiResponse({ status: 404, description: 'Booking not found' }) - checkUsage(@Param('id') id: string) { - return this.service.checkBookingUsage(id); + update(@Param('id') id: string, @Body() dto: any) { + return this.service.update(id, dto); } - + @Delete(':bookingRef') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 44a31c151..22c11b8f1 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -956,31 +956,47 @@ export class BookingsService { where: { bookingRef }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, - seats: { include: { seat: { include: { coach: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, paymentIntent: true, ticket: true, }, }); if (!booking) throw new NotFoundException('Booking not found'); return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalFare: booking.totalMinor / 100, adultCount: booking.adultCount, childCount: booking.childCount, - displayCurrency: booking.displayCurrency, displayTotalFare: booking.displayTotalMinor ? booking.displayTotalMinor / 100 : undefined, + totalMinor: booking.totalMinor, currency: 'ETB', + adultCount: booking.adultCount, childCount: booking.childCount, + displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined, bookingType: booking.bookingType, returnLegStatus: (booking as any).returnLegStatus ?? null, outboundBoardedAt: (booking as any).outboundBoardedAt ?? null, returnBoardedAt: (booking as any).returnBoardedAt ?? null, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, createdAt: booking.createdAt, schedule: { - number: booking.schedule.train.number, + id: booking.schedule.id, + trainNumber: booking.schedule.train.number, + trainName: booking.schedule.train.name, origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city }, destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city }, departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt, }, passengers: booking.seats?.map((bs: any) => ({ - fullName: bs.passengerName, category: bs.passengerCategory, verifaydaVerified: bs.verifaydaVerified, - seat: { number: bs.seat.label, coach: bs.seat.coach.label, class: bs.seat.coach.seatClass.name }, + fullName: bs.passengerName, + category: bs.passengerCategory, + leg: bs.leg ?? 1, + fareMinor: bs.fareMinor, + verifaydaVerified: bs.verifaydaVerified, + seat: { + id: bs.seat.id, + number: bs.seat.seatNumber, + coach: bs.seat.coach.number, + coachId: bs.seat.coach.id, + seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null, + }, })), payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined, + ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined, }; } diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 48b84c4b6..922d7409b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -190,9 +190,9 @@ export class GuestBookingService { displayCurrency, displayTotalMinor, bookingType: 'ONE_WAY', - userAgent: dto.deviceId, - // contactEmail: firstPassenger.email, // Temporarily disabled until migration - // contactPhone: firstPassenger.phone, // Temporarily disabled until migration + userAgent: dto.deviceId, + contactEmail: firstPassenger.email || null, + contactPhone: firstPassenger.phone || null, seats: { create: passengersData.map((p) => ({ seat: { connect: { id: p.seatId } }, @@ -384,6 +384,8 @@ export class GuestBookingService { returnSeatClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map((p) => ({ @@ -574,6 +576,8 @@ export class GuestBookingService { leg2DestinationStationId: dto.leg2DestinationStationId, leg2SeatClassId: leg2SeatClassId, userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => ({ @@ -785,6 +789,8 @@ export class GuestBookingService { returnLeg2SeatClassId: retL2ClassId, returnLegStatus: 'NEITHER_USED', userAgent: dto.deviceId, + contactEmail: passengersData[0]?.email || null, + contactPhone: passengersData[0]?.phone || null, seats: { create: [ ...passengersData.map(p => makeSeat(p, p.seatId, 1, dto.scheduleId, obL1Fare)), @@ -859,13 +865,16 @@ export class GuestBookingService { return { guestPassenger, userId: user.id, createdAccount: true }; } - const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - let guestEmail = firstPassenger.email || `guest-${uniqueId}@edr-platform.com`; - if (firstPassenger.email) { - const existing = await this.prisma.user.findUnique({ where: { email: firstPassenger.email } }); - if (existing) guestEmail = `guest-${uniqueId}@edr-platform.com`; + const uniqueId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + + let guestEmail = firstPassenger.email; + if (guestEmail) { + const existing = await this.prisma.user.findUnique({ where: { email: guestEmail } }); + if (existing) guestEmail = null; } - let guestPhone = firstPassenger.phone || null; + if (!guestEmail) guestEmail = `guest-${uniqueId}@edr-platform.com`; + + let guestPhone = firstPassenger.phone; if (guestPhone) { const existing = await this.prisma.user.findUnique({ where: { phone: guestPhone } }); if (existing) guestPhone = null; diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 4cf4ce9d3..63b675157 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -93,10 +93,20 @@ export class TicketsService { include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, + paymentIntent: true, }, }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + if (booking.status !== 'CONFIRMED') { + const paymentStatus = booking.paymentIntent?.status ?? null; + throw new BadRequestException( + `Payment not completed. Please complete your payment before accessing the ticket. ` + + `Booking status: ${booking.status}` + + (paymentStatus ? `. Payment status: ${paymentStatus}` : ''), + ); + } + // Build a compact multi-leg payload for the QR so gate scanners see all legs const legSummary = this.buildLegSummary(booking); const qrData = JSON.stringify({ diff --git a/apps/edr-passenger-web/portal/package.json b/apps/edr-passenger-web/portal/package.json index 8e063d85a..8323c3bb0 100644 --- a/apps/edr-passenger-web/portal/package.json +++ b/apps/edr-passenger-web/portal/package.json @@ -13,13 +13,17 @@ "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", - "@tanstack/react-query": "^5.59.0", "@hookform/resolvers": "^3.3.4", + "@tanstack/react-query": "^5.59.0", + "@types/qrcode": "^1.5.6", "axios": "^1.7.7", "clsx": "^2.1.1", "date-fns": "^3.0.0", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "lucide-react": "^0.446.0", "next": "^14.2.0", + "qrcode": "^1.5.4", "qrcode.react": "^3.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 1a36ac04b..f7cabc212 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -7,7 +7,7 @@ import { useBookingStore } from '@/lib/booking-store'; import { useMutation, useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import { useEffect, useState, useRef } from 'react'; -import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train } from 'lucide-react'; +import { CheckCircle, Download, Share2, Copy, Printer, Mail, Train, FileText } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { format } from 'date-fns'; @@ -26,6 +26,7 @@ export default function ConfirmationPage() { const router = useRouter(); const { bookingId, pnr, selectedSchedule, passengers, clearBooking } = useBookingStore(); const [copied, setCopied] = useState(false); + const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); const confirmMutation = useMutation({ @@ -69,8 +70,61 @@ export default function ConfirmationPage() { } }; - const handleDownloadTickets = () => { - alert('Ticket download will be available soon. Your tickets are displayed below.'); + const handleDownloadVoucher = async () => { + if (!_booking || !pnr) { + alert('Booking data not available. Please try again.'); + return; + } + + setIsGeneratingVoucher(true); + try { + console.log('📄 Generating voucher with data:', { _booking, pnr, selectedSchedule, passengers }); + + const { generateVoucherPDF } = await import('@/lib/generate-voucher'); + + const voucherData = { + bookingRef: pnr, + status: _booking.status || 'CONFIRMED', + passengers: passengers.map(p => ({ + fullName: p.name, + category: 'ADULT', + seat: p.seatNumber ? { + number: p.seatNumber, + coach: 'N/A', + seatClass: selectedSchedule?.selectedSeatClassName || 'Standard', + } : undefined, + })), + schedule: { + trainNumber: selectedSchedule?.trainNumber || 'N/A', + trainName: 'EDR Express', + origin: { + name: selectedSchedule?.origin || 'Origin', + code: 'ORG', + city: selectedSchedule?.origin || 'Origin', + }, + destination: { + name: selectedSchedule?.destination || 'Destination', + code: 'DST', + city: selectedSchedule?.destination || 'Destination', + }, + departureAt: selectedSchedule?.departureTime || new Date().toISOString(), + arrivalAt: selectedSchedule?.arrivalTime || new Date().toISOString(), + }, + totalMinor: _booking.totalMinor || passengers.reduce((sum) => sum + (selectedSchedule?.baseFareAdult || 0), 0), + currency: 'ETB', + bookingType: 'ONE_WAY', + createdAt: new Date().toISOString(), + }; + + console.log('📄 Voucher data prepared:', voucherData); + await generateVoucherPDF(voucherData); + console.log('✅ Voucher generated successfully'); + } catch (error) { + console.error('❌ Failed to generate voucher:', error); + alert(`Failed to generate voucher: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsGeneratingVoucher(false); + } }; const handlePrintTickets = () => { @@ -131,47 +185,63 @@ export default function ConfirmationPage() {
- {/* Trip Summary */} + {/* Trip Summary with QR Code */}
-
-
- +
+ {/* QR Code Section */} +
+ +

Scan at gate

-

Trip details

-
-
-
-
-

Train number

-

{selectedSchedule?.trainNumber}

-
-
-

Route

-

{selectedSchedule?.origin} → {selectedSchedule?.destination}

-
- {selectedSchedule?.selectedSeatClassName && ( -
-

Class

-

{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+ + {/* Trip Details */} +
+
+
+
- )} -
-
-
-

Departure

-

- {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} -

+

Trip details

-
-

Arrival

-

- {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} -

-
-
-

Duration

-

{selectedSchedule?.duration}

+
+
+
+

Train number

+

{selectedSchedule?.trainNumber}

+
+
+

Route

+

{selectedSchedule?.origin} → {selectedSchedule?.destination}

+
+ {selectedSchedule?.selectedSeatClassName && ( +
+

Class

+

{selectedSchedule.selectedSeatClassName.replace(/_/g, ' ')}

+
+ )} +
+
+
+

Departure

+

+ {selectedSchedule?.departureTime && format(new Date(selectedSchedule.departureTime), 'PPp')} +

+
+
+

Arrival

+

+ {selectedSchedule?.arrivalTime && format(new Date(selectedSchedule.arrivalTime), 'PPp')} +

+
+
+

Duration

+

{selectedSchedule?.duration}

+
+
@@ -184,62 +254,36 @@ export default function ConfirmationPage() { {passengers.map((passenger, index) => { const backendTicket = _booking?.ticket || null; const ticketNumber = backendTicket?.barcodePayload || `TKT-${bookingId?.slice(0, 8).toUpperCase()}-${(index + 1).toString().padStart(2, '0')}`; - const qrData = backendTicket?.qrPayload || JSON.stringify({ - pnr, - ticketNumber, - passengerName: passenger.name, - trainNumber: selectedSchedule?.trainNumber, - date: selectedSchedule?.departureTime, - }); return (
-
- {/* Ticket Info */} -
-
-
-

{passenger.name}

-

Passenger {index + 1}

-
- CONFIRMED -
- -
-
-

Ticket Number

-

{ticketNumber}

-
-
-

Date of Birth

-

{format(new Date(passenger.dateOfBirth), 'PP')}

-
-
-

Nationality

-

{passenger.nationality}

-
-
-

Seat

-

{passenger.seatNumber || 'Will be assigned'}

-
-
- -
-

- 📱 Show this QR code at the gate for boarding -

+ {/* Ticket Info */} +
+
+
+

{passenger.name}

+

Passenger {index + 1}

+ CONFIRMED
- - {/* QR Code */} -
- -

Scan at gate

+ +
+
+

Ticket Number

+

{ticketNumber}

+
+
+

Date of Birth

+

{format(new Date(passenger.dateOfBirth), 'PP')}

+
+
+

Nationality

+

{passenger.nationality}

+
+
+

Seat

+

{passenger.seatNumber || 'Will be assigned'}

+
@@ -249,13 +293,24 @@ export default function ConfirmationPage() {
{/* Action Buttons */} -
+
+ + +
+
+ ); + } + + const isPendingPayment = booking.status === 'PENDING_PAYMENT' || booking.status === 'DRAFT'; + const isConfirmed = booking.status === 'TICKETED' || booking.status === 'CONFIRMED'; + const isExpired = booking.status === 'EXPIRED'; + const isCancelled = booking.status === 'CANCELLED'; + + console.log('📊 Booking Status:', booking.status); + console.log('📊 isPendingPayment:', isPendingPayment); + console.log('📊 isConfirmed:', isConfirmed); + console.log('📊 isExpired:', isExpired); + console.log('📊 isCancelled:', isCancelled); + + const StatusBadge = () => { + const statusConfig = { + PENDING_PAYMENT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' }, + DRAFT: { color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', label: 'Pending Payment' }, + CONFIRMED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Confirmed' }, + TICKETED: { color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', label: 'Ticketed' }, + EXPIRED: { color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', label: 'Expired' }, + CANCELLED: { color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', label: 'Cancelled' }, + }; + + const config = statusConfig[booking.status as keyof typeof statusConfig] || statusConfig.DRAFT; + + return ( + + {isConfirmed && } + {config.label} + + ); + }; + + if (isPendingPayment && !isExpired) { + return ( +
+
+
+ +
+
+
+

Complete Payment

+

+ Booking Reference: {booking.bookingRef} +

+
+ +
+ + {booking.createdAt && ( +
+ + + Booking created on {format(new Date(booking.createdAt), 'PPpp')} + +
+ )} +
+ +
+ +
+ +
+

Trip Summary

+ +
+
+ Your Journey + {booking.passengers?.[0]?.seat?.seatClass && ( + + {booking.passengers[0].seat.seatClass} + + )} +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.origin?.name} +
+
+ {booking.schedule?.origin?.city} +
+
+ + {/* Journey Info */} +
+
+
+ + + + Train {booking.schedule?.trainNumber} +
+ {booking.schedule?.trainName && ( + + {booking.schedule.trainName} + + )} +
+
+ + {/* Destination */} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.destination?.name} +
+
+ {booking.schedule?.destination?.city} +
+
+
+
+ +
+
+ + + {booking.passengers?.length || 0} Passenger(s) + +
+
+ {booking.passengers?.map((passenger: any, idx: number) => ( +
+
+
{passenger.fullName}
+
+ {passenger.category} • Coach {passenger.seat?.coach} +
+
+
+
+ Seat {passenger.seat?.number} +
+
+ {passenger.seat?.seatClass} +
+
+
+ ))} +
+
+
+ +
+

Select Payment Method

+ + {paymentMethods && Array.isArray(paymentMethods) && paymentMethods.length > 0 ? ( +
+ {paymentMethods.map((method: any) => ( + + ))} +
+ ) : ( +
+ No payment methods available +
+ )} +
+ + +
+ +
+
+

Order Summary

+ +
+
+ Subtotal ({booking.adultCount} Adult{booking.adultCount > 1 ? 's' : ''}{booking.childCount > 0 ? `, ${booking.childCount} Child${booking.childCount > 1 ? 'ren' : ''}` : ''}) + + {booking.currency} {((booking.totalMinor || 0) / 100).toFixed(2)} + +
+
+ +
+
+ Total + + {booking.displayCurrency} {((booking.displayTotalMinor || booking.totalMinor || 0) / 100).toFixed(2)} + +
+
+
+
+
+
+
+
+ ); + } + + if (isConfirmed || isCancelled || isExpired) { + return ( +
+
+
+ +
+
+ {isConfirmed ? ( + + ) : ( + + )} +
+

+ {isConfirmed ? 'Booking Confirmed!' : isCancelled ? 'Booking Cancelled' : 'Booking Expired'} +

+

+ {isConfirmed ? 'Your tickets have been generated successfully' : isCancelled ? 'This booking has been cancelled' : 'This booking has expired'} +

+ +
+
+
Booking Reference
+
{booking.bookingRef}
+
+ +
+ +
+ {isConfirmed && ( + <> + + + + + )} +
+
+ +
+

Journey Details

+ +
+
+ Your Journey + {booking.passengers?.[0]?.seat?.seatClass && ( + + {booking.passengers[0].seat.seatClass} + + )} +
+ + {/* Flight-style timeline */} +
+ {/* Left column: Timeline with dots and line */} +
+ {/* Origin dot */} +
+ {/* Vertical line */} +
+ {/* Destination dot */} +
+
+ + {/* Right column: Content */} +
+ {/* Origin */} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.departureAt ? format(new Date(booking.schedule.departureAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.origin?.name} +
+
+ {booking.schedule?.origin?.city} +
+
+ + {/* Journey Info */} +
+
+
+ + + + Train {booking.schedule?.trainNumber} +
+ {booking.schedule?.trainName && ( + + {booking.schedule.trainName} + + )} +
+
+ + {/* Destination */} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {booking.schedule?.arrivalAt ? format(new Date(booking.schedule.arrivalAt), 'EEE, MMM d') : 'N/A'} +
+
+ {booking.schedule?.destination?.name} +
+
+ {booking.schedule?.destination?.city} +
+
+
+
+
+ +
+

+ Passenger Details ({booking.passengers?.length || 0}) +

+ +
+ {booking.passengers?.map((passenger: any, idx: number) => ( +
+
+
+
+ + {idx + 1} + +

{passenger.fullName}

+ + {passenger.category} + +
+ +
+
+ Coach: +
+ {passenger.seat?.coach || 'N/A'} +
+
+
+ Seat Number: +
+ {passenger.seat?.number || 'N/A'} +
+
+
+ Class: +
+ {passenger.seat?.seatClass || 'N/A'} +
+
+
+
+ + {isConfirmed && ( +
+
+ +
+
+ )} +
+
+ ))} +
+
+ +
+ +
+
+
+
+ ); + } + + // Fallback for any other status + return ( +
+
+
+ +
+

Unknown Booking Status

+

+ Booking status: {booking.status} +

+ +
+
+ ); +} + +export default function BookingDetailPage() { + return ( + +
+
+

Loading...

+
+
+ }> + +
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx index e326bd376..dd01d4d49 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/layout.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/layout.tsx @@ -22,7 +22,7 @@ export default function BookingLayout({ }; const currentStep = stepMap[pathname] || 'search'; - const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation'; + const showProgress = pathname !== '/booking/search' && pathname !== '/booking/confirmation' && pathname !== '/booking/detail' && pathname !== '/booking/lookup'; return (
diff --git a/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx new file mode 100644 index 000000000..e65882fd2 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/lookup/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export default function BookingLookupPage() { + const router = useRouter(); + const [bookingRef, setBookingRef] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = bookingRef.trim().toUpperCase(); + if (!trimmed) { + setError("Please enter a booking reference"); + return; + } + router.push(`/booking/detail?ref=${trimmed}`); + }; + + return ( +
+
+
+
+
+ +
+

+ Find Your Booking +

+

+ Enter your booking reference (PNR) to view details +

+
+ +
+
+ + { + setBookingRef(e.target.value.toUpperCase()); + setError(""); + }} + placeholder="Enter your PNR" + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent dark:bg-gray-700 dark:text-white text-lg font-mono" + /> + {error && ( +

{error}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index a9fdf5b35..d2ed43878 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -557,11 +557,14 @@ export default function PassengersPage() { nationalId: p.nationalId, passportNumber: p.passportNumber, passportCountry: p.passportCountry, - phone: p.phone, - email: p.email, + passportIssueDate: p.passportIssueDate, + passportExpiryDate: p.passportExpiryDate, + passportIssuingAuthority: p.passportIssuingAuthority, + phone: p.phone || '', + email: p.email || '', isPrimaryPassenger: i === 0, passengerId: i === 0 && passengerId ? passengerId : undefined, - })); + })) const deviceId = typeof window !== 'undefined' ? (localStorage.getItem('deviceId') || crypto.randomUUID()) diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index b73bac953..3b6c19336 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -4,11 +4,9 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { usePathname } from "next/navigation"; import { LanguageSwitcher } from "./LanguageSwitcher"; export default function AppHeader() { - const pathname = usePathname(); const [isOpen, setIsOpen] = useState(false); const [isDark, setIsDark] = useState(false); @@ -31,13 +29,7 @@ export default function AppHeader() { } }; - const isLandingPage = [ - "/", - "/services", - "/about", - "/contact", - "/help", - ].includes(pathname); + return (
@@ -59,35 +51,15 @@ export default function AppHeader() { /> - {/* Desktop Menu - only show for landing pages */} - {isLandingPage && ( -
- - Home - - - Services - - - About - - - Contact - -
- )} + {/* Desktop Menu */} +
+ + My Booking + +
{/* Right Actions */}
@@ -133,39 +105,13 @@ export default function AppHeader() { {/* Mobile Menu */} {isOpen && (
- {isLandingPage && ( - <> - setIsOpen(false)} - > - Home - - setIsOpen(false)} - > - Services - - setIsOpen(false)} - > - About - - setIsOpen(false)} - > - Contact - - - )} - + setIsOpen(false)} + > + My Booking + ; + schedule: { + trainNumber: string; + trainName?: string; + origin: { + name: string; + code: string; + city: string; + }; + destination: { + name: string; + code: string; + city: string; + }; + departureAt: string; + arrivalAt: string; + }; + totalMinor: number; + currency: string; + bookingType: string; + createdAt: string; +} + +export const generateVoucherPDF = async (booking: VoucherData) => { + const doc = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const margin = 15; + let yPos = margin; + + // Colors + const primaryColor = [20, 113, 76]; // EDR Green + const darkGray = [51, 51, 51]; + const mediumGray = [102, 102, 102]; + const lightGray = [200, 200, 200]; + + // ============ HEADER ============ + // Company branding strip + doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.rect(0, 0, pageWidth, 30, 'F'); + + // Load and add logo + try { + const logoImg = await fetch('/edr-logo.png'); + const logoBlob = await logoImg.blob(); + const logoDataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(logoBlob); + }); + + // Create image to get dimensions + const img = new Image(); + await new Promise((resolve) => { + img.onload = resolve; + img.src = logoDataUrl; + }); + + // Calculate aspect ratio and dimensions + const logoHeight = 18; + const logoWidth = (img.width / img.height) * logoHeight; + + // Add logo on left side with proper aspect ratio + doc.addImage(logoDataUrl, 'PNG', margin, 6, logoWidth, logoHeight); + + // Company name next to logo + doc.setTextColor(255, 255, 255); + doc.setFontSize(20); + doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', margin + logoWidth + 5, 14); + + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', margin + logoWidth + 5, 20); + } catch (error) { + console.error('Failed to load logo:', error); + // Fallback: just show text centered + doc.setTextColor(255, 255, 255); + doc.setFontSize(24); + doc.setFont('helvetica', 'bold'); + doc.text('ETHIO-DJIBOUTI RAILWAY', pageWidth / 2, 12, { align: 'center' }); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text('Premium Travel Experience', pageWidth / 2, 18, { align: 'center' }); + } + + yPos = 40; + + // ============ TITLE & STATUS ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(20); + doc.setFont('helvetica', 'bold'); + doc.text('BOOKING VOUCHER', pageWidth / 2, yPos, { align: 'center' }); + + yPos += 10; + + // Status badge (simplified) + const statusText = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? 'CONFIRMED' : booking.status; + const statusColor = booking.status === 'TICKETED' || booking.status === 'CONFIRMED' ? [34, 197, 94] : [234, 179, 8]; + + doc.setFillColor(statusColor[0], statusColor[1], statusColor[2]); + doc.rect(pageWidth / 2 - 20, yPos - 4, 40, 8, 'F'); + doc.setTextColor(255, 255, 255); + doc.setFontSize(9); + doc.setFont('helvetica', 'bold'); + doc.text(statusText, pageWidth / 2, yPos + 1, { align: 'center' }); + + yPos += 12; + + // ============ QR CODE ============ + // Generate QR code data URL + const canvas = document.createElement('canvas'); + const QRCode = (await import('qrcode')).default; + + const qrSize = 35; // 35mm = 3.5cm + await QRCode.toCanvas(canvas, booking.bookingRef, { + width: 300, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }); + + const qrDataUrl = canvas.toDataURL('image/png'); + + // Place QR code at top-right + const qrX = pageWidth - margin - qrSize; + const qrY = yPos; + + doc.addImage(qrDataUrl, 'PNG', qrX, qrY, qrSize, qrSize); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('SCAN AT TERMINAL', qrX + qrSize / 2, qrY + qrSize + 4, { align: 'center' }); + + // ============ BOOKING REFERENCE ============ + doc.setFillColor(245, 245, 245); + doc.rect(margin, yPos, pageWidth - margin * 2 - qrSize - 5, 18, 'F'); + + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text('BOOKING REFERENCE', margin + 5, yPos + 6); + + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text(booking.bookingRef, margin + 5, yPos + 14); + + yPos += 25; + + // ============ JOURNEY DETAILS ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('JOURNEY DETAILS', margin, yPos); + + yPos += 8; + + // Route box + doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); + doc.setLineWidth(0.5); + doc.rect(margin, yPos, pageWidth - margin * 2, 40); + + // Origin + doc.setFontSize(9); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('FROM', margin + 5, yPos + 6); + + doc.setFontSize(16); + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text(booking.schedule.origin.code, margin + 5, yPos + 14); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(booking.schedule.origin.name, margin + 5, yPos + 20); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.text(booking.schedule.origin.city, margin + 5, yPos + 25); + + // Departure time + const departureDate = new Date(booking.schedule.departureAt); + doc.setFontSize(14); + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFont('helvetica', 'bold'); + doc.text(departureDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), margin + 5, yPos + 33); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text(departureDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), margin + 5, yPos + 38); + + // Arrow + doc.setDrawColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setLineWidth(1); + const arrowStartX = pageWidth / 2 - 10; + const arrowEndX = pageWidth / 2 + 10; + const arrowY = yPos + 20; + + // Draw arrow line + doc.line(arrowStartX, arrowY, arrowEndX, arrowY); + + // Draw arrow head manually with lines + doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY - 2); + doc.line(arrowEndX, arrowY, arrowEndX - 3, arrowY + 2); + + // Destination + const destX = pageWidth - margin - 50; + doc.setFontSize(9); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('TO', destX, yPos + 6); + + doc.setFontSize(16); + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text(booking.schedule.destination.code, destX, yPos + 14); + + doc.setFontSize(10); + doc.setFont('helvetica', 'normal'); + doc.text(booking.schedule.destination.name, destX, yPos + 20); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.text(booking.schedule.destination.city, destX, yPos + 25); + + // Arrival time + const arrivalDate = new Date(booking.schedule.arrivalAt); + doc.setFontSize(14); + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFont('helvetica', 'bold'); + doc.text(arrivalDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), destX, yPos + 33); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text(arrivalDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }), destX, yPos + 38); + + yPos += 48; + + // Train info + doc.setFillColor(250, 250, 250); + doc.rect(margin, yPos, pageWidth - margin * 2, 12, 'F'); + + doc.setFontSize(9); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('TRAIN', margin + 5, yPos + 5); + + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text(booking.schedule.trainNumber, margin + 5, yPos + 9); + + if (booking.schedule.trainName) { + doc.setFont('helvetica', 'normal'); + doc.text(` - ${booking.schedule.trainName}`, margin + 25, yPos + 9); + } + + yPos += 18; + + // ============ PASSENGERS ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('PASSENGERS', margin, yPos); + + yPos += 8; + + // Passenger table + const passengerData = booking.passengers.map((p, idx) => [ + (idx + 1).toString(), + p.fullName, + p.category, + p.seat?.number || '-', + p.seat?.coach || '-', + p.seat?.seatClass || '-', + ]); + + autoTable(doc, { + startY: yPos, + head: [['#', 'Passenger Name', 'Type', 'Seat', 'Coach', 'Class']], + body: passengerData, + theme: 'striped', + headStyles: { + fillColor: [primaryColor[0], primaryColor[1], primaryColor[2]], + textColor: [255, 255, 255], + fontSize: 9, + fontStyle: 'bold', + }, + bodyStyles: { + fontSize: 9, + textColor: [darkGray[0], darkGray[1], darkGray[2]], + }, + alternateRowStyles: { + fillColor: [250, 250, 250], + }, + margin: { left: margin, right: margin }, + }); + + yPos = (doc as any).lastAutoTable.finalY + 10; + + // ============ PAYMENT SUMMARY ============ + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFontSize(12); + doc.setFont('helvetica', 'bold'); + doc.text('PAYMENT SUMMARY', margin, yPos); + + yPos += 8; + + doc.setFillColor(250, 250, 250); + doc.rect(margin, yPos, pageWidth - margin * 2, 20, 'F'); + + doc.setFontSize(10); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('Total Amount', margin + 5, yPos + 7); + + doc.setFontSize(16); + doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); + doc.setFont('helvetica', 'bold'); + doc.text(`${booking.currency} ${(booking.totalMinor / 100).toFixed(2)}`, pageWidth - margin - 5, yPos + 7, { align: 'right' }); + + doc.setFontSize(9); + doc.setTextColor(34, 197, 94); + doc.setFont('helvetica', 'bold'); + doc.text('✓ PAID', margin + 5, yPos + 15); + + yPos += 28; + + // ============ INSTRUCTIONS ============ + doc.setFillColor(252, 211, 77); + doc.rect(margin, yPos, pageWidth - margin * 2, 18, 'F'); + + doc.setFontSize(9); + doc.setTextColor(darkGray[0], darkGray[1], darkGray[2]); + doc.setFont('helvetica', 'bold'); + doc.text('⚠ IMPORTANT INSTRUCTIONS', margin + 5, yPos + 6); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(8); + doc.text('• Present this voucher at the terminal for boarding', margin + 5, yPos + 11); + doc.text('• Arrive at least 30 minutes before departure', margin + 5, yPos + 15); + + // ============ FOOTER ============ + const footerY = pageHeight - 25; + + doc.setDrawColor(lightGray[0], lightGray[1], lightGray[2]); + doc.line(margin, footerY, pageWidth - margin, footerY); + + doc.setFontSize(8); + doc.setTextColor(mediumGray[0], mediumGray[1], mediumGray[2]); + doc.setFont('helvetica', 'normal'); + doc.text('Support: support@edr.com | +251-11-XXX-XXXX', pageWidth / 2, footerY + 5, { align: 'center' }); + doc.text('Terms & Conditions apply. Visit www.edr.com for details.', pageWidth / 2, footerY + 9, { align: 'center' }); + + doc.setFontSize(7); + doc.text(`Generated: ${new Date().toLocaleString('en-US')}`, pageWidth / 2, footerY + 13, { align: 'center' }); + + // Watermark (removed rotation as it may cause issues) + doc.setTextColor(240, 240, 240); + doc.setFontSize(50); + doc.setFont('helvetica', 'bold'); + doc.text('EDR', pageWidth / 2, pageHeight / 2, { align: 'center' }); + + // Save PDF + doc.save(`EDR-Voucher-${booking.bookingRef}.pdf`); +}; diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 948616110..d0cb89d19 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -1,6 +1,3 @@ -import { createRequire } from "module"; - -const require = createRequire(import.meta.url); /** @type {import('tailwindcss').Config} */ export default { @@ -90,3 +87,4 @@ export default { }, plugins: [], }; + diff --git a/packages/api-common/src/constants/apiModules.ts b/packages/api-common/src/constants/apiModules.ts new file mode 100644 index 000000000..78ef2badf --- /dev/null +++ b/packages/api-common/src/constants/apiModules.ts @@ -0,0 +1,43 @@ +export const flatResponseModules: string[] = [ + "/api/file-settings", + "api/me", + "/api/auth", + "/api/sessions", + "/api/users", + "/api/roles", + "/api/user-roles", + "/api/permissions", + "/api/role-permissions", + "/api/user-documents", + "/api/documentary-requirements", + "/api/account-configurations", + "/api/applications", + "/api/organization-types", + "/api/default-units", + "/api/default-positions", + "/api/organizations", + "/api/units", + "/api/unit-settings", + "/api/organization-configurations", + "/api/positions", + "/api/employees", + "/api/employee-positions", + "/api/migrate", + "/api/projects", + "/api/position-permissions", + "/api/position-type-permissions", + "/api/position-types", + "/api/position-configurations", + "/api/organization-global-configurations", + "/api/global-unit-configurations", + "/api/position-type-configurations", + "/api/organization-settings", + "/api/location-types", + "/api/locations", + "/api/unit-clusters", + "/api/seals", + "/api/headers", + "/api/footers", + "/api/employee-signatures", + "/api/employee-stamps", +]; \ No newline at end of file diff --git a/packages/api-common/src/interceptors/response-transform.interceptor.ts b/packages/api-common/src/interceptors/response-transform.interceptor.ts index d3a024b62..0348126b6 100644 --- a/packages/api-common/src/interceptors/response-transform.interceptor.ts +++ b/packages/api-common/src/interceptors/response-transform.interceptor.ts @@ -8,6 +8,7 @@ import { import { Readable } from "stream"; import { Observable } from "rxjs"; import { map } from "rxjs/operators"; +import { flatResponseModules } from "../constants/apiModules"; export interface StandardResponse { success: true; @@ -33,6 +34,25 @@ export class ResponseTransformInterceptor implements NestInterceptor< ) { return data; } + const request = _context.switchToHttp().getRequest(); + const path = request.route?.path ?? request.originalUrl ?? ""; + + const shouldFlatten = flatResponseModules.some((module) => + path.startsWith(module), + ); + + if ( + shouldFlatten && + data && + typeof data === "object" && + !Array.isArray(data) + ) { + return { + success: true, + ...data, + timestamp: new Date().toISOString(), + }; + } return { success: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 610821e53..3bca217d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -656,6 +656,9 @@ importers: '@tanstack/react-query': specifier: ^5.59.0 version: 5.101.0(react@18.3.1) + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 axios: specifier: ^1.7.7 version: 1.17.0 @@ -665,12 +668,21 @@ importers: date-fns: specifier: ^3.0.0 version: 3.6.0 + jspdf: + specifier: ^4.2.1 + version: 4.2.1 + jspdf-autotable: + specifier: ^5.0.8 + version: 5.0.8(jspdf@4.2.1) lucide-react: specifier: ^0.446.0 version: 0.446.0(react@18.3.1) next: specifier: ^14.2.0 version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 qrcode.react: specifier: ^3.1.0 version: 3.2.0(react@18.3.1) @@ -7786,9 +7798,21 @@ packages: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} + jspdf-autotable@5.0.8: + resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==} + peerDependencies: + jspdf: ^2 || ^3 || ^4 + jspdf@3.0.4: resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + + jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -19797,6 +19821,10 @@ snapshots: ms: 2.1.3 semver: 7.8.2 + jspdf-autotable@5.0.8(jspdf@4.2.1): + dependencies: + jspdf: 4.2.1 + jspdf@3.0.4: dependencies: '@babel/runtime': 7.29.7 @@ -19808,6 +19836,28 @@ snapshots: dompurify: 3.4.8 html2canvas: 1.4.1 + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.8 + html2canvas: 1.4.1 + + jsprim@1.4.2: + dependencies: + '@babel/runtime': 7.29.7 + fast-png: 6.4.0 + fflate: 0.8.3 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.8 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9