From 9d5c195d94903de616471b651d78bb177817c545 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 08:16:49 +0000 Subject: [PATCH 01/50] style: improvment to the booking --- apps/edr-freight-web/portal/src/App.tsx | 24 +-- .../onboarding/OnboardingResumeBanner.tsx | 145 ++++++++++++++++++ .../onboarding/OnboardingWizardDialog.tsx | 2 +- .../src/pages/MyPortalPage/MyPortalPage.tsx | 4 - .../MyPortalPage/components/SetupPrompt.tsx | 70 --------- .../pages/MyPortalPage/components/index.ts | 2 +- .../portal/src/utils/profileCompletion.ts | 56 +++++++ 7 files changed, 204 insertions(+), 99 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/MyPortalPage/components/SetupPrompt.tsx create mode 100644 apps/edr-freight-web/portal/src/utils/profileCompletion.ts diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 15137cb26..3a32f2b78 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -7,7 +7,6 @@ import { MapPin, Receipt, Settings, - Sparkles, } from "lucide-react"; import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; @@ -21,6 +20,7 @@ import { } from "react-router-dom"; import useAuth from "./hooks/useAuth"; +import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; @@ -155,28 +155,6 @@ function OnboardingGate() { ); } -/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */ -function OnboardingResumeBanner({ onResume }: { onResume: () => void }) { - return ( -
-
- - - Finish setting up your company to unlock bookings, tracking and - billing. - -
- -
- ); -} - /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx new file mode 100644 index 000000000..e0df7346f --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -0,0 +1,145 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight } from "lucide-react"; +import { api } from "@/services/api"; +import { + getProfileCompletion, + type ProfileCompletion, +} from "@/utils/profileCompletion"; + +interface OnboardingResumeBannerProps { + /** Re-opens the onboarding wizard. */ + onResume: () => void; +} + +interface BannerCopy { + title: string; + subtitle: string; + cta: string; +} + +/** Picks wording based on how far through setup the user actually is. */ +function getCopy( + completion: ProfileCompletion, + pct: number, + isPending: boolean, +): BannerCopy { + // Until the profile loads, or before anything is filled in, treat it as a + // fresh start rather than guessing progress. + if (isPending || completion.completed === 0) { + return { + title: "Set up your company profile", + subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", + cta: "Start onboarding", + }; + } + + const remaining = completion.total - completion.completed; + if (remaining <= 2) { + return { + title: `Almost done — you're ${pct}% set up`, + subtitle: `Just ${remaining} more ${ + remaining === 1 ? "detail" : "details" + } to unlock bookings, tracking and billing.`, + cta: "Finish onboarding", + }; + } + + return { + title: `You're ${pct}% set up`, + subtitle: `${completion.completed} of ${completion.total} details added — finish to unlock bookings, tracking and billing.`, + cta: "Continue onboarding", + }; +} + +/** Circular percentage meter that reads at a glance against the dark banner. */ +function ProgressRing({ pct }: { pct: number }) { + const size = 56; + const stroke = 5; + const r = (size - stroke) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference * (1 - pct / 100); + + return ( + + + + + + {pct}% + + ); +} + +/** + * Prominent banner shown on onboarding-allowed pages after the wizard is + * dismissed. It reads the company profile directly so it stays aware of real + * progress: a percentage ring and the copy adapt as fields get filled, and the + * whole banner disappears once every required detail is complete. + */ +export default function OnboardingResumeBanner({ + onResume, +}: OnboardingResumeBannerProps) { + const profileQuery = useQuery( + api.companies.getProfile.queryOptions({ retry: false }), + ); + + const completion = getProfileCompletion(profileQuery.data); + + // Reliably step aside once the user has genuinely finished onboarding. + if (!profileQuery.isPending && completion.isComplete) return null; + + const pct = Math.round((completion.completed / completion.total) * 100); + const { title, subtitle, cta } = getCopy( + completion, + pct, + profileQuery.isPending, + ); + + return ( +
+
+
+ + + + + + + + + {title} + + + {subtitle} + +
+ +
+
+ ); +} 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 3628d7403..b25f6f12e 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -355,7 +355,7 @@ export default function OnboardingWizardDialog({ withCloseButton closeOnClickOutside={false} closeOnEscape - size={1040} + size={720} radius="lg" padding="xl" centered diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 0a4dcb6ce..13ab980b8 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -7,7 +7,6 @@ import { HelloSection, InvoicesSection, RecentActivitySection, - SetupPrompt, ShipmentsSection, StatsSection, } from "./components"; @@ -16,7 +15,6 @@ import { useMyPortalData } from "./hooks"; export default function MyPortalPage() { const navigate = useNavigate(); const { - customer, bookingsQuery, dashboardQuery, allBookings, @@ -40,8 +38,6 @@ export default function MyPortalPage() { - - !profile[field]); -} - -interface SetupPromptProps { - show: boolean; -} - -export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), - ); - - const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); - - if (!show && !incomplete) return null; - - return ( - - - - - {incomplete && } - - {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} - - - - {incomplete - ? "Your company profile is incomplete. Fill in the missing details to unlock all features." - : "Complete your company information to unlock all features and start booking shipments."} - - - - - {incomplete ? "Complete Profile" : "Complete Setup"} - - - - - - - - - - - ); -}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts index de3ddb448..4bf94f45f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection"; export { HelloSection } from "./HelloSection"; export { InvoicesSection } from "./InvoicesSection"; export { RecentActivitySection } from "./RecentActivitySection"; -export { SetupPrompt } from "./SetupPrompt"; export { ShipmentsSection } from "./ShipmentsSection"; export { StatKpi } from "./StatKpi"; export { StatsSection } from "./StatsSection"; export { Stepper } from "./Stepper"; + diff --git a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts new file mode 100644 index 000000000..5e95af40d --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts @@ -0,0 +1,56 @@ +import type { ProfileResponse } from "@/types/profile"; + +/** + * Company-profile fields that must be filled before onboarding is considered + * finished. Shared between the portal SetupPrompt and the onboarding banner so + * both agree on what "done" means. + */ +export const REQUIRED_PROFILE_FIELDS: (keyof ProfileResponse)[] = [ + "companyEmail", + "companyPhone", + "companyAddress", + "fanNumber", + "contactPersonName", + "contactPersonPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +]; + +export interface ProfileCompletion { + /** Number of required fields that are filled in. */ + completed: number; + /** Total number of required fields. */ + total: number; + /** Required fields still missing a value. */ + missing: (keyof ProfileResponse)[]; + /** True when every required field is filled. */ + isComplete: boolean; +} + +/** Breaks a profile down into how much of the required setup is complete. */ +export function getProfileCompletion( + profile?: ProfileResponse | null, +): ProfileCompletion { + const total = REQUIRED_PROFILE_FIELDS.length; + if (!profile) { + return { + completed: 0, + total, + missing: [...REQUIRED_PROFILE_FIELDS], + isComplete: false, + }; + } + const missing = REQUIRED_PROFILE_FIELDS.filter((field) => !profile[field]); + return { + completed: total - missing.length, + total, + missing, + isComplete: missing.length === 0, + }; +} + +/** Convenience predicate kept for existing call sites. */ +export function isProfileIncomplete(profile?: ProfileResponse | null): boolean { + return !getProfileCompletion(profile).isComplete; +} From c9694177d8206c36b8f4983257dbe8bf80e10076 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 08:55:09 +0000 Subject: [PATCH 02/50] style: ui improvement to kpi in portal --- .../pages/MyPortalPage/components/StatKpi.tsx | 88 +++++++++++++++---- .../MyPortalPage/components/StatsSection.tsx | 30 +++++-- 2 files changed, 91 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx index 413caafc4..a17af733d 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatKpi.tsx @@ -1,15 +1,30 @@ import { Box, Group, Text } from "@mantine/core"; -import { memo } from "react"; import type { LucideIcon } from "lucide-react"; +import { memo } from "react"; import { cv } from "../constants"; +/** Accent families map a KPI to a soft tile + strong ink pair from the theme. */ +type Accent = "green" | "amber" | "blue" | "slate"; + +const ACCENTS: Record = { + green: { soft: cv("edr-soft"), ink: cv("edr-green.7") }, + amber: { soft: cv("edr-amber-soft"), ink: cv("edr-amber-text") }, + blue: { soft: cv("edr-blue-soft"), ink: cv("edr-blue") }, + slate: { soft: cv("edr-slate-soft"), ink: cv("edr-slate") }, +}; + interface StatKpiProps { icon: LucideIcon; label: string; value: string; delta: string; - deltaColor: string; + /** Color family for the icon chip. */ + accent: Accent; + /** Tint of the delta pill — defaults to the card accent. */ + deltaTone?: Accent | "muted"; + /** Draw a separating border on the left (on wide layouts). */ divider?: boolean; + loading?: boolean; } export const StatKpi = memo(function StatKpi({ @@ -17,30 +32,67 @@ export const StatKpi = memo(function StatKpi({ label, value, delta, - deltaColor, + accent, + deltaTone, divider, + loading, }: StatKpiProps) { + const a = ACCENTS[accent]; + const tone = deltaTone ?? accent; + const pill = + tone === "muted" + ? { bg: cv("edr-slate-soft2"), fg: cv("edr-muted") } + : { bg: ACCENTS[tone].soft, fg: ACCENTS[tone].ink }; + return ( - - - - {label} - - - - - {value} - - - {delta} - + {/* Icon chip + metric label, aligned on one line. */} + + + + + + + + {loading ? "—" : value} + + {delta && !loading && ( + + + {delta} + + + )} + + + {label} + + + + {/* Value + its trend pill, grouped together at the bottom of the cell. */} + ); }); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx index 8a0545fb7..dbd12546b 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/StatsSection.tsx @@ -1,7 +1,7 @@ +import { formatCurrency } from "@/pages/billing/invoices.mock"; import { SimpleGrid } from "@mantine/core"; import { CheckCircle2, Clock3, Truck, Wallet } from "lucide-react"; import { memo } from "react"; -import { formatCurrency } from "@/pages/billing/invoices.mock"; import { formatPct } from "../constants"; import { Card } from "./Card"; import { StatKpi } from "./StatKpi"; @@ -29,39 +29,51 @@ export const StatsSection = memo(function StatsSection({ completionRate, spendYtd, spendYtdChangePct, + dashboardLoading, }: StatsSectionProps) { return ( - - + + 0 ? `+${newActiveThisWeek} this week` : ""} + loading={bookingsLoading} /> From 43be6892aa7bcd1379388e16f854a22f71d7707c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 11:50:04 +0000 Subject: [PATCH 03/50] fix: block the user from booking if not approved --- .../src/modules/bookings/bookings.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b6f3ee220..1f93eccc3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -361,6 +361,17 @@ export class BookingsService { tradeDirection, fallbackType, ); + + // A customer booking under their own account may only do so once the + // resolved operational profile has been approved by the backoffice. Staff- + // and government-initiated bookings (companyId supplied explicitly) bypass + // this gate. + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } const allowConsolidation = From 9b48955eaf1b87f65e70f6015f3c7d92a182db6f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 11:50:28 +0000 Subject: [PATCH 04/50] feat: centralized the user onboaridn requriements --- .../modules/companies/companies.controller.ts | 12 + .../src/modules/companies/companies.module.ts | 2 + .../modules/companies/companies.service.ts | 206 +++++++++++++++++- .../onboarding-requirements-response.dto.ts | 78 +++++++ .../src/pages/customers/CustomersPage.tsx | 44 +++- apps/edr-freight-web/portal/src/App.tsx | 15 +- .../src/components/NewBookingButton.tsx | 60 +++++ .../onboarding/OnboardingResumeBanner.tsx | 108 ++++++--- .../onboarding/OnboardingWizardDialog.tsx | 135 ++++++++++-- .../portal/src/constants/URLS.ts | 1 + .../portal/src/hooks/useAuth.ts | 11 + .../src/pages/accounts/CompanyProfileForm.tsx | 2 +- .../portal/src/pages/bookings/MyBookings.tsx | 26 +-- .../src/pages/bookings/NewBookingPage.tsx | 8 +- .../portal/src/services/api.ts | 7 + .../portal/src/services/companies.service.ts | 49 +++++ .../portal/src/utils/profileCompletion.ts | 56 ----- 17 files changed, 670 insertions(+), 150 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts create mode 100644 apps/edr-freight-web/portal/src/components/NewBookingButton.tsx delete mode 100644 apps/edr-freight-web/portal/src/utils/profileCompletion.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index fff8fc7e5..2feebffb5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -40,6 +40,7 @@ import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto"; import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto"; import { FetchETradeDto } from "./dto/fetch-etrade.dto"; import { ETradeResponseDto } from "./dto/etrade-response.dto"; @@ -221,6 +222,17 @@ export class CompaniesController { await this.companiesService.setOnboardingStep(user.id, dto.step); } + @Get("onboarding/requirements") + @ApiOperation({ + summary: + "What the current user's company still needs to finish onboarding (server-driven documents + outstanding items)", + }) + async getOnboardingRequirements( + @CurrentUser() user: CurrentIamUser, + ): Promise { + return this.companiesService.getOnboardingRequirements(user.id); + } + @Post("onboarding/complete") @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) async completeOnboarding( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index d275c57a7..88871f8ad 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; +import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; @@ -20,6 +21,7 @@ import { ETradeService } from "./services/etrade.service"; TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), HttpModule, FilesModule, + FileUploadSettingsModule, MinioModule, ], controllers: [CompaniesController], 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 12fcb6238..b145f354c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -3,13 +3,17 @@ import { NotFoundException, ConflictException, BadRequestException, + ForbiddenException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; +import { FilesService } from "../files/files.service"; +import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { ETradeService } from "./services/etrade.service"; +import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto"; import { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; @@ -50,9 +54,67 @@ export class CompaniesService { private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly etradeService: ETradeService, ) { } + /** + * Required company-information fields that must be filled before onboarding can + * be submitted. The backend owns this list so the portal never has to know + * which fields are mandatory — it just renders what's reported outstanding. + * `get` reads the value from the company (some live in the attributes blob). + */ + private readonly REQUIRED_COMPANY_INFO: { + key: string; + label: string; + get: (company: Company) => unknown; + }[] = [ + { + key: "tinNumber", + label: "Company TIN", + get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null), + }, + { key: "companyEmail", label: "Company email", get: (c) => c.email }, + { key: "companyPhone", label: "Company phone", get: (c) => c.phone }, + { key: "companyAddress", label: "Company address", get: (c) => c.address }, + { key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber }, + { + key: "contactPersonName", + label: "Contact person name", + get: (c) => c.attributes?.contactPersonName, + }, + { + key: "contactPersonPhone", + label: "Contact person phone", + get: (c) => c.attributes?.contactPersonPhone, + }, + { + key: "generalManagerName", + label: "General manager name", + get: (c) => c.attributes?.generalManagerName, + }, + { + key: "generalManagerEmail", + label: "General manager email", + get: (c) => c.attributes?.generalManagerEmail, + }, + { + key: "generalManagerPhone", + label: "General manager phone", + get: (c) => c.attributes?.generalManagerPhone, + }, + ]; + + /** The nationality-based document setting code for a company. */ + private documentSettingCodeFor( + nationality: CompanyNationality | null | undefined, + ): string { + return nationality === CompanyNationality.Foreign + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; + } + async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { @@ -624,6 +686,17 @@ export class CompaniesService { ); if (!updated) throw new NotFoundException(`Company profile ${profileId} not found`); + + // Approving any profile promotes a pending company to active, so the + // customer can start working as soon as their first profile is cleared. + if (status === ProfileStatus.Active) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company && company.status === CompanyStatus.Pending) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + } + } return updated; } @@ -809,6 +882,100 @@ export class CompaniesService { await this.profilesRepo.update(profile.id, { onboardingStep: step }); } + /** + * Server-driven onboarding requirements for the current user's company. + * + * The backend resolves the nationality-based document set, checks which + * company documents and per-profile licenses are already uploaded, and reports + * exactly what is still outstanding. The portal renders this list verbatim and + * relies on `isComplete` to decide when to auto-finish — it never decides for + * itself which documents apply or which fields are mandatory. + */ + async getOnboardingRequirements( + userId: string, + ): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + // 1. Required company-information fields. + const missingInfo = this.REQUIRED_COMPANY_INFO.filter( + (f) => !f.get(company), + ).map((f) => ({ key: f.key, label: f.label })); + + // 2. Nationality-based company documents + which are already uploaded. + const documentSettingCode = this.documentSettingCodeFor(company.nationality); + const [setting, uploadedFiles] = await Promise.all([ + this.fileUploadSettingsService + .getByCode(documentSettingCode) + .catch(() => null), + this.filesService.findByResource(company.id, "companies"), + ]); + const uploadedCodes = new Set(uploadedFiles.map((f) => f.code)); + const documents = (setting?.fields ?? []) + .slice() + .sort((a, b) => a.displayOrder - b.displayOrder) + .map((f) => ({ + fileKey: f.fileKey, + fileLabel: f.fileLabel, + helpText: f.helpText ?? null, + isRequired: f.isRequired, + isMultiple: f.isMultiple, + maxFiles: f.maxFiles, + allowedExtensions: f.allowedExtensions, + maxSizeMb: f.maxSizeMb, + displayOrder: f.displayOrder, + uploaded: uploadedCodes.has(f.fileKey), + })); + const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); + + // 3. Per-operational-profile business licenses. + const licenseProfiles = (company.companyProfiles ?? []).map((p) => ({ + profileId: p.id, + type: p.type, + reference: p.reference, + uploaded: (p.businessLicenseFiles?.length ?? 0) > 0, + })); + const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + + const outstanding = [ + ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), + ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), + ...missingLicenses.map( + (p) => + `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, + ), + ]; + + // Progress spans every required item the user has to satisfy: company-info + // fields, required documents and one license per operational profile. + const requiredDocCount = documents.filter((d) => d.isRequired).length; + const total = + this.REQUIRED_COMPANY_INFO.length + + requiredDocCount + + licenseProfiles.length; + const completed = + total - + (missingInfo.length + missingDocs.length + missingLicenses.length); + + return new OnboardingRequirementsResponseDto({ + documentSettingCode, + nationality: company.nationality ?? CompanyNationality.Ethiopian, + companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, + documents, + licenseProfiles, + progress: { completed, total }, + isComplete: outstanding.length === 0, + onboardingCompleted: profile.onboardingCompleted, + outstanding, + }); + } + + /** + * Submit onboarding for review. Validation is delegated entirely to + * getOnboardingRequirements (the same source of truth the portal renders), so + * the gate can never drift from what the UI shows. On success the company and + * all its operational profiles move to PENDING — the backoffice approves each + * profile before it can be used (see setCompanyProfileStatus). + */ async markOnboardingComplete( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { @@ -817,23 +984,21 @@ export class CompaniesService { throw new NotFoundException(`Profile for user ${userId} not found`); const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - // Guard against finishing on a still-draft company (TIN never filled in). - if (!company.tin || company.tin.startsWith("D")) { + const requirements = await this.getOnboardingRequirements(userId); + if (!requirements.isComplete) { throw new BadRequestException( - "Company information is incomplete — please fill in your company details before finishing.", + requirements.outstanding[0] ?? + "Your onboarding is incomplete. Please complete all required steps before submitting.", ); } - // Every operational profile must have at least one business-license file - // (stored directly on the profile). + // Send every operational profile in for approval; the company itself becomes + // active once the backoffice approves at least one profile. const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); for (const cp of profiles) { - if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) { - throw new BadRequestException( - `Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`, - ); + if (cp.status !== ProfileStatus.Pending) { + await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending); } } @@ -842,11 +1007,30 @@ export class CompaniesService { onboardingStep: "done", }); await this.companiesRepo.update(companyId, { - status: CompanyStatus.Active, + status: CompanyStatus.Pending, }); return this.getCompanyInfoByUserId(userId); } + /** + * Block a customer from booking under a profile that isn't approved yet. + * Called from the booking-create path for self-service bookings; staff- and + * government-initiated bookings bypass this. No-op when the profile can't be + * found (defensive — resolution is best-effort upstream). + */ + async assertCompanyProfileApprovedForBooking( + companyProfileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(companyProfileId); + if (!profile) return; + if (profile.status !== ProfileStatus.Active) { + const role = profile.type.replace(/_/g, " "); + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, + ); + } + } + /** * Authorize and resolve a company_profile that must belong to the current * user's company — used before accepting/returning its license files. diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts new file mode 100644 index 000000000..92f9fa513 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -0,0 +1,78 @@ +/** + * Server-driven description of what a company still needs to finish onboarding. + * + * The portal renders this verbatim instead of deciding for itself which + * documents apply or which fields are mandatory: the backend resolves the + * nationality-based document set, checks which files are already uploaded, and + * reports exactly what is outstanding. `isComplete` is the single source of + * truth the wizard uses to auto-finish. + */ + +export interface OnboardingInfoField { + key: string; + label: string; +} + +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + /** True when a file with this code is already stored for the company. */ + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + /** True when at least one business-license file is stored on the profile. */ + uploaded: boolean; +} + +export class OnboardingRequirementsResponseDto { + /** Resolved document setting code (by nationality) the docs were drawn from. */ + documentSettingCode: string; + nationality: string; + + /** Required company-information fields and whether each is filled. */ + companyInfo: { + complete: boolean; + missingFields: OnboardingInfoField[]; + }; + + /** The document fields the portal should render, with upload state. */ + documents: OnboardingDocumentField[]; + + /** Per-operational-profile business-license requirements. */ + licenseProfiles: OnboardingLicenseProfile[]; + + /** Overall setup progress across fields + documents + licenses. */ + progress: { completed: number; total: number }; + + /** True once every required field, document and license is satisfied. */ + isComplete: boolean; + + /** Whether the user has already submitted onboarding (awaiting approval). */ + onboardingCompleted: boolean; + + /** Human-readable list of everything still outstanding (empty when complete). */ + outstanding: string[]; + + constructor(init: Omit) { + this.documentSettingCode = init.documentSettingCode; + this.nationality = init.nationality; + this.companyInfo = init.companyInfo; + this.documents = init.documents; + this.licenseProfiles = init.licenseProfiles; + this.progress = init.progress; + this.isComplete = init.isComplete; + this.onboardingCompleted = init.onboardingCompleted; + this.outstanding = init.outstanding; + } +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index e06d310f4..aff353055 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -1,11 +1,14 @@ import { ActionIcon, + Badge, Box, Card, Group, + SegmentedControl, Stack, Text, TextInput, + Tooltip, } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; import { useQuery } from "@tanstack/react-query"; @@ -32,7 +35,7 @@ import { } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; -import type { Company } from "@/types/customer"; +import type { Company, CompanyStatus } from "@/types/customer"; import { DataTable, DataTableFooter, @@ -45,14 +48,17 @@ export default function CustomersPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); + // "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review). + const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>(""); const filter = useMemo( () => ({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, search: debouncedQuery, + status: statusFilter || undefined, }), - [pagination.pageIndex, pagination.pageSize, debouncedQuery], + [pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter], ); const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} })); @@ -107,7 +113,25 @@ export default function CustomersPage() { { id: "status", header: "Status", - cell: ({ row }) => , + cell: ({ row }) => { + const pending = (row.original.companyProfiles ?? []).filter( + (p) => p.status === "pending", + ).length; + return ( + + + {pending > 0 ? ( + 1 ? "s" : ""} awaiting approval`} + > + + {pending} pending + + + ) : null} + + ); + }, }, { id: "contact", @@ -216,6 +240,20 @@ export default function CustomersPage() { style={{ flex: 1, minWidth: "240px" }} radius="lg" /> + { + setStatusFilter(v === "all" ? "" : (v as CompanyStatus)); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "All", value: "all" }, + { label: "Pending approval", value: "pending" }, + { label: "Active", value: "active" }, + ]} + /> {total} record{total !== 1 ? "s" : ""} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 3a32f2b78..9082773b6 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -1,4 +1,5 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; +import { useDisclosure } from "@mantine/hooks"; import { CalendarCheck, Home, @@ -8,7 +9,6 @@ import { Receipt, Settings, } from "lucide-react"; -import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; import { Navigate, @@ -19,9 +19,11 @@ import { useNavigate, } from "react-router-dom"; -import useAuth from "./hooks/useAuth"; -import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner"; +import OnboardingResumeBanner, { + AccountReviewBanner, +} from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; +import useAuth from "./hooks/useAuth"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import MySignaturePage from "./pages/MySignaturePage"; @@ -36,11 +38,11 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import MyBookings from "./pages/bookings/MyBookings"; import NewBookingPage from "./pages/bookings/NewBookingPage"; -import ContractsList from "./pages/contracts/ContractsList"; import ContractDetailPage from "./pages/contracts/ContractDetailPage"; +import ContractsList from "./pages/contracts/ContractsList"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; -import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; +import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import TrackingPage from "./pages/tracking/TrackingPage"; function FullScreenSpinner() { @@ -143,9 +145,10 @@ function OnboardingGate() { return ( <> - {needsOnboarding && !wizardOpen && ( + {needsOnboarding && ( )} + {!needsOnboarding && } + + + + + ); + } + + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx index e0df7346f..d0024c6f6 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -1,10 +1,8 @@ import { useQuery } from "@tanstack/react-query"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Clock } from "lucide-react"; import { api } from "@/services/api"; -import { - getProfileCompletion, - type ProfileCompletion, -} from "@/utils/profileCompletion"; +import useAuth from "@/hooks/useAuth"; +import type { OnboardingRequirements } from "@/services/companies.service"; interface OnboardingResumeBannerProps { /** Re-opens the onboarding wizard. */ @@ -17,15 +15,16 @@ interface BannerCopy { cta: string; } -/** Picks wording based on how far through setup the user actually is. */ +/** + * Wording is driven entirely by the backend's outstanding-items list — the + * client never decides what's required, it just narrates what's left. + */ function getCopy( - completion: ProfileCompletion, + requirements: OnboardingRequirements | undefined, pct: number, - isPending: boolean, ): BannerCopy { - // Until the profile loads, or before anything is filled in, treat it as a - // fresh start rather than guessing progress. - if (isPending || completion.completed === 0) { + // No data yet (or nothing started) — treat it as a fresh start. + if (!requirements || requirements.progress.completed === 0) { return { title: "Set up your company profile", subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", @@ -33,20 +32,29 @@ function getCopy( }; } - const remaining = completion.total - completion.completed; + // Everything's filled in but not yet submitted for review. + if (requirements.isComplete) { + return { + title: "Everything's ready to go", + subtitle: "Submit your profile to send it for approval.", + cta: "Submit for review", + }; + } + + const remaining = requirements.outstanding.length; if (remaining <= 2) { return { title: `Almost done — you're ${pct}% set up`, subtitle: `Just ${remaining} more ${ - remaining === 1 ? "detail" : "details" - } to unlock bookings, tracking and billing.`, + remaining === 1 ? "item" : "items" + } to finish: ${requirements.outstanding.join(", ")}.`, cta: "Finish onboarding", }; } return { title: `You're ${pct}% set up`, - subtitle: `${completion.completed} of ${completion.total} details added — finish to unlock bookings, tracking and billing.`, + subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`, cta: "Continue onboarding", }; } @@ -90,28 +98,23 @@ function ProgressRing({ pct }: { pct: number }) { /** * Prominent banner shown on onboarding-allowed pages after the wizard is - * dismissed. It reads the company profile directly so it stays aware of real - * progress: a percentage ring and the copy adapt as fields get filled, and the - * whole banner disappears once every required detail is complete. + * dismissed. Progress and copy are read straight from the backend's onboarding + * requirements, so the banner always agrees with the wizard about what's left. */ export default function OnboardingResumeBanner({ onResume, }: OnboardingResumeBannerProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ retry: false }), ); - const completion = getProfileCompletion(profileQuery.data); - - // Reliably step aside once the user has genuinely finished onboarding. - if (!profileQuery.isPending && completion.isComplete) return null; - - const pct = Math.round((completion.completed / completion.total) * 100); - const { title, subtitle, cta } = getCopy( - completion, - pct, - profileQuery.isPending, - ); + const requirements = requirementsQuery.data; + const { completed, total } = requirements?.progress ?? { + completed: 0, + total: 0, + }; + const pct = total > 0 ? Math.round((completed / total) * 100) : 0; + const { title, subtitle, cta } = getCopy(requirements, pct); return (
@@ -143,3 +146,46 @@ export default function OnboardingResumeBanner({
); } + +/** + * Shown once onboarding is submitted but the company's operational profiles are + * still being reviewed. Communicates that approval is per-profile and that + * bookings unlock as each profile is cleared. Self-hides when nothing is pending. + */ +export function AccountReviewBanner() { + const { company } = useAuth(); + const profiles = company?.company?.companyProfiles ?? []; + const pending = profiles.filter((p) => p.status === "pending"); + const approved = profiles.filter((p) => p.status === "active"); + + if (profiles.length === 0 || pending.length === 0) return null; + + const pendingLabel = pending + .map((p) => p.type.replace(/_/g, " ")) + .join(", "); + + return ( +
+
+
+ + + + + + Your account is under review + + + We're reviewing your {pendingLabel}{" "} + {pending.length === 1 ? "profile" : "profiles"}. You can create + bookings under a profile as soon as it's approved. + + +
+ + {approved.length} of {profiles.length} approved + +
+
+ ); +} 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 b25f6f12e..845453200 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -14,8 +14,11 @@ import { ArrowRight, Building2, CheckCircle2, + Clock, FileText, Globe2, + PartyPopper, + ShieldCheck, UploadCloud, User, UserCheck, @@ -142,7 +145,7 @@ export default function OnboardingWizardDialog({ onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company, onboardingStep } = useAuth(); + const { user, company, onboardingStep, onboardingCompleted } = useAuth(); const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); @@ -174,6 +177,10 @@ export default function OnboardingWizardDialog({ // 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); + // Once submission succeeds we swap the whole wizard body for a congratulations + // panel, and keep the modal open (the gate would otherwise tear it down the + // moment onboardingCompleted flips true). + const [completed, setCompleted] = useState(false); // Saved profile data, for rehydrating the form fields after a refresh. const profileQuery = useQuery( @@ -184,6 +191,19 @@ export default function OnboardingWizardDialog({ }), ); + // Server-driven onboarding requirements: the backend decides which document + // set applies (by nationality) and what's still outstanding, so the client + // never makes that choice itself. This is the heavier "second request" — it's + // only issued while onboarding is still incomplete; once the getInfo flag says + // we're done, it never fires. + const requirementsQuery = useQuery( + api.companies.onboardingRequirements.queryOptions({ + enabled: companyAlreadyStarted && !onboardingCompleted, + retry: false, + refetchOnWindowFocus: false, + }), + ); + const refreshInfo = useCallback( () => queryClient.invalidateQueries({ @@ -225,7 +245,10 @@ export default function OnboardingWizardDialog({ } return api.companies.completeOnboarding.call(); }, - onSuccess: refreshInfo, + onSuccess: async () => { + await refreshInfo(); + setCompleted(true); + }, onError: (err) => setStartError(extractApiError(err).message), }); @@ -329,8 +352,22 @@ export default function OnboardingWizardDialog({ const stepMeta = STEP_META[activeStep]; const activeIdx = WIZARD_STEPS.indexOf(activeStep); + // Closing from the congratulations panel also clears the completed flag so a + // future reopen (shouldn't happen once onboarded) starts clean. + const handleClose = useCallback(() => { + if (completed) setCompleted(false); + onClose(); + }, [completed, onClose]); + + // Prefer the backend-resolved document code; fall back to the local mapping + // only until the requirements query lands (the documents step is reached well + // after the draft — and thus the requirements — exist). + const resolvedDocumentSettingCode = + requirementsQuery.data?.documentSettingCode ?? + documentSettingCode(effectiveNationality); + const formProps = { - documentSettingCode: documentSettingCode(effectiveNationality), + documentSettingCode: resolvedDocumentSettingCode, documentFiles, onDocumentFilesChange: setDocumentFiles, user, @@ -350,11 +387,11 @@ export default function OnboardingWizardDialog({ return ( - - - {stepMeta.icon} - {stepMeta.title} - - - {stepMeta.description} - - - -
+ completed ? null : ( + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + + + ) } > + {completed ? ( + + ) : ( {phase === "nationality" ? ( @@ -438,10 +480,65 @@ export default function OnboardingWizardDialog({ )} + )} ); } +/** + * Replaces the wizard body once onboarding is submitted: congratulates the user + * and sets the expectation that their company is now under review, and that + * bookings unlock per profile as the team approves each one. + */ +function OnboardingCompletePanel({ onClose }: { onClose: () => void }) { + return ( + + + + + + + You're all set! + + Thanks for completing your company profile. Your application has been + submitted and is now with our team for review. + + + + + + + + Each operational profile (importer, exporter, freight forwarder) is + reviewed and approved individually. + + + + + + You can start creating bookings under a profile as soon as it's + approved — we'll let you know the moment that happens. + + + + + + + ); +} + /** * Continuous progress pill: a single rounded track that fills left-to-right as * the user advances, with faint ticks marking each step boundary. diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 6a22ef954..3d30b7d15 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -89,6 +89,7 @@ export const URL_CONSTANTS = { ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", + ONBOARDING_REQUIREMENTS: "/api/companies/onboarding/requirements", DASHBOARD: "/api/companies/dashboard", FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 0f4f4e6a1..c44db9ac3 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -161,6 +161,15 @@ const useAuth = () => { companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + // Booking is gated on backoffice approval of the active operational profile: + // a customer can only book under a profile once its status is "active". + const activeProfile = + companyInfo?.company?.companyProfiles?.find( + (p) => p.id === activeCompanyProfileId, + ) ?? null; + const activeProfileStatus = activeProfile?.status ?? null; + const canBook = activeProfileStatus === "active"; + /** Refetch everything scoped to the active operational profile. */ const invalidateScopedData = async () => { await Promise.all([ @@ -229,6 +238,8 @@ const useAuth = () => { customer: isAuthenticated ? (companyQuery.data ?? null) : null, activeProfileType, activeCompanyProfileId, + activeProfileStatus, + canBook, companyType, onboardingCompleted, onboardingStep, 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 115351440..41113f6fc 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -912,7 +912,7 @@ export default function CompanyProfileForm({ {step === "documents" ? "Continue" : step === "additional" - ? "Finish onboarding" + ? "Submit for review" : "Save & Continue"} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index 5d7200008..e7e128a10 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { ActionIcon, @@ -25,7 +25,6 @@ import { LayoutList, MoreVertical, Package, - Plus, Search, Train, Wallet, @@ -35,6 +34,7 @@ import { import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal"; import { PayNowButton } from "./payments/PayNowButton"; import { ModeIndicator } from "@/components/ModeIndicator"; +import { NewBookingButton } from "@/components/NewBookingButton"; import { BookingTypeBadge, CargoModeCell, @@ -623,15 +623,7 @@ export default function MyBookings() { Track every cargo booking — from draft to delivery. - + {/* ── Summary stat cards ──────────────────────────────────────── */} @@ -779,17 +771,7 @@ export default function MyBookings() { : "Create your first booking to get started."} {!query && ( - + )} ) : ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index dd76d893a..bd62d1952 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -29,7 +29,7 @@ import { } from "lucide-react"; import { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { BookingFormInputValues, @@ -63,6 +63,12 @@ export default function NewBookingPage() { api.bookings.referenceData.queryOptions(), ); + // Booking is gated on profile approval: a customer whose active profile isn't + // approved yet is bounced back to the list, where the gate is explained. + if (!auth.isPending && auth.company && !auth.canBook) { + return ; + } + if (!auth.isPending && !auth.company) { return ( ( + "companies", + "onboardingRequirements", + companiesService.getOnboardingRequirements, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 791c0f8de..fd44dc276 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -82,6 +82,47 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +/** A single onboarding document field, as resolved and described by the backend. */ +export interface OnboardingDocumentField { + fileKey: string; + fileLabel: string; + helpText: string | null; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; + uploaded: boolean; +} + +export interface OnboardingLicenseProfile { + profileId: string; + type: string; + reference: string; + uploaded: boolean; +} + +/** + * Server-driven onboarding requirements. The portal renders this verbatim: the + * backend decides which documents apply (by nationality) and what is still + * outstanding, so the client never hardcodes required fields or document sets. + */ +export interface OnboardingRequirements { + documentSettingCode: string; + nationality: string; + companyInfo: { + complete: boolean; + missingFields: { key: string; label: string }[]; + }; + documents: OnboardingDocumentField[]; + licenseProfiles: OnboardingLicenseProfile[]; + progress: { completed: number; total: number }; + isComplete: boolean; + onboardingCompleted: boolean; + outstanding: string[]; +} + export interface CompanyProfileInput { type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; businessLicense?: string; @@ -226,6 +267,14 @@ export const companiesService = { return unwrap(response.data); }, + /** Server-driven list of outstanding onboarding requirements + completeness. */ + getOnboardingRequirements: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_REQUIREMENTS, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts deleted file mode 100644 index 5e95af40d..000000000 --- a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ProfileResponse } from "@/types/profile"; - -/** - * Company-profile fields that must be filled before onboarding is considered - * finished. Shared between the portal SetupPrompt and the onboarding banner so - * both agree on what "done" means. - */ -export const REQUIRED_PROFILE_FIELDS: (keyof ProfileResponse)[] = [ - "companyEmail", - "companyPhone", - "companyAddress", - "fanNumber", - "contactPersonName", - "contactPersonPhone", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", -]; - -export interface ProfileCompletion { - /** Number of required fields that are filled in. */ - completed: number; - /** Total number of required fields. */ - total: number; - /** Required fields still missing a value. */ - missing: (keyof ProfileResponse)[]; - /** True when every required field is filled. */ - isComplete: boolean; -} - -/** Breaks a profile down into how much of the required setup is complete. */ -export function getProfileCompletion( - profile?: ProfileResponse | null, -): ProfileCompletion { - const total = REQUIRED_PROFILE_FIELDS.length; - if (!profile) { - return { - completed: 0, - total, - missing: [...REQUIRED_PROFILE_FIELDS], - isComplete: false, - }; - } - const missing = REQUIRED_PROFILE_FIELDS.filter((field) => !profile[field]); - return { - completed: total - missing.length, - total, - missing, - isComplete: missing.length === 0, - }; -} - -/** Convenience predicate kept for existing call sites. */ -export function isProfileIncomplete(profile?: ProfileResponse | null): boolean { - return !getProfileCompletion(profile).isComplete; -} From 66d3ae8093aa6ce9603965d16cef88264ba72586 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 12:18:31 +0000 Subject: [PATCH 05/50] fix: onboarding --- .../companies/company-profile.repository.ts | 6 +- .../onboarding/OnboardingWizardDialog.tsx | 14 +- .../src/pages/accounts/CompanyProfileForm.tsx | 252 +++++++++--------- 3 files changed, 149 insertions(+), 123 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index bc2d95224..15aec5ac6 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -30,7 +30,11 @@ export class CompanyProfileRepository extends BaseRepository { } async generateReference(type: ProfileType): Promise { - const seqName = SEQUENCE_MAP[type]; + // The sequences live in the same schema as the entity (e.g. "freight"), but + // the connection's search_path is "public" — so the sequence MUST be + // schema-qualified or `nextval` fails with "relation does not exist". + const schema = this.repository.metadata.schema ?? "public"; + const seqName = `"${schema}".${SEQUENCE_MAP[type]}`; const result = await this.repository.query( `SELECT nextval('${seqName}') AS next_id`, ); 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 845453200..a34676539 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -149,6 +149,10 @@ export default function OnboardingWizardDialog({ const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); + // A draft can exist with zero operational profiles (e.g. an interrupted start). + // Such a draft must re-run role selection so the profiles actually get created + // — otherwise the user is stuck with nothing to upload a license against. + const hasOperationalProfiles = existingProfiles.length > 0; const savedNationality = (company?.company?.nationality as CompanyNationality | null) ?? null; @@ -160,7 +164,11 @@ export default function OnboardingWizardDialog({ // Phases: nationality → role → form. If a draft already exists, resume // straight into the form with nationality + roles pre-selected. const [phase, setPhase] = useState<"nationality" | "role" | "form">( - companyAlreadyStarted ? "form" : "nationality", + companyAlreadyStarted + ? hasOperationalProfiles + ? "form" + : "role" + : "nationality", ); const [nationality, setNationality] = useState( savedNationality, @@ -283,7 +291,9 @@ export default function OnboardingWizardDialog({ resumedRef.current = true; setRoles(existingProfiles.map((p) => p.type)); setNationality(savedNationality); - setPhase("form"); + // Resume into the form only when profiles exist; otherwise send the user to + // role selection so the missing operational profiles get created. + setPhase(hasOperationalProfiles ? "form" : "role"); const idx = FORM_STEPS.indexOf(resumeFormStep); if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; // eslint-disable-next-line react-hooks/exhaustive-deps 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 41113f6fc..67fd0d9ca 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,7 +1,6 @@ import { Alert, Button, - Checkbox, Divider, Group, Loader, @@ -12,12 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - UserCheck, -} from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -86,11 +80,11 @@ const onboardingSchema = z.object({ .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"), + generalManagerName: z.string().min(1, "Manager name is required"), + generalManagerEmail: z.string().email("Invalid Manager email"), generalManagerPhone: z .string() - .min(1, "GM phone is required") + .min(1, "Manager phone is required") .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), poaPhone: z @@ -171,7 +165,10 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } /** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: CompanyStep, d: FormData): Partial { +function stepPayload( + step: CompanyStep, + d: FormData, +): Partial { switch (step) { case "company": return { @@ -262,6 +259,20 @@ function toFormValues(p: ProfileResponse): FormData { }; } +/** A single read-only registration value rendered as a label/value pair. */ +function ReadOnlyField({ label, value }: { label: string; value?: string }) { + return ( + + + {label} + + + {value && value.trim() ? value : "—"} + + + ); +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -389,6 +400,20 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // eTrade carries no email, so the company/contact email fields start blank. + // Seed them from the registering user's account email — but only while empty, + // so a typed or rehydrated value is never overwritten. + useEffect(() => { + if (!user?.email) return; + if (!watch("companyEmail")) { + setValue("companyEmail", user.email, { shouldValidate: true }); + } + if (!watch("contactPersonEmail")) { + setValue("contactPersonEmail", user.email); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.email, rehydrate]); + // The business owner/manager pulled from eTrade — powers "Use owner as // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ @@ -396,11 +421,6 @@ export default function CompanyProfileForm({ phone: string; } | null>(null); - // Mirror the two "copy from previous person" checkboxes so they can be - // re-toggled (re-checking re-pulls the latest values). - const [gmIsContact, setGmIsContact] = useState(false); - const [contactIsPoa, setContactIsPoa] = useState(false); - const handleETradeDataLoaded = (data: CompanyRegistrationData) => { // Company name comes from the eTrade manager/owner name on the license. if (data.managerName) { @@ -457,19 +477,19 @@ export default function CompanyProfileForm({ }); }; - /** Copy the General Manager into the Contact Person fields (toggleable). */ - const toggleGmAsContact = (checked: boolean) => { - setGmIsContact(checked); - if (!checked) return; - setValue("contactPersonName", watch("generalManagerName")); + /** Copy the General Manager into the Contact Person fields (still editable). */ + const useGmAsContact = () => { + setValue("contactPersonName", watch("generalManagerName"), { + shouldValidate: true, + }); setValue("contactPersonEmail", watch("generalManagerEmail")); - setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue("contactPersonPhone", watch("generalManagerPhone"), { + shouldValidate: true, + }); }; - /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ - const toggleContactAsPoa = (checked: boolean) => { - setContactIsPoa(checked); - if (!checked) return; + /** Copy the Contact Person into the PoA fields (still editable). */ + const useContactAsPoa = () => { setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); @@ -477,6 +497,25 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Registration + address details come straight from the eTrade lookup and are + // not user-editable — only shown once a TIN lookup (or rehydration) has filled + // them in. We watch the values so the read-only display reflects the latest. + const registration = watch([ + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewalDate", + "renewedFrom", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", + ]); + const hasRegistrationDetails = registration.some((v) => v && v.trim()); + // Single source of truth for step sequence — navigation, labels and the // progress bar all derive from this so adding/removing a step is one edit. const stepOrder: CompanyStep[] = [ @@ -606,51 +645,41 @@ export default function CompanyProfileForm({ /> - <> + {hasRegistrationDetails && ( + <> - - Registration Details - + + + Registration Details + + + from eTrade · read-only + + - - - - - - - - - - @@ -658,47 +687,15 @@ export default function CompanyProfileForm({ Address Information - - - - - - - - - - + + + + + + + )} )} @@ -746,15 +743,22 @@ export default function CompanyProfileForm({ {step === "contact" && ( <> - - Contact Person - - toggleGmAsContact(e.currentTarget.checked)} - /> + + + Contact Person + + {watch("generalManagerName") && ( + + )} + - - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. - - toggleContactAsPoa(e.currentTarget.checked)} - /> + + + Power of Attorney details are optional. Fill them in if you + have them, or skip to continue. + + {watch("contactPersonName") && ( + + )} + {})} + onChange={onLicenseChange ?? (() => { })} /> )} @@ -902,9 +914,9 @@ export default function CompanyProfileForm({ loading={isPending || saving} rightSection={ !isPending && - !saving && - step !== "additional" && - step !== "documents" ? ( + !saving && + step !== "additional" && + step !== "documents" ? ( ) : undefined } From b31b27ce52184df74ebe3574b490711dcab7e62d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 12:45:10 +0000 Subject: [PATCH 06/50] fix: the company onboading flow --- .../src/pages/accounts/CompanyProfileForm.tsx | 119 ++++++++++++------ 1 file changed, 81 insertions(+), 38 deletions(-) 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 67fd0d9ca..8675f66d0 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -63,11 +63,13 @@ const onboardingSchema = z.object({ renewedFrom: z.string().optional(), renewalDate: z.string().optional(), renewedTo: z.string().optional(), - region: z.string().optional(), - zone: z.string().optional(), - woreda: z.string().optional(), - kebele: z.string().optional(), - houseNo: z.string().optional(), + // Address fields are user-entered and required (the registration/license + // fields above are read-only confirmations pulled from eTrade). + region: z.string().min(1, "Region is required"), + zone: z.string().min(1, "Zone is required"), + woreda: z.string().min(1, "Woreda is required"), + kebele: z.string().min(1, "Kebele is required"), + houseNo: z.string().min(1, "House number is required"), etradePhone: z.string().optional(), contactPersonName: z.string().min(1, "Contact person name is required"), contactPersonPosition: z.string().optional(), @@ -414,6 +416,22 @@ export default function CompanyProfileForm({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.email, rehydrate]); + // Keep the (hidden, derived) company address in sync with the editable address + // fields — so it reflects both the eTrade auto-fill and any later user edits, + // instead of only whatever was composed at lookup time. + const region = watch("region"); + const zone = watch("zone"); + const woreda = watch("woreda"); + const kebele = watch("kebele"); + const houseNo = watch("houseNo"); + useEffect(() => { + const composed = [houseNo, kebele, woreda, zone, region] + .filter((part) => part && part.trim()) + .join(", "); + setValue("companyAddress", composed); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [region, zone, woreda, kebele, houseNo]); + // The business owner/manager pulled from eTrade — powers "Use owner as // manager" on the General Manager step. Null until a TIN lookup succeeds. const [etradeOwner, setEtradeOwner] = useState<{ @@ -441,18 +459,9 @@ export default function CompanyProfileForm({ "etradePhone", toEthiopianE164(data.regularPhone || data.mobilePhone), ); - - // Compose a readable company address from the granular eTrade parts. - const addressParts = [ - data.houseNo, - data.kebele, - data.woreda, - data.zone, - data.region, - ].filter((part) => part && part.trim()); - if (addressParts.length) { - setValue("companyAddress", addressParts.join(", ")); - } + // companyAddress is composed reactively from the address fields below, so + // setting region/zone/woreda/kebele/houseNo above is enough — no need to + // compose it here. // Pre-fill the company contact phone from eTrade's mobile number. const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); @@ -497,9 +506,10 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); - // Registration + address details come straight from the eTrade lookup and are - // not user-editable — only shown once a TIN lookup (or rehydration) has filled - // them in. We watch the values so the read-only display reflects the latest. + // The registration/license details come straight from the eTrade lookup and + // are not user-editable — shown as a read-only confirmation once a TIN lookup + // (or rehydration) has filled them in. The address fields below are separate: + // user-entered and required. We watch the values so the display stays current. const registration = watch([ "licenceNumber", "statusDescription", @@ -507,12 +517,6 @@ export default function CompanyProfileForm({ "renewalDate", "renewedFrom", "renewedTo", - "region", - "zone", - "woreda", - "kebele", - "houseNo", - "etradePhone", ]); const hasRegistrationDetails = registration.some((v) => v && v.trim()); @@ -682,20 +686,59 @@ export default function CompanyProfileForm({ value={watch("renewedTo")} /> - - - Address Information - - - - - - - - - )} + + + + Address Information + + + + + + + + + + + + + )} From 2261675e2191b7dc6434598068572f5274615c43 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 24 Jun 2026 12:48:42 +0000 Subject: [PATCH 07/50] chore: add api errors --- .../onboarding/OnboardingWizardDialog.tsx | 4 ++++ .../src/pages/accounts/CompanyProfileForm.tsx | 14 ++++++++++++++ 2 files changed, 18 insertions(+) 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 a34676539..a12c959c4 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -393,6 +393,10 @@ export default function OnboardingWizardDialog({ roleProfiles, licenseFiles, onLicenseChange: setLicenseFiles, + // Surface a failed final submit (license/document upload or complete) inside + // the form — otherwise the server message (e.g. a 500) would be invisible on + // the submit step. + submitError: phase === "form" ? startError : null, }; return ( 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 8675f66d0..80d2b4db3 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -292,6 +292,7 @@ export default function CompanyProfileForm({ roleProfiles, licenseFiles, onLicenseChange, + submitError, }: { documentSettingCode: string; documentFiles?: Record; @@ -319,6 +320,8 @@ export default function CompanyProfileForm({ /** Newly-selected license files per profile id. */ licenseFiles?: Record; onLicenseChange?: (value: Record) => void; + /** Server error from the final submit (uploads/complete), shown verbatim. */ + submitError?: string | null; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -934,6 +937,17 @@ export default function CompanyProfileForm({ )} + {submitError && ( + } + title="Couldn't submit your application" + > + {submitError} + + )} + {showBack ? ( )} - @@ -734,6 +734,7 @@ const FirstMilePage = () => { key={option.value} size="xs" variant={active ? "filled" : "default"} + fw={500} onClick={() => { setStatusFilter(option.value); setPagination((p) => ({ ...p, pageIndex: 0 })); 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 7a68e447b..6f2661f35 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -629,11 +629,11 @@ const LastMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -646,6 +646,7 @@ const LastMilePage = () => { key={option.value} size="xs" variant={active ? "filled" : "default"} + fw={500} onClick={() => { setStatusFilter(option.value); setPagination((p) => ({ ...p, pageIndex: 0 })); From f7bdd19a07b9824c84702ca302e094a4d75aa7d7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Wed, 24 Jun 2026 14:32:04 +0000 Subject: [PATCH 16/50] fix ui --- .../backoffice/src/pages/fleet/FleetResourcePage.tsx | 6 +++--- .../backoffice/src/pages/operations/FirstMilePage.tsx | 6 +++--- .../backoffice/src/pages/operations/LastMilePage.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) 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 a7fc9efbd..6e89f265f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -360,7 +360,7 @@ const FleetResourcePage = () => { {config.subtitle} - )} - @@ -734,7 +734,7 @@ const FirstMilePage = () => { key={option.value} size="xs" variant={active ? "filled" : "default"} - fw={500} + styles={{ label: { fontWeight: 500 } }} onClick={() => { setStatusFilter(option.value); setPagination((p) => ({ ...p, pageIndex: 0 })); 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 6f2661f35..f7807f90a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -629,11 +629,11 @@ const LastMilePage = () => { /> {selectedIds.length > 0 && ( - )} - @@ -646,7 +646,7 @@ const LastMilePage = () => { key={option.value} size="xs" variant={active ? "filled" : "default"} - fw={500} + styles={{ label: { fontWeight: 500 } }} onClick={() => { setStatusFilter(option.value); setPagination((p) => ({ ...p, pageIndex: 0 })); From a1bb458a9158a6c9bf86a2b56703e55bc264f9e5 Mon Sep 17 00:00:00 2001 From: natib21 Date: Wed, 24 Jun 2026 14:37:45 +0000 Subject: [PATCH 17/50] fix ui --- .../backoffice/src/pages/fleet/FleetResourcePage.tsx | 2 -- 1 file changed, 2 deletions(-) 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 6e89f265f..f45a35f34 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx @@ -391,7 +391,6 @@ const FleetResourcePage = () => { size="xs" radius="md" variant={filter.value === option.value ? "filled" : "outline"} - color="green" styles={{ label: { fontWeight: 500 } }} onClick={() => { setListFilterValues((prev) => ({ @@ -418,7 +417,6 @@ const FleetResourcePage = () => { size="xs" radius="md" variant={statusFilter === option.value ? "filled" : "outline"} - color="green" styles={{ label: { fontWeight: 500 } }} onClick={() => setStatusFilter(option.value)} > From 6cf0ae09f567a059e1b48ccba2e09c29106200b1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Wed, 24 Jun 2026 14:50:26 +0000 Subject: [PATCH 18/50] fix issue --- .../src/modules/first-mile/first-mile.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2d4079a8b..cb35f9a89 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 @@ -45,7 +45,7 @@ export class FirstMileService { * unknown or the booking has not reached PAID status. */ async acceptBooking(bookingReference: string): Promise { - const booking = await this.bookingsRepository.findById(bookingReference); + const booking = await this.bookingsRepository.findByReference(bookingReference); if (!booking) { return null; From 58967e6e3df8777dd4fbd29f79e82aea60406887 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 24 Jun 2026 19:53:05 +0300 Subject: [PATCH 19/50] IAM related required updates --- apps/edr-passenger-api/prisma/seed.ts | 2 +- .../src/modules/agents/agents.controller.ts | 10 +- .../src/modules/agents/agents.service.ts | 6 + .../modules/bookings/bookings.controller.ts | 29 ++-- .../src/modules/bookings/bookings.service.ts | 5 +- .../passengers/passengers.controller.ts | 11 +- .../modules/passengers/passengers.service.ts | 138 ++++++++++++------ .../modules/payments/payments.controller.ts | 13 +- .../src/modules/seats/seats.controller.ts | 4 +- .../system-config/system-config.controller.ts | 29 +++- .../src/modules/tickets/tickets.controller.ts | 11 +- .../src/modules/tickets/tickets.service.ts | 1 + .../backoffice/src/app/passengers/page.tsx | 12 +- .../backoffice/src/app/tickets/page.tsx | 52 ++++--- .../backoffice/src/lib/api/index.ts | 4 +- .../src/app/booking/confirmation/page.tsx | 23 +-- .../src/app/booking/passengers/page.tsx | 5 +- .../portal/src/app/booking/payment/page.tsx | 15 +- .../portal/src/lib/api-client.ts | 12 +- 19 files changed, 240 insertions(+), 142 deletions(-) diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index a21a009ec..9c5a22b0f 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -496,7 +496,7 @@ async function seedPaymentMethods() { { type: 'TELEBIRR', displayName: 'Telebirr', region: 'ETHIOPIA' }, { type: 'CBE_BIRR', displayName: 'CBE Birr', region: 'ETHIOPIA' }, { type: 'EBIRR', displayName: 'eBirr', region: 'ETHIOPIA' }, - { type: 'WAAFI', displayName: 'Waffi', region: 'DJIBOUTI' }, + { type: 'WAAFI', displayName: 'Waafi', region: 'DJIBOUTI' }, { type: 'CARD', displayName: 'Credit/Debit Card', region: 'GLOBAL' }, { type: 'WALLET', displayName: 'Wallet', region: 'GLOBAL' }, ]; diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 378a2a361..139782475 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,19 +1,21 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; -// IAM auth: validate the IAM session token via @tria-plc/api-common's DB-backed JwtGuard. import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; @ApiTags('Agents') @Controller('agents') -// TODO(iam-authz): restrict per route via @UseGuards(PermissionGuard([...])) once the IAM -// role→permission mapping (EIamPermissionKey) is confirmed. For now: authenticated IAM users only. @UseGuards(IamJwtGuard) @ApiBearerAuth('IAM-auth') export class AgentsController { constructor(private service: AgentsService) {} + @Get('me') + @ApiOperation({ summary: 'Get agent profile for logged-in IAM user' }) + getMe(@Request() req: any) { + return this.service.getMe(req.user?.id ?? req.user?.sub); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index 1cee0b4e4..4d4b593fb 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -133,4 +133,10 @@ export class AgentsService { take: 20 }); } + + async getMe(iamUserId: string) { + const agent = await this.prisma.agent.findUnique({ where: { iamUserId } }); + if (!agent) throw new NotFoundException('No agent profile found for this user'); + return agent; + } } 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 ad4a7cd20..77b3ca1f8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -1,6 +1,5 @@ -import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Patch, UseGuards, Query, Req, BadRequestException, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiBody } from '@nestjs/swagger'; -import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; import { BookingsService } from './bookings.service'; import { GuestBookingService } from './guest-booking.service'; @@ -47,7 +46,7 @@ export class BookingsController { } @Get('by-device') - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Get bookings by device ID', description: 'Returns all bookings associated with a device ID (for guest users). Includes saved passenger details and booking history.' @@ -76,8 +75,8 @@ export class BookingsController { } @Get() - @ApiOperation({ - summary: 'List all bookings with filters (Admin/Agent)', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) @@ -102,7 +101,7 @@ export class BookingsController { } @Post('guest') - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Create guest booking — ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT (no login required)', description: `Creates a booking without requiring login. Supports all four booking types. @@ -256,7 +255,8 @@ export class BookingsController { } @Get('saved-passengers') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get saved passenger profiles', description: 'Retrieve saved passenger details by userId (if logged in) or deviceId (for guest users)' }) @@ -417,8 +417,8 @@ export class BookingsController { } @Get(':id/usage') - @ApiOperation({ - summary: 'Check if booking is in use', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Returns list of modules/data that reference this booking' }) @ApiResponse({ status: 200, description: 'Usage information retrieved' }) @@ -428,7 +428,8 @@ export class BookingsController { } @Get(':bookingRef') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get booking details by reference (no auth required)', description: 'Returns booking with passenger categories, Verifayda verification status, and multi-currency amounts. Works for both guest and authenticated bookings.' }) @@ -452,8 +453,8 @@ export class BookingsController { } @Delete(':id') - @ApiOperation({ - summary: 'Delete booking (admin only)', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Permanently deletes a booking record' }) @ApiResponse({ status: 200, description: 'Booking deleted successfully' }) @@ -463,8 +464,8 @@ export class BookingsController { } @Patch(':id') - @ApiOperation({ - summary: 'Update booking details', + @SetMetadata('isPublic', true) + @ApiOperation({ description: 'Updates booking information for admin/agent operations' }) @ApiResponse({ status: 200, description: 'Booking updated successfully' }) 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 21532fa44..a81c6eeb0 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1027,9 +1027,10 @@ export class BookingsService { ); } - async getByRef(bookingRef: string) { + async getByRef(bookingRefOrId: string) { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ - where: { bookingRef }, + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index 7cd119d42..be3f9ba05 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards, Query, Request, UnauthorizedException, Patch, Delete, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; @@ -19,6 +19,7 @@ export class PassengersController { ) {} @Get() + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all passengers with filters (Admin/Agent)', description: 'Returns paginated list of passengers with search filters' @@ -86,6 +87,7 @@ export class PassengersController { } @Post('verify-fayda') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Verify Ethiopian national ID via Verifayda 2.0', description: `**Standalone endpoint for pre-verification of Ethiopian national IDs** @@ -155,6 +157,7 @@ Pre-verify national ID to auto-fill passenger registration form before submissio } @Post('register') + @SetMetadata('isPublic', true) @UseGuards(OptionalJwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ @@ -249,6 +252,7 @@ The API automatically detects: } @Post('save-details') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Bulk save passenger details from booking flow', description: `**Endpoint for saving multiple passengers in a single booking** @@ -347,6 +351,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Patch(':id') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update passenger details', description: 'Updates passenger information for admin/agent operations' @@ -358,6 +363,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Delete(':id') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete passenger (admin only)', description: 'Permanently deletes a passenger record and associated data' @@ -369,6 +375,7 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Get(':id/usage') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Check if passenger is in use', description: 'Returns list of modules/data that reference this passenger' diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index a9f6c8f77..893bdb6ae 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -32,46 +32,20 @@ export class PassengersService { const { search, verified, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - let iamUserIdFilter: string[] | null = null; + const where: any = {}; - if (search || verified !== undefined) { - const conditions: string[] = []; - const params: any[] = []; - let idx = 1; - - if (search) { - conditions.push(`( - u.email ILIKE $${idx} OR - u.phone_number ILIKE $${idx} OR - (u.name->>'en') ILIKE $${idx} OR - (u.name->>'am') ILIKE $${idx} - )`); - params.push(`%${search}%`); - idx++; - } - - if (verified !== undefined) { - if (verified) { - conditions.push(`u.metadata->>'faydaVerified' = 'true'`); - } else { - conditions.push(`(u.metadata IS NULL OR u.metadata->>'faydaVerified' IS DISTINCT FROM 'true')`); - } - } - - const rows = await this.dataSource.query<{ id: string }[]>( - `SELECT u.id FROM iam.users u WHERE ${conditions.join(' AND ')}`, - params, - ); - iamUserIdFilter = rows.map(r => r.id); - - if (iamUserIdFilter.length === 0) { - return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } }; - } + if (search) { + where.user = { + OR: [ + { email: { contains: search, mode: 'insensitive' } }, + { phone: { contains: search, mode: 'insensitive' } }, + { fullName: { contains: search, mode: 'insensitive' } }, + ], + }; } - const where: any = {}; - if (iamUserIdFilter) { - where.iamUserId = { in: iamUserIdFilter }; + if (verified !== undefined) { + where.user = { ...(where.user ?? {}), faydaVerified: verified }; } const [items, total] = await Promise.all([ @@ -81,8 +55,22 @@ export class PassengersService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { + user: true, loyalty: true, + wallet: true, _count: { select: { bookings: true } }, + bookings: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { + contactEmail: true, + contactPhone: true, + seats: { take: 1, orderBy: { id: 'asc' }, select: { + passengerName: true, dateOfBirth: true, passportNumber: true, + passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true, + }}, + }, + }, }, }), this.prisma.passenger.count({ where }), @@ -97,16 +85,68 @@ export class PassengersService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + // Collect guest contact details for bulk SavedPassengerProfile lookup + const guestContacts = items + .filter(p => !(p as any).user && !p.iamUserId) + .map(p => (p as any).bookings?.[0]) + .filter(Boolean); + const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[]; + const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[]; + + const savedProfiles = (guestEmails.length || guestPhones.length) + ? await this.prisma.savedPassengerProfile.findMany({ + where: { OR: [ + ...(guestEmails.length ? [{ email: { in: guestEmails } }] : []), + ...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []), + ]}, + orderBy: { createdAt: 'desc' }, + }) + : []; + + // Index by email then phone for O(1) lookup + const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s])); + const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s])); + return { items: items.map(passenger => { + const localUser = (passenger as any).user ?? null; const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined; - const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true'; + const faydaVerified = localUser?.faydaVerified === true + || iam?.metadata?.faydaVerified === true + || iam?.metadata?.faydaVerified === 'true'; + const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null; + const guestSeat = guestBooking?.seats?.[0] ?? null; + const savedProfile = guestBooking + ? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null) + : null; return { id: passenger.id, - fullName: iam?.name?.en ?? iam?.name?.am ?? null, - email: iam?.email ?? null, - phone: iam?.phone_number ?? null, + fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null, + email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null, + phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null, + gender: localUser?.gender ?? iam?.metadata?.gender ?? null, + dateOfBirth: localUser?.dateOfBirth + ? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth) + : (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth + ? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0] + : (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))), + nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null, + nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null, + faydaVerified, + faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null, + passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null, + passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null, + passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null, + idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null, verified: faydaVerified, + lastLoginAt: localUser?.lastLoginAt ?? null, + role: localUser?.role ?? null, + loyalty: passenger.loyalty + ? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 } + : null, + wallet: (passenger as any).wallet + ? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' } + : null, loyaltyTier: passenger.loyalty?.tier || 'BRONZE', loyaltyPoints: passenger.loyalty?.pointsBalance || 0, totalBookings: passenger._count.bookings, @@ -384,7 +424,21 @@ export class PassengersService { const passenger = await this.prisma.passenger.findUnique({ where: { id } }); if (!passenger) throw new NotFoundException('Passenger not found'); - await this.prisma.passenger.delete({ where: { id } }); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }), + this.prisma.notification.deleteMany({ where: { passengerId: id } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), + this.prisma.journey.deleteMany({ where: { passengerId: id } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), + this.prisma.booking.deleteMany({ where: { passengerId: id } }), + this.prisma.passenger.delete({ where: { id } }), + ]); + return { deleted: true, passengerId: id }; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 0b8d6fe18..6a1df8cb1 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -7,6 +7,7 @@ import { Post, Query, Res, + SetMetadata, UseGuards, } from "@nestjs/common"; import { @@ -17,7 +18,7 @@ import { ApiOkResponse, ApiProduces, } from "@nestjs/swagger"; -import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; + import { SkipThrottle, Throttle } from "@nestjs/throttler"; import { Response } from "express"; import { PaymentsService } from "./payments.service"; @@ -65,7 +66,7 @@ export class PaymentsController { } @Post("initiate") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Initiate payment with nationality-based payment methods", description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, @@ -75,14 +76,14 @@ export class PaymentsController { } @Get("intents/:bookingId") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Get payment intent status for a booking" }) getIntent(@Param("bookingId") bookingId: string) { return this.service.getIntentByBookingId(bookingId); } @Get("waafi/return") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "DEMO ONLY — confirm a Waafi payment from the browser-return params and return JSON for the " + @@ -123,7 +124,7 @@ export class PaymentsController { } @Get("methods") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "List payment systems supported by the platform", description: @@ -136,7 +137,7 @@ export class PaymentsController { } @Get("checkout") - @IsPublic() + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Browser checkout redirect", description: diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index 513017ad6..b946e7537 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -7,6 +7,7 @@ import { Post, Patch, Query, + SetMetadata, UseGuards, } from "@nestjs/common"; import { @@ -17,7 +18,6 @@ import { ApiQuery, ApiResponse, } from "@nestjs/swagger"; -import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; import { SeatsService } from "./seats.service"; import { HoldSeatsDto } from "./seats.dto"; import { JwtGuard } from "../../common/jwt.guard"; @@ -30,6 +30,7 @@ export class SeatsController { // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Get seat map filtered by coach type", description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`, @@ -103,6 +104,7 @@ This makes it clear which segment of the route each seat is held for, enabling s } @Post("hold") + @SetMetadata('isPublic', true) @ApiOperation({ summary: "Hold seats for 15 minutes before booking (Public - Guest booking supported)", diff --git a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts index 88fe21ffd..6f2bbb73d 100644 --- a/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts +++ b/apps/edr-passenger-api/src/modules/system-config/system-config.controller.ts @@ -1,23 +1,38 @@ -import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Get, Patch, SetMetadata, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { SkipThrottle } from '@nestjs/throttler'; import { SystemConfigService } from './system-config.service'; import { IamGuard } from '../../common/iam-adapter'; import { Roles } from '../../common/roles.decorator'; -@ApiTags('System Config') -@ApiBearerAuth('IAM-auth') -@UseGuards(IamGuard) -@Roles('ADMIN') -@Controller('system-config') +@ApiTags('Config') +@Controller('config') export class SystemConfigController { constructor(private service: SystemConfigService) {} + @Get('fayda-status') + @SetMetadata('isPublic', true) + @SkipThrottle() + @ApiOperation({ summary: 'Get Fayda verification enabled status (public)' }) + getFaydaStatus() { + const enabled = process.env.VERIFAYDA_ENABLED !== 'false'; + return { enabled }; + } + @Get() + @ApiBearerAuth('IAM-auth') + @UseGuards(IamGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Get all system config (admin)' }) getAll() { return this.service.getAll(); } @Patch() + @ApiBearerAuth('IAM-auth') + @UseGuards(IamGuard) + @Roles('ADMIN') + @ApiOperation({ summary: 'Update system config (admin)' }) update(@Body() body: Record) { return this.service.updateMany(body); } diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index d4d68ad1f..029be67e2 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,8 @@ export class TicketsController { constructor(private service: TicketsService) {} @Post('generate/:bookingId') - @ApiOperation({ + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' }) @@ -18,6 +19,7 @@ export class TicketsController { } @Patch('update-seats/:bookingId') + @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Update ticket seats before final confirmation', description: 'Allows users to change selected seats after ticket generation. Removes old seat blocks and creates new ones for updated seats.' @@ -69,9 +71,8 @@ export class TicketsController { } @Get(':bookingRef') - @ApiOperation({ - summary: 'Get ticket with QR code and passenger details (public)', - }) + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get ticket with QR code and passenger details (public)' }) getByRef(@Param('bookingRef') ref: string) { return this.service.getByRef(ref); } 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 8e04d1a87..13e31c91a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -85,6 +85,7 @@ export class TicketsService { ticketNumber: t.barcodePayload, bookingRef: t.bookingRef, booking: { + id: t.booking.id, bookingRef: t.booking.bookingRef, status: t.booking.status, bookingType: t.booking.bookingType, diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index f1a4cd2e5..a3a7a4bcd 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -91,7 +91,7 @@ export default function PassengersPage() { case 'dateOfBirth': return p.dateOfBirth ? formatDate(p.dateOfBirth) : ''; case 'gender': return p.gender || ''; case 'nationality': return p.nationality || ''; - case 'verified': return p.nationalId ? 'Yes' : 'No'; + case 'verified': return p.faydaVerified ? 'Yes' : 'No'; default: return ''; } }); @@ -117,15 +117,15 @@ export default function PassengersPage() { ), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone }, - { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, + { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, { key: 'verified', label: 'Status', render: (p: any) => ( - - {p.nationalId ? 'Verified' : 'Unverified'} + + {p.faydaVerified ? 'Verified' : 'Unverified'} ), }, @@ -192,7 +192,7 @@ export default function PassengersPage() { {selectedPassenger && (() => { const p = selectedPassenger; const isVerified = !!p.faydaVerified || !!p.nationalId; - const tier = p.passenger?.loyalty?.tier || p.loyalty?.tier; + const tier = p.loyalty?.tier || p.loyaltyTier; const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE; return ( diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index fca15313f..68ece3408 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -11,6 +11,7 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; import { ticketsApi, apiClient, stationsApi, excessBaggageApi } from '@/lib/api'; import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; export default function TicketsPage() { const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' }); @@ -25,15 +26,23 @@ export default function TicketsPage() { const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); + const { user } = useAuthStore(); + // Excess baggage state const [excessModalOpen, setExcessModalOpen] = useState(false); const [excessTicket, setExcessTicket] = useState(null); const [excessKg, setExcessKg] = useState(''); const [excessCollectCash, setExcessCollectCash] = useState(false); - const [excessAgentId, setExcessAgentId] = useState(''); const [excessError, setExcessError] = useState(null); const [excessResult, setExcessResult] = useState(null); + const { data: agentData } = useQuery({ + queryKey: ['agent-me'], + queryFn: () => apiClient.get('/agents/me'), + enabled: !!user, + retry: false, + }); + const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (

{label}

@@ -105,7 +114,6 @@ export default function TicketsPage() { setExcessTicket(ticket); setExcessKg(''); setExcessCollectCash(false); - setExcessAgentId(''); setExcessError(null); setExcessResult(null); setExcessModalOpen(true); @@ -114,9 +122,11 @@ export default function TicketsPage() { const handleExcessSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!excessTicket) return; + const agentId = agentData?.id; + if (!agentId) { setExcessError('No agent profile found for your account'); return; } await excessMutation.mutateAsync({ - bookingId: excessTicket.bookingId, - agentId: excessAgentId, + bookingId: excessTicket.booking?.id ?? excessTicket.bookingId, + agentId, excessWeightKg: parseInt(excessKg), collectCash: excessCollectCash, }); @@ -371,6 +381,13 @@ export default function TicketsPage() { ]; const actions = [ + { + label: 'Baggage', + onClick: openExcessModal, + variant: 'secondary' as const, + icon: Package, + show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), + }, { label: 'Board', onClick: handleBoard, @@ -411,13 +428,6 @@ export default function TicketsPage() { variant: 'danger' as const, icon: Trash2, }, - { - label: 'Excess Baggage', - onClick: openExcessModal, - variant: 'secondary' as const, - icon: Package, - show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status), - }, ]; const stations = stationsData?.items || []; @@ -736,16 +746,16 @@ export default function TicketsPage() {
Booking: {excessTicket?.booking?.bookingRef}
-
- - setExcessAgentId(e.target.value)} - required - /> -
+ {agentData && ( +
+ Agent: {agentData.agentCode} +
+ )} + {!agentData && ( +
+ ⚠ No agent profile linked to your account. +
+ )}
apiClient.get>('/system-config'), - update: (data: Record) => apiClient.patch>('/system-config', data), + getAll: () => apiClient.get>('/config'), + update: (data: Record) => apiClient.patch>('/config', data), }; 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 f7cabc212..5644c9b56 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 @@ -4,7 +4,7 @@ export const dynamic = 'force-dynamic'; import { useRouter } from 'next/navigation'; import { useBookingStore } from '@/lib/booking-store'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { 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, FileText } from 'lucide-react'; @@ -29,10 +29,6 @@ export default function ConfirmationPage() { const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false); const confirmAttempted = useRef(false); - const confirmMutation = useMutation({ - mutationFn: () => apiClient.patch(`/bookings/${bookingId}/confirm`, { status: 'SUCCEEDED' }), - }); - const { data: _booking } = useQuery({ queryKey: ['booking', bookingId], queryFn: async (): Promise => { @@ -54,13 +50,20 @@ export default function ConfirmationPage() { useEffect(() => { if (bookingId && !confirmAttempted.current) { confirmAttempted.current = true; - confirmMutation.mutate(); - - apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { - console.error('Failed to generate ticket:', err); + + // Only generate ticket if booking is already CONFIRMED (e.g. wallet payment) + // For other payment methods, ticket is generated by the payment webhook after payment completes + apiClient.get(`/bookings/${bookingId}`).then((data: any) => { + if (data?.status === 'CONFIRMED') { + apiClient.post(`/tickets/generate/${bookingId}`).catch((err) => { + console.error('Failed to generate ticket:', err); + }); + } + }).catch((err) => { + console.error('Failed to fetch booking status:', err); }); } - }, [bookingId, confirmMutation]); + }, [bookingId]); const copyPNR = () => { if (pnr) { 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 d2ed43878..0a3e1d65b 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 @@ -380,6 +380,7 @@ export default function PassengersPage() { const router = useRouter(); const { searchCriteria, setPassengers, setCreateAccount } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); + const isInitialized = useAuthStore((s) => s.isInitialized); const [faydaEnabled, setFaydaEnabled] = useState(true); const [verificationStatus, setVerificationStatus] = useState>({}); const [saving, setSaving] = useState(false); @@ -435,8 +436,8 @@ export default function PassengersPage() { useEffect(() => { const populateForm = async () => { + if (!isInitialized) return; if (!isAuthenticated || !user?.id || !searchCriteria) { - console.log('Missing required data for population'); setFormInitialized(true); return; } @@ -475,7 +476,7 @@ export default function PassengersPage() { }; populateForm(); - }, [isAuthenticated, user, searchCriteria, setValue]); + }, [isInitialized, isAuthenticated, user, searchCriteria, setValue]); const openFaydaVerification = async (index: number) => { if (typeof window === 'undefined') return; diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index def68279c..fc06005f0 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -60,21 +60,14 @@ export default function PaymentPage() { const paymentMutation = useMutation({ mutationFn: async (data: any) => { - // For TELEBIRR and WAAFI, use the initiate endpoint - if (data.method === 'TELEBIRR' || data.method === 'WAAFI') { - const response = await apiClient.post('/payments/initiate', { + // For all payment methods, use the initiate endpoint + try { + return await apiClient.post("/payments/initiate", { bookingId: data.bookingId, method: data.method, paymentMethodId: data.paymentMethodId, - platform: 'web' + platform: 'web', }); - - return response; - } - - // For other payment methods, try the regular payment intent API - try { - return await apiClient.post("/payments/intent", data); } catch (error) { console.log("Payment API not available, using mock payment"); // Mock payment response diff --git a/apps/edr-passenger-web/portal/src/lib/api-client.ts b/apps/edr-passenger-web/portal/src/lib/api-client.ts index 1a29658fd..472c66e70 100644 --- a/apps/edr-passenger-web/portal/src/lib/api-client.ts +++ b/apps/edr-passenger-web/portal/src/lib/api-client.ts @@ -15,21 +15,21 @@ class ApiClient { this.client.interceptors.request.use((config) => { const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - if (token) { + if (token && token !== 'null' && token !== 'undefined') { config.headers.Authorization = `Bearer ${token}`; } return config; }); + const PUBLIC_PREFIXES = ['/config/', '/auth/login', '/auth/register', '/passengers/me']; + this.client.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - // Don't redirect if it's a login or register request (invalid credentials) - const isAuthEndpoint = error.config?.url?.includes('/auth/login') || - error.config?.url?.includes('/auth/register'); - - if (!isAuthEndpoint && typeof window !== 'undefined') { + const url: string = error.config?.url || ''; + const isPublic = PUBLIC_PREFIXES.some((p) => url.includes(p)); + if (!isPublic && typeof window !== 'undefined') { localStorage.removeItem('auth_token'); localStorage.removeItem('auth_user'); window.location.href = '/login'; From 2e138a73eaa9a58cf955a9edcf95d96cdc76b7bb Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Wed, 24 Jun 2026 22:56:32 +0300 Subject: [PATCH 20/50] Updating package --- apps/edr-freight-api/package.json | 2 +- apps/edr-passenger-api/package.json | 2 +- ...3.tgz => tria-plc-iamapi-common-0.7.4.tgz} | Bin 469487 -> 469487 bytes 3 files changed, 2 insertions(+), 2 deletions(-) rename local-packages/{tria-plc-iamapi-common-0.7.3.tgz => tria-plc-iamapi-common-0.7.4.tgz} (99%) diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 897a7b764..5b333f2b9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -37,7 +37,7 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index 6694f1c30..64637ee1a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -38,7 +38,7 @@ "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", "@types/bcrypt": "^6.0.0", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/local-packages/tria-plc-iamapi-common-0.7.3.tgz b/local-packages/tria-plc-iamapi-common-0.7.4.tgz similarity index 99% rename from local-packages/tria-plc-iamapi-common-0.7.3.tgz rename to local-packages/tria-plc-iamapi-common-0.7.4.tgz index 70dae003de177f14296cdd3f04ce31adf467d6ee..bb86dd1346da32ff4ba4b1c7f780c18da49c5d5a 100644 GIT binary patch delta 45 ycmaEVS?2v^nT8g|7N#xC8n2n0EZenSGXpUT5VLOAdd;@ij$_MThc65}Y#0E=&l4R0 delta 45 ycmaEVS?2v^nT8g|7N#xC8n2m}*0pQBW(HywAZFdJ^_p$39mneg#g_~_Y#0F6R}(@2 From 15ab9f906e4ee1cf3c33c2f6371ed6a0b0da2e03 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 23:37:55 +0000 Subject: [PATCH 21/50] feat(bookings): enhance booking process with customs clearing and document handling - Update bookings service to prioritize uploaded documents over profile snapshots. - Refactor pricing data seeder to remove unused service types and streamline cargo type seeding. - Add customs clearing information to BookingRouteServiceCard, displaying agent details if applicable. - Extend BookingDetail type to include customs clearing options. - Modify NewBookingPage to remove the scheduling step, integrating estimated shipment date into the route step. - Update StepIndicator to reflect the new step structure. - Revise document handling in StepDocuments to allow for user uploads while displaying onboarding documents. - Adjust Step2ServiceType to manage customs clearing agent input based on service type. - Implement shipment date input in Step4Route for one-time bookings. - Revise Step8Review to reflect changes in document handling and scheduling. --- .../src/modules/bookings/bookings.service.ts | 6 +- .../src/seed/pricing-data.seeder.ts | 152 ++++++++---------- .../detail/BookingRouteServiceCard.tsx | 45 +++++- .../backoffice/src/types/booking.ts | 4 +- .../src/pages/bookings/NewBookingPage.tsx | 149 +++++++++++++++-- .../new-booking-form/StepIndicator.tsx | 8 +- .../pages/bookings/new-booking-form/schema.ts | 4 +- .../new-booking-form/step-documents.tsx | 139 ++++++++++------ .../new-booking-form/step2-service-type.tsx | 134 ++++++++++----- .../bookings/new-booking-form/step4-route.tsx | 96 +++++++++-- .../new-booking-form/step8-review.tsx | 53 +++--- 11 files changed, 566 insertions(+), 224 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 45d2fbf7c..e05a3b35a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -480,7 +480,11 @@ export class BookingsService { // Reuse the booking profile's onboarding documents instead of asking the // customer to re-upload. Snapshot them onto the booking now (by reference), // so a later active-profile switch never changes this booking's documents. - if (companyProfileId) { + // + // Skip this when the customer uploaded documents for this booking — those + // per-booking files take precedence, so auto-attaching the profile snapshots + // would create duplicates. + if (companyProfileId && files.length === 0) { try { const onboardingFiles = await this.companiesService.getProfileOnboardingFiles(companyProfileId); 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 6f44baaef..d4694d78f 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -32,7 +32,7 @@ export class PricingDataSeeder { const prRepo = manager.getRepository(PriorityConfig); const rRepo = manager.getRepository(Rate); - await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.upsertReferenceData(manager, ctRepo, yRepo, slRepo); await this.seedDomesticRoute(manager, yRepo); await this.seedWeightLimits(wlRepo, ctRepo); await this.seedPriorityConfigs(prRepo); @@ -71,7 +71,6 @@ export class PricingDataSeeder { private async upsertReferenceData( manager: any, ctRepo: any, - stRepo: any, yRepo: any, slRepo: any, ): Promise { @@ -155,47 +154,7 @@ export class PricingDataSeeder { { conflictPaths: { code: true } }, ); - await stRepo.upsert( - [ - { - code: "RAIL_CONTAINER", - serviceName: "Rail Container Service", - description: "Standard rail container transport", - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 0, - isActive: true, - displayOrder: 1, - }, - { - code: "RAIL_FORWARDING", - serviceName: "Rail Forwarding Service", - description: "Rail transport with first/last mile and customs", - canBeBookedAlone: true, - includesFirstMile: true, - includesLastMile: true, - includesCustoms: true, - priorityBonusPoints: 15, - isActive: true, - displayOrder: 2, - }, - { - code: "RAIL_BULK", - serviceName: "Rail Bulk Transport", - description: "Bulk commodity rail transport", - canBeBookedAlone: true, - includesFirstMile: false, - includesLastMile: false, - includesCustoms: false, - priorityBonusPoints: 10, - isActive: true, - displayOrder: 3, - }, - ], - { conflictPaths: { code: true } }, - ); + await slRepo.upsert( [ @@ -238,53 +197,78 @@ export class PricingDataSeeder { { conflictPaths: { code: true } }, ); - await manager.getRepository(CargoType).upsert( + await this.seedCargoTypes(manager); + } + + /** + * Cargo types are a fixed two-level tree: two top-level groups — Bulk and + * Break Bulk — each with a set of commodity children. The groups are the + * stable parents the booking wizard renders; children carry the + * unit_of_measure used when reserving quantity (PER_TON for bulk commodities, + * PER_ITEM for break-bulk items like vehicles/machinery). + * + * Parents are upserted first, then re-read by code to resolve their ids so the + * children can be linked via parent_group_id (upsert doesn't return ids). + */ + private async seedCargoTypes(manager: any): Promise { + const repo = manager.getRepository(CargoType); + + const groups = [ + { code: "BULK", cargoTypeName: "Bulk", displayOrder: 1 }, + { code: "BREAK_BULK", cargoTypeName: "Break Bulk", displayOrder: 2 }, + ]; + await repo.upsert( + groups.map((g) => ({ ...g, isActive: true })), + { conflictPaths: { code: true } }, + ); + + const bulk = await repo.findOneBy({ code: "BULK" }); + const breakBulk = await repo.findOneBy({ code: "BREAK_BULK" }); + if (!bulk || !breakBulk) return; + + // Bulk commodities — measured by tonnage (PER_TON). + const bulkChildren = [ + { code: "SUGAR", cargoTypeName: "Sugar" }, + { code: "GRAIN", cargoTypeName: "Grain / Cereals" }, + { code: "WHEAT", cargoTypeName: "Wheat" }, + { code: "FERTILIZER", cargoTypeName: "Fertilizer" }, + { code: "CEMENT", cargoTypeName: "Cement / Clinker" }, + { code: "COAL", cargoTypeName: "Coal" }, + ]; + + // Break-bulk items — counted as whole units (PER_ITEM). + const breakBulkChildren = [ + { code: "CARS", cargoTypeName: "Cars / Vehicles" }, + { code: "MACHINERY", cargoTypeName: "Heavy Machinery" }, + { code: "STEEL", cargoTypeName: "Steel / Rebar" }, + { code: "PIPES", cargoTypeName: "Pipes" }, + { code: "TIMBER", cargoTypeName: "Timber" }, + ]; + + await repo.upsert( [ - { - code: "GRAIN", - cargoTypeName: "Grain / Cereals", - requiresDirectorApproval: false, + ...bulkChildren.map((c, i) => ({ + ...c, + parentGroupId: bulk.id, + unitOfMeasure: "PER_TON", isActive: true, - displayOrder: 1, - }, - { - code: "FERTILIZER", - cargoTypeName: "Fertilizer", - requiresDirectorApproval: false, + displayOrder: i + 1, + })), + ...breakBulkChildren.map((c, i) => ({ + ...c, + parentGroupId: breakBulk.id, + unitOfMeasure: "PER_ITEM", isActive: true, - displayOrder: 2, - }, - { - code: "CEMENT", - cargoTypeName: "Cement / Clinker", - requiresDirectorApproval: false, - isActive: true, - displayOrder: 3, - }, - { - code: "STEEL", - cargoTypeName: "Steel / Rebar", - requiresDirectorApproval: true, - isActive: true, - displayOrder: 4, - }, - { - code: "MACHINERY", - cargoTypeName: "Heavy Machinery", - requiresDirectorApproval: true, - isActive: true, - displayOrder: 5, - }, - { - code: "OTHER_BULK", - cargoTypeName: "Other Bulk Cargo", - requiresDirectorApproval: false, - isActive: true, - displayOrder: 6, - }, + displayOrder: i + 1, + })), ], { conflictPaths: { code: true } }, ); + + // Retire the old flat "Other Bulk Cargo" top-level type from earlier seeds so + // it no longer shows alongside the Bulk / Break Bulk groups. No-op on a fresh + // DB where it was never seeded. + await repo.update({ code: "OTHER_BULK" }, { isActive: false }); } private async seedDomesticRoute(manager: any, yRepo: any): Promise { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index a4976ec61..67851ab30 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -1,4 +1,4 @@ -import { Train, MapPin, ArrowRight } from "lucide-react"; +import { Train, MapPin, ArrowRight, FileText } from "lucide-react"; import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; @@ -44,6 +44,8 @@ export function BookingRouteServiceCard({ const serviceLabel = booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service"; + const includesCustoms = booking.serviceType?.includesCustoms; + const metrics = [ { label: "Trade direction", value: booking.tradeDirection }, { label: "Freight type", value: booking.freightType }, @@ -96,6 +98,47 @@ export function BookingRouteServiceCard({ ))} + + {includesCustoms ? ( + + + + + Customs clearing included automatically + + + + ) : booking.customsClearingAgent ? ( + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + + + + + ) : null} ); } diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index e8e6a6912..12648c2ad 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -157,6 +157,8 @@ export interface BookingDetail { firstMilePickupAddress?: string | null; lastMileDeliveryAddress?: string | null; equipmentReturn?: string; + customsClearingEnabled?: boolean; + customsClearingAgent?: string | null; contractSummary?: string | null; latestChangeRequestNote?: string | null; nextStep?: BookingNextStep | null; @@ -167,7 +169,7 @@ export interface BookingDetail { company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; - serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number }; + serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean }; cargoType?: BookingNamedRef; shippingLine?: BookingNamedRef; bookingContainers?: BookingContainerLine[]; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 22dd92dd7..0aa9336fa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -53,11 +53,22 @@ import { Step5CargoDetails, Step8Review, StepDocuments, - StepScheduling, } from "./new-booking-form/steps"; type PriceModalMode = "submit" | "draft"; +/** Human-readable label for a rate's charge unit (e.g. "per container"). */ +function formatPriceUnit(unit: string): string { + const map: Record = { + PER_CONTAINER: "per container", + PER_TON: "per ton", + PER_WAGON: "per wagon", + PER_KM: "per km", + FLAT: "flat", + }; + return map[unit] ?? unit.replace(/_/g, " ").toLowerCase(); +} + export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -238,15 +249,11 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); - const bookingType = form.watch("bookingType"); - const isGeneralContract = bookingType === "general_contract"; - // General contracts have no shipment date at creation — the Schedule step - // (id 5) is skipped; the date is chosen per order against the contract later. - const visibleSteps = useMemo( - () => STEPS.filter((s) => !(isGeneralContract && s.id === 5)), - [isGeneralContract], - ); + // The estimated shipment date lives in the Route step now; for general + // contracts that date field is simply hidden there (the date is chosen per + // order against the contract later). No dedicated schedule step remains. + const visibleSteps = useMemo(() => STEPS, []); const visibleStepIds = useMemo( () => visibleSteps.map((s) => s.id), [visibleSteps], @@ -633,10 +640,7 @@ export default function NewBookingPage() { isLoading={refDataLoading} /> )} - {step === 5 && ( - - )} - {step === 6 && } + {step === 6 && } {step === 7 && ( + {pricingData.lineItems.length > 0 && ( + + + Price breakdown + + + {pricingData.lineItems.map((item) => { + const hasUnit = + item.unitAmount != null && + item.quantity != null && + item.quantity > 0; + return ( + + + + {item.description} + + {hasUnit && ( + + {item.quantity!.toLocaleString()} ×{" "} + {item.unitAmount!.toLocaleString()} {item.currency} + {item.unit + ? ` · ${formatPriceUnit(item.unit)}` + : ""} + + )} + + + {item.amount.toLocaleString()} {item.currency} + + + ); + })} + + + )} + {priceChangeResult.lineItems && + priceChangeResult.lineItems.length > 0 && ( + + + Price breakdown + + + {priceChangeResult.lineItems.map((item) => { + const hasUnit = + item.unitAmount != null && + item.quantity != null && + item.quantity > 0; + return ( + + + + {item.description} + + {hasUnit && ( + + {item.quantity!.toLocaleString()} ×{" "} + {item.unitAmount!.toLocaleString()}{" "} + {item.currency} + {item.unit + ? ` · ${formatPriceUnit(item.unit)}` + : ""} + + )} + + + {item.amount.toLocaleString()} {item.currency} + + + ); + })} + + + )}
>> = { "extraRoutes", "isHazardous", "isRefrigerated", + // Estimated shipment date now lives in the Route step (one-time bookings only). + "scheduledDate", ], - 5: ["scheduledDate"], 6: ["documents"], 7: ["notes"], }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index 7cb091d68..e0ae67384 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -1,16 +1,32 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Group, Loader, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; import { CheckCircle2, FileText, FileUp } from "lucide-react"; +import { SmartFileInput } from "@edr/ui-common"; +import { type UseFormReturn } from "react-hook-form"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import { + type BookingDocuments, + type BookingFormInputValues, + type BookingFormValues, +} from "./schema"; import { StepCard, StepHeader } from "./shared"; -export interface OnboardingDoc { - name: string; - url: string; - size: number; - mimeType?: string; +type BookingForm = UseFormReturn< + BookingFormInputValues, + any, + BookingFormValues +>; + +/** Onboarding document setting code for the company's nationality. */ +function documentSettingCode(nationality: string | null | undefined): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; } -function formatSize(bytes: number): string { +function formatSize(bytes?: number): string { if (!bytes) return ""; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; @@ -18,57 +34,58 @@ function formatSize(bytes: number): string { } /** - * Read-only documents step: lists the documents the company uploaded during - * onboarding for the active operational profile. These are attached to the - * booking automatically at submission — the customer is never asked to re-upload. + * Editable documents step. Mirrors the company onboarding documents (TIN, + * passport, investment/commercial license, national ID, …) and lets the customer + * attach or replace them FOR THIS BOOKING. Selections are stored on the form's + * `documents` field and saved against the specific booking on submit — editing + * here never touches the company profile. + * + * The documents already on file from onboarding are shown as a reference so the + * customer can see what EDR already has; they only need to upload here if they + * want to override a document for this booking. */ -export function StepDocuments({ documents }: { documents: OnboardingDoc[] }) { - const total = documents.length; +export function StepDocuments({ form }: { form: BookingForm }) { + const auth = useAuth(); + + const nationality = auth.company?.company?.nationality as + | string + | null + | undefined; + + const docSettingQuery = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode(nationality) }, + }), + ); + + // Documents already on file from onboarding (read-only reference). + const onboardingDocs = (() => { + const profiles = auth.company?.company?.companyProfiles ?? []; + const active = + profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; + return active?.licenseFiles ?? []; + })(); + + const documents = (form.watch("documents") ?? {}) as BookingDocuments; + + const setDocuments = (next: Record) => { + form.setValue("documents", next, { shouldDirty: true }); + }; return ( } title="Documents" - description="The documents from your onboarding will be attached to this booking automatically. No re-upload is needed." + description="Attach the documents for this booking. They default to what you uploaded during onboarding — upload here only to override a document for this specific booking." /> - - 0 ? "#ECF6F1" : "#FBECEC", - color: total > 0 ? "#0A6F4D" : "#B42318", - }} - > - {total > 0 ? : } - - - {total > 0 - ? `${total} onboarding ${total === 1 ? "document" : "documents"} will be attached to this booking.` - : "No onboarding documents found on your active profile. You can add documents later from the booking page."} - - - - {total > 0 && ( - - {documents.map((doc, i) => ( + {onboardingDocs.length > 0 && ( + + + On file from your onboarding + + {onboardingDocs.map((doc, i) => ( - Uploaded + On file ))} )} + + + Documents for this booking + + + {docSettingQuery.isLoading ? ( + + + + ) : docSettingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. The documents + on file from your onboarding will be attached to this booking + automatically. + + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index e326f84e7..d11d09415 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -1,6 +1,6 @@ import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core"; import type { ReactNode } from "react"; -import { FileText, Layers, Train, Truck } from "lucide-react"; +import { FileText, Info, Layers, Train, Truck } from "lucide-react"; import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues } from "./schema"; @@ -40,7 +40,6 @@ export function Step2ServiceType({ serviceType ?? {}; const firstMileEnabled = form.watch("firstMile.enabled"); const lastMileEnabled = form.watch("lastMile.enabled"); - const customsClearingEnabled = form.watch("customsClearingEnabled"); const prevServiceType = useRef(serviceType); useEffect(() => { @@ -65,12 +64,17 @@ export function Step2ServiceType({ if (!prev || prev === serviceType) return; - if (!includesCustoms) + if (includesCustoms) { + form.setValue("customsClearingEnabled", true, { shouldDirty: true }); + form.setValue("customsClearingAgent", "", { shouldDirty: true }); + } else { form.setValue("customsClearingEnabled", false, { shouldDirty: true }); + form.setValue("customsClearingAgent", "", { shouldDirty: true }); + } }, [serviceTypeId, form]); const showServiceSections = - includesCustoms || includesFirstMile || includesLastMile; + serviceType != null || includesFirstMile || includesLastMile; return ( ( - } - title="Customs Clearing Service" - description="EDR handles customs documentation and clearance on your behalf." - checked={field.value ?? false} - onChange={(value) => { - field.onChange(value); - if (!value) { - form.setValue("customsClearingAgent", "", { - shouldDirty: true, - shouldValidate: true, - }); - } + {includesCustoms ? ( + + + - {customsClearingEnabled && ( - ( - - )} - /> - )} - + + + + + Customs Clearing Service + + + Customs documentation and clearance is included automatically with this service. + + + + + + Included + + + + + ) : ( + ( + + + + + + + + Customs Clearing Agent + + + Enter the name of the customs clearing agent for this shipment. + + + + + )} /> )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx index 168790f90..89cb293a9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx @@ -9,8 +9,10 @@ import { Stack, Switch, Text, + TextInput, } from "@mantine/core"; import { + CalendarDays, Flame, MapPin, Plus, @@ -18,7 +20,7 @@ import { Snowflake, Trash2, } from "lucide-react"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useFieldArray, @@ -48,8 +50,31 @@ export function Step4Route({ }) { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const operationType = form.watch("operationType"); const isGeneralContract = form.watch("bookingType") === "general_contract"; + // The operation chosen in step 0 fixes which end of the route is inside + // Ethiopia. Djibouti yards stand in for "outside Ethiopia" (the port), + // mirroring getRouteDirection / the backend's deriveTradeDirection: + // import → origin outside (Djibouti), destination Ethiopia + // export → origin Ethiopia, destination outside (Djibouti) + // intercity → both Ethiopia (domestic) + // _ff variants share the trade direction of their base operation. + const { originCountry, destinationCountry } = useMemo(() => { + switch (operationType) { + case "import": + case "import_ff": + return { originCountry: "Djibouti", destinationCountry: "Ethiopia" }; + case "export": + case "export_ff": + return { originCountry: "Ethiopia", destinationCountry: "Djibouti" }; + case "intercity": + return { originCountry: "Ethiopia", destinationCountry: "Ethiopia" }; + default: + return { originCountry: null, destinationCountry: null }; + } + }, [operationType]); + const { fields: extraRoutes, append: appendRoute, @@ -65,25 +90,40 @@ export function Step4Route({ return yardOptions .filter((o) => o.value !== destinationYard) .filter((o) => { - const dest = referenceData?.yard.find((y) => y.id === destinationYard); - if (!dest) return true; - const origin = referenceData?.yard.find((y) => y.id === o.value); - - // can't go from Djibouti to Djibouti - if (dest?.country === "Djibouti" && origin?.country == "Djibouti") - return false; - - return true; + if (!originCountry) return true; + const yard = referenceData?.yard.find((y) => y.id === o.value); + return yard?.country === originCountry; }); - }, [yardOptions, destinationYard]); + }, [yardOptions, destinationYard, originCountry, referenceData]); const destData = useMemo(() => { - return yardOptions.filter((o) => o.value !== originYard); - }, [yardOptions, originYard]); + return yardOptions + .filter((o) => o.value !== originYard) + .filter((o) => { + if (!destinationCountry) return true; + const yard = referenceData?.yard.find((y) => y.id === o.value); + return yard?.country === destinationCountry; + }); + }, [yardOptions, originYard, destinationCountry, referenceData]); const origin = referenceData?.yard.find((y) => y.id === originYard); const dest = referenceData?.yard.find((y) => y.id === destinationYard); const direction = getRouteDirection(origin, dest); + // Changing the operation type (step 0) can invalidate a yard already chosen + // here — e.g. switching import→export flips which end must be in Ethiopia. + // Clear any selection that no longer matches the operation's required country + // so the customer can't submit a route that contradicts the operation. + useEffect(() => { + if (originCountry && origin && origin.country !== originCountry) { + form.setValue("originYard", ""); + } + }, [originCountry, origin, form]); + useEffect(() => { + if (destinationCountry && dest && dest.country !== destinationCountry) { + form.setValue("destinationYard", ""); + } + }, [destinationCountry, dest, form]); + const directionStyle: Record = { EXPORT: "bg-sky-50 text-sky-800 border-sky-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200", @@ -117,6 +157,13 @@ export function Step4Route({ const quantityStep = isPerItem ? 1 : 0.01; const showRouteQuantity = isGeneralContract && !isContainer; + // Earliest selectable shipment date (today, local) for the date input's `min`. + const todayISODate = useMemo(() => { + const now = new Date(); + const tz = now.getTimezoneOffset() * 60000; + return new Date(now.getTime() - tz).toISOString().slice(0, 10); + }, []); + return ( )} + {/* Estimated shipment date — one-time bookings only. General contracts + pick the date per order drawn against the contract later. */} + {!isGeneralContract && ( + + ( + } + error={fieldState.error?.message} + value={field.value ?? ""} + onChange={(e) => field.onChange(e.currentTarget.value)} + radius="md" + /> + )} + /> + + )} {showRouteQuantity && ( , + ) + .filter(([, v]) => (Array.isArray(v) ? v.length > 0 : Boolean(v))) + .map(([key, v]) => ({ + name: Array.isArray(v) ? (v[0]?.name ?? key) : ((v as File).name ?? key), + })); + const onboardingDocsCount = attachedDocs.length || onboardingDocs.length; + const docsToShow = attachedDocs.length > 0 ? attachedDocs : onboardingDocs; const selectedCommodity = (() => { if (values.cargoType !== "bulk" || !referenceData) return null; @@ -347,13 +358,15 @@ export function Step8Review({ /> - } - title="Schedule" - onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)} - > - - + {!isGeneralContract && ( + } + title="Schedule" + onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)} + > + + + )} } @@ -400,7 +413,7 @@ export function Step8Review({ > {onboardingDocsCount > 0 ? ( - onboardingDocs.map((doc, i) => ( + docsToShow.map((doc, i) => ( - Uploaded + Attached )) @@ -421,13 +434,13 @@ export function Step8Review({ - No onboarding documents found on your active profile. + No documents attached yet. )} - Documents from your onboarding will be attached to this booking. + These documents will be attached to this booking. @@ -463,10 +476,12 @@ export function Step8Review({ done={Boolean(values.originYard && values.destinationYard)} label="Route selected" /> - + {!isGeneralContract && ( + + )} 0} - label="Onboarding documents attached" + label="Documents attached" /> From e0c30449333408fd30c399c510a68444c726d909 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 23:54:45 +0000 Subject: [PATCH 22/50] feat(train-sets): implement multi-locomotive support for train sets - Added TrainSetLocomotive entity to link multiple locomotives to a train set. - Updated TrainSet entity to include a OneToMany relationship with TrainSetLocomotive. - Modified the train scheduling logic to require at least two locomotives for a train set. - Enhanced the UI components to support selecting multiple locomotives. - Introduced new permissions for viewing customs clearance. - Updated migrations to create the train_set_locomotives table and backfill existing data. - Implemented utility functions for managing train numbers based on cargo type and direction. - Added tests for train number utilities to ensure correct functionality. --- .../1820000000011-AddTrainSetLocomotives.ts | 59 +++++ .../modules/bookings/bookings.controller.ts | 14 +- .../src/modules/bookings/bookings.service.ts | 39 +++ .../train-schedules.repository.ts | 1 + .../create-container-train-schedule.dto.ts | 23 +- .../train-scheduling/train-capacity.util.ts | 19 ++ .../train-number.util.spec.ts | 52 ++++ .../train-scheduling/train-number.util.ts | 68 ++++++ .../train-scheduling.module.ts | 2 + .../train-scheduling.service.spec.ts | 24 +- .../train-scheduling.service.ts | 228 +++++++++++++----- .../entities/train-set-locomotive.entity.ts | 31 +++ .../train-sets/entities/train-set.entity.ts | 6 + .../modules/train-sets/train-sets.module.ts | 3 +- .../src/seed/freight-permissions.registry.ts | 11 +- apps/edr-freight-web/backoffice/src/App.tsx | 37 ++- .../trainScheduling/AllocateBookingWizard.tsx | 29 ++- .../backoffice/src/lib/permissions.ts | 6 + .../TrainScheduleV2DetailPage.tsx | 22 +- .../TrainScheduleV2ListPage.tsx | 62 +++-- .../backoffice/src/types/trainScheduling.ts | 20 +- .../portal/src/components/AppLayout.tsx | 2 +- .../MyPortalPage/components/HelloSection.tsx | 2 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 6 +- .../portal/src/pages/bookings/MyBookings.tsx | 2 + .../src/pages/bookings/NewBookingPage.tsx | 24 +- .../new-booking-form/useBookingDraft.ts | 141 +++++++++++ .../src/pages/contracts/ContractsList.tsx | 2 +- 28 files changed, 817 insertions(+), 118 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/useBookingDraft.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts new file mode 100644 index 000000000..b014e71a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000011-AddTrainSetLocomotives.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Multi-locomotive train sets: a train set is now pulled by 2+ locomotives. + * + * Adds the `freight.train_set_locomotives` link table (train set ⇄ locomotive, + * with an order index) and backfills one row per existing train set from its + * current `locomotive_id`, so existing read paths keep resolving locomotives. + * The `train_sets.locomotive_id` column is retained as the "primary" locomotive. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class AddTrainSetLocomotives1820000000011 implements MigrationInterface { + name = 'AddTrainSetLocomotives1820000000011'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_set_locomotives ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_set_id uuid NOT NULL, + locomotive_id uuid NOT NULL, + sequence_no int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_train_set_locomotives" PRIMARY KEY (id), + CONSTRAINT "FK_train_set_locomotives_train_set" FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets (id) ON DELETE CASCADE, + CONSTRAINT "FK_train_set_locomotives_locomotive" FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives (id) + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_set_locomotives_set_loco" + ON freight.train_set_locomotives (train_set_id, locomotive_id); + `); + + // Backfill: one link row per existing train set, from its current primary loco. + await queryRunner.query(` + INSERT INTO freight.train_set_locomotives (train_set_id, locomotive_id, sequence_no) + SELECT ts.id, ts.locomotive_id, 0 + FROM freight.train_sets ts + WHERE ts.locomotive_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.train_set_locomotives tsl + WHERE tsl.train_set_id = ts.id AND tsl.locomotive_id = ts.locomotive_id + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_train_set_locomotives_set_loco";`, + ); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_locomotives;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 8197125f1..331706c5a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -134,6 +134,12 @@ export class BookingsController { if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { return this.bookingsService.findAll(filter); } + // Global Logistics has clearance:view but NOT bookings:view — it is scoped + // to the customs document-clearance queue only and never sees the general + // booking-request list. + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)) { + return this.bookingsService.findClearanceQueue(filter); + } const userId = user?.id; if (!userId) throw new UnauthorizedException('Authentication required'); const companyId = @@ -236,8 +242,12 @@ export class BookingsController { @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); - // Staff see any booking; customers only their own company's. - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + // Staff see any booking; Global Logistics (clearance:view) may inspect any + // booking for the clearance gate; customers only their own company's. + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, booking, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e05a3b35a..486e7eae3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -742,6 +742,45 @@ export class BookingsService { 'AWAITING_PAYMENT', ]; + /** + * Booking statuses that belong to the customs document-clearance queue. The + * Global Logistics role is scoped to ONLY these — it never sees the general + * booking-request list. + */ + private static readonly CLEARANCE_STATUSES = [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + ]; + + /** + * List bookings in the customs document-clearance queue. Used by Global + * Logistics (clearance:view) which has no general bookings:view — so the + * status set is force-scoped to clearance statuses and can't be widened to + * arbitrary bookings by a caller-supplied status filter. + */ + async findClearanceQueue( + filter: FilterBookingDto, + ): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 100; + // Honour a caller status filter only if it's within the clearance set; + // otherwise fall back to the full clearance status list. + const requested = filter.status; + const statuses = + requested && BookingsService.CLEARANCE_STATUSES.includes(requested) + ? [requested] + : BookingsService.CLEARANCE_STATUSES; + + return this.bookingsRepository.findAllPaginated({ + page, + pageSize, + statuses, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + /** * List the current customer's bookings that are ready for payment: * payable status AND not yet PAID. Company scope is derived from the diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 8ec002d49..58e71143c 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -25,6 +25,7 @@ export class TrainSchedulesRepository extends BaseRepository { route: true, trainSet: { locomotive: true, + locomotives: { locomotive: true }, wagons: { wagonType: true, physicalWagon: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 5c2486fa3..60aab2862 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -1,6 +1,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDateString, IsInt, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsUUID, + Min, +} from 'class-validator'; export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @@ -11,9 +20,15 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; - @ApiProperty({ format: 'uuid' }) - @IsUUID() - locomotiveId!: string; + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Locomotives pulling the train (minimum 2 — front and back)', + }) + @IsArray() + @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @IsUUID('all', { each: true }) + locomotiveIds!: string[]; @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 593bb7bee..5ec385924 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -69,6 +69,25 @@ export function deriveTrainCapacityFromLocomotive( export const MAX_FALLBACK_WEIGHT = 3500; export const MAX_FALLBACK_LENGTH = 760; +/** + * Effective pull limits for a train set with multiple locomotives: the weakest + * locomotive caps the train, so take the minimum pull weight and minimum length + * across all assigned locomotives. Returns null when no locomotives are given. + */ +export function minLocomotiveLimits( + locomotives: Array>, +): LocomotiveLimits | null { + if (!locomotives.length) return null; + return { + maxPullWeightTons: Math.min( + ...locomotives.map((l) => Number(l.maxPullWeightTons) || Infinity), + ), + maxTrainLengthMeters: Math.min( + ...locomotives.map((l) => Number(l.maxTrainLengthMeters) || Infinity), + ), + }; +} + /** Per-booking train length from wagon count and freight-specific wagon type length. */ export function bookingTrainLengthMeters( freightType: string | null | undefined, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts new file mode 100644 index 000000000..ce3d6166b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.spec.ts @@ -0,0 +1,52 @@ +import { + BULK_IMPORT_NUMBERS, + CONTAINER_EXPORT_NUMBERS, + CONTAINER_IMPORT_NUMBERS, + pickLowestFreeNumber, + pickTrainNumberPool, +} from './train-number.util'; + +describe('train-number.util', () => { + describe('pickTrainNumberPool', () => { + it('picks container export (odd) when container wagons dominate and direction is EXPORT', () => { + const pool = pickTrainNumberPool(5, 2, 'EXPORT'); + expect(pool.cargo).toBe('CONTAINER'); + expect(pool.direction).toBe('EXPORT'); + expect(pool.numbers).toEqual(CONTAINER_EXPORT_NUMBERS); + }); + + it('picks container import (even) when container wagons dominate and direction is IMPORT', () => { + const pool = pickTrainNumberPool(5, 2, 'IMPORT'); + expect(pool.numbers).toEqual(CONTAINER_IMPORT_NUMBERS); + }); + + it('picks bulk when bulk wagons dominate', () => { + const pool = pickTrainNumberPool(1, 9, 'IMPORT'); + expect(pool.cargo).toBe('BULK'); + expect(pool.numbers).toEqual(BULK_IMPORT_NUMBERS); + }); + + it('treats a tie as container', () => { + expect(pickTrainNumberPool(3, 3, 'EXPORT').cargo).toBe('CONTAINER'); + }); + + it('defaults DOMESTIC to the export/odd pool', () => { + expect(pickTrainNumberPool(5, 0, 'DOMESTIC').direction).toBe('EXPORT'); + expect(pickTrainNumberPool(5, 0, null).direction).toBe('EXPORT'); + }); + }); + + describe('pickLowestFreeNumber', () => { + it('returns the lowest unused number', () => { + expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, ['8001'])).toBe('8101'); + }); + + it('returns the first number when none are used', () => { + expect(pickLowestFreeNumber(CONTAINER_EXPORT_NUMBERS, [])).toBe('8001'); + }); + + it('returns null when the pool is exhausted', () => { + expect(pickLowestFreeNumber(BULK_IMPORT_NUMBERS, [...BULK_IMPORT_NUMBERS])).toBeNull(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts new file mode 100644 index 000000000..f4af19cc1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-number.util.ts @@ -0,0 +1,68 @@ +/** + * Fixed train-number pools assigned to a train on dispatch. + * + * The prefix encodes cargo type (8 = container, 1 = bulk) and the parity encodes + * trade direction (odd = export, even = import). Numbers are finite and recycle: + * a number is "in use" only while its train is DISPATCHED and not yet ARRIVED. + */ + +export const CONTAINER_EXPORT_NUMBERS = [ + '8001', '8101', '8201', '8301', '8401', '8501', '8601', '8701', '8801', '8901', +] as const; + +export const CONTAINER_IMPORT_NUMBERS = [ + '8002', '8102', '8202', '8302', '8402', '8502', '8602', '8702', '8802', '8902', +] as const; + +export const BULK_EXPORT_NUMBERS = ['1101', '1103', '1105', '1107'] as const; + +export const BULK_IMPORT_NUMBERS = ['1002', '1004', '1006', '1008'] as const; + +export type CargoKind = 'CONTAINER' | 'BULK'; +export type PoolDirection = 'IMPORT' | 'EXPORT'; + +export interface TrainNumberPool { + cargo: CargoKind; + /** EXPORT = odd numbers, IMPORT = even numbers. */ + direction: PoolDirection; + numbers: readonly string[]; +} + +/** + * Resolve which fixed pool a train draws from. + * + * - Cargo: container vs bulk by dominant wagon count; ties resolve to container. + * - Direction: EXPORT → odd pool, IMPORT → even pool. DOMESTIC (neither end is + * Djibouti) has no dedicated pool, so it defaults to the export/odd pool. + */ +export function pickTrainNumberPool( + containerWagons: number, + bulkWagons: number, + direction: 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null | undefined, +): TrainNumberPool { + const cargo: CargoKind = bulkWagons > containerWagons ? 'BULK' : 'CONTAINER'; + const poolDirection: PoolDirection = direction === 'IMPORT' ? 'IMPORT' : 'EXPORT'; + + const numbers = + cargo === 'CONTAINER' + ? poolDirection === 'IMPORT' + ? CONTAINER_IMPORT_NUMBERS + : CONTAINER_EXPORT_NUMBERS + : poolDirection === 'IMPORT' + ? BULK_IMPORT_NUMBERS + : BULK_EXPORT_NUMBERS; + + return { cargo, direction: poolDirection, numbers }; +} + +/** Lowest pool number not currently in use, or null when the pool is exhausted. */ +export function pickLowestFreeNumber( + pool: readonly string[], + usedNumbers: Iterable, +): string | null { + const used = new Set(usedNumbers); + for (const number of pool) { + if (!used.has(number)) return number; + } + return null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 64112d720..e38fc4d75 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -7,6 +7,7 @@ import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Route } from '../routes/entities/route.entity'; +import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetsModule } from '../train-sets/train-sets.module'; @@ -30,6 +31,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; WagonType, TrainSet, TrainSetWagon, + TrainSetLocomotive, Route, Wagon, Container, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 163008ecc..6f22f7a83 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -389,8 +389,12 @@ describe('TrainSchedulingService', () => { isActive: true, }; + const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' }; const lockedLocomotiveRepo = { - findOne: jest.fn().mockResolvedValue(locomotive), + findOne: jest + .fn() + .mockResolvedValueOnce(locomotive) + .mockResolvedValueOnce(locomotive2), update: jest.fn().mockResolvedValue(undefined), }; const trainScheduleRepo = { @@ -401,6 +405,10 @@ describe('TrainSchedulingService', () => { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), }; + const trainSetLocomotiveRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + }; const manager = { getRepository: jest.fn((entity: { name?: string }) => { switch (entity?.name) { @@ -410,13 +418,14 @@ describe('TrainSchedulingService', () => { return trainScheduleRepo; case 'TrainSet': return trainSetRepo; + case 'TrainSetLocomotive': + return trainSetLocomotiveRepo; default: throw new Error(`Unexpected transaction repository ${entity?.name}`); } }), }; - jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); dataSource.getRepository.mockImplementation((entity: unknown) => { if ((entity as { name?: string })?.name === 'Route') { return { findOne: jest.fn().mockResolvedValue(route) }; @@ -437,12 +446,16 @@ describe('TrainSchedulingService', () => { const result = await service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', - locomotiveId: 'loc-1', + locomotiveIds: ['loc-1', 'loc-2'], }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); - expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); + expect(trainSetLocomotiveRepo.save).toHaveBeenCalled(); + expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith( + { id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) }, + { status: 'ASSIGNED' }, + ); expect(result.id).toBe('schedule-1'); }); @@ -508,7 +521,6 @@ describe('TrainSchedulingService', () => { })), }; - jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); dataSource.getRepository.mockImplementation((entity: { name?: string }) => { if (entity?.name === 'Route') { return { @@ -531,7 +543,7 @@ describe('TrainSchedulingService', () => { service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', - locomotiveId: 'loc-1', + locomotiveIds: ['loc-1', 'loc-2'], }), ).rejects.toBeInstanceOf(ConflictException); }); 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 3928376ce..6cf009562 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 @@ -22,6 +22,7 @@ import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; import { Route } from '../routes/entities/route.entity'; +import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; @@ -79,8 +80,10 @@ import { pickBulkWagonType, } from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; +import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { deriveTrainCapacityFromLocomotive, + minLocomotiveLimits, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { @@ -288,31 +291,42 @@ export class TrainSchedulingService { async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { const route = await this.getActiveRoute(dto.routeId); - const locomotive = await this.selectOrValidateLocomotive(dto.locomotiveId, 0, 0); + + const locomotiveIds = [...new Set(dto.locomotiveIds)]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } const createdScheduleId = await this.dataSource.transaction(async (manager) => { - const lockedLocomotive = await manager.getRepository(Locomotive).findOne({ - where: { id: locomotive.id }, - lock: { mode: 'pessimistic_write' }, - }); - if (!lockedLocomotive) { - throw new NotFoundException(`Locomotive ${locomotive.id} not found`); - } - if (lockedLocomotive.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${lockedLocomotive.code} is not available`); + // Lock and validate every locomotive: all must be AVAILABLE and at the origin yard. + const lockedLocomotives: Locomotive[] = []; + for (const locomotiveId of locomotiveIds) { + const locked = await manager.getRepository(Locomotive).findOne({ + where: { id: locomotiveId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) { + throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + } + if (locked.status !== 'AVAILABLE') { + throw new ConflictException(`Locomotive ${locked.code} is not available`); + } + if (locked.currentYardId !== route.originYardId) { + throw new ConflictException( + `Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`, + ); + } + lockedLocomotives.push(locked); } const direction = deriveScheduleDirection( route.originYard ?? { country: null }, route.destinationYard ?? { country: null }, ); - if (lockedLocomotive.currentYardId !== route.originYardId) { - throw new ConflictException( - `Locomotive ${lockedLocomotive.code} is at yard ${lockedLocomotive.currentYardId} but schedule originates from ${route.originYardId}`, - ); - } - const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotive); + const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); + // Effective capacity is capped by the weakest locomotive in the set. + const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, @@ -322,11 +336,14 @@ export class TrainSchedulingService { status: TrainScheduleStatusEnum.Draft, direction, maxWagons: ( - await this.resolveTrainLimitConfig(dto, lockedLocomotive) + await this.resolveTrainLimitConfig(dto, limitLoco) ).maxWagonsPerTrain, }); const saved = await manager.getRepository(TrainSchedule).save(schedule); - await manager.getRepository(Locomotive).update(lockedLocomotive.id, { status: 'ASSIGNED' }); + await manager.getRepository(Locomotive).update( + { id: In(lockedLocomotives.map((l) => l.id)) }, + { status: 'ASSIGNED' }, + ); return saved.id; }); @@ -375,8 +392,9 @@ export class TrainSchedulingService { maxWagonsPerTrain: dto.maxWagonsPerTrain, }; - const locomotive = schedule.trainSet.locomotive; - const limits = await this.resolveTrainLimitConfig(previewDto, locomotive ?? undefined); + const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet); + const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined; + const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco); const validation = await this.validateBookingsForScheduling( previewDto, freightType ?? null, @@ -408,17 +426,17 @@ export class TrainSchedulingService { const totalWeightTons = validation.summary.totalWeightTons; const totalLengthMeters = validation.summary.totalLengthMeters; - if (!locomotive) { - throw new BadRequestException('Schedule train set has no locomotive'); + if (!limitLoco) { + throw new BadRequestException('Schedule train set has no locomotives'); } - if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + if (limitLoco.maxPullWeightTons < totalWeightTons) { throw new BadRequestException( - `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + `Train set locomotives cannot pull ${totalWeightTons}T`, ); } - if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) { + if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { throw new BadRequestException( - `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`, + `Train set locomotives cannot support ${totalLengthMeters}m`, ); } @@ -681,10 +699,12 @@ export class TrainSchedulingService { const now = new Date(); await this.dataSource.transaction(async (manager) => { + const trainNumber = await this.assignTrainNumber(manager, schedule); + await this.trainSchedulesRepository.updateStatus( scheduleId, TrainScheduleStatusEnum.Dispatched, - { actualDepartureAt: now }, + { actualDepartureAt: now, trainNumber }, manager, ); if (schedule.trainSetId) { @@ -718,6 +738,60 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } + /** + * Assign a fixed train number on dispatch. The number is drawn from the pool + * for the train's dominant cargo type (container vs bulk) and trade direction + * (export = odd, import = even). Numbers recycle once a train ARRIVES, so the + * "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so + * concurrent dispatches can't grab the same number. Throws when the pool is + * exhausted. Idempotent: returns the existing number if already assigned. + */ + private async assignTrainNumber( + manager: EntityManager, + schedule: TrainSchedule, + ): Promise { + if (schedule.trainNumber) return schedule.trainNumber; + + // Count container vs bulk wagons from the planned allocations. + let containerWagons = 0; + let bulkWagons = 0; + for (const wagon of schedule.trainSet?.wagons ?? []) { + const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK'); + if (isBulk) bulkWagons += 1; + else containerWagons += 1; + } + + const direction = + (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? + (schedule.originStation && schedule.destinationStation + ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation) + : null); + + const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction); + + // Lock the set of currently-active numbered schedules so two concurrent + // dispatches serialize and can't both claim the same lowest-free number. + const activeNumbered = await manager + .getRepository(TrainSchedule) + .createQueryBuilder('schedule') + .setLock('pessimistic_write') + .where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched }) + .andWhere('schedule.train_number IS NOT NULL') + .getMany(); + + const usedNumbers = activeNumbered + .map((s) => s.trainNumber) + .filter((n): n is string => Boolean(n)); + + const number = pickLowestFreeNumber(pool.numbers, usedNumbers); + if (!number) { + throw new ConflictException( + `No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`, + ); + } + return number; + } + /** Open or close a schedule's booking window (staff override). */ async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { await this.dataSource @@ -931,16 +1005,12 @@ export class TrainSchedulingService { }); } - if (schedule.trainSet?.locomotiveId) { - const loco = await manager - .getRepository(Locomotive) - .findOne({ where: { id: schedule.trainSet.locomotiveId } }); - if (loco) { - await manager.getRepository(Locomotive).update(loco.id, { - status: 'AVAILABLE', - currentYardId: schedule.destinationStationId, - }); - } + const arrivingLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (arrivingLocoIds.length) { + await manager.getRepository(Locomotive).update( + { id: In(arrivingLocoIds) }, + { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, + ); } for (const slot of schedule.trainSet?.wagons ?? []) { @@ -982,7 +1052,7 @@ export class TrainSchedulingService { async getContainerTrainSchedules() { const schedules = await this.trainSchedulesRepository.findAll({ relations: { - trainSet: { locomotive: true }, + trainSet: { locomotive: true, locomotives: { locomotive: true } }, route: true, originStation: true, destinationStation: true, @@ -1013,10 +1083,11 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } - if (schedule.trainSet?.locomotiveId) { - await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { - status: 'AVAILABLE', - }); + const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (cancelledLocoIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' }); } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { @@ -1251,24 +1322,29 @@ export class TrainSchedulingService { } } - let assignedLocomotive: Locomotive | null = null; + let assignedLocomotives: Locomotive[] = []; if (targetScheduleId) { const targetSchedule = await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId); - assignedLocomotive = targetSchedule?.trainSet?.locomotive ?? null; + assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet); } - if (assignedLocomotive) { - if (assignedLocomotive.currentYardId !== originYardId) { + if (assignedLocomotives.length) { + // Every locomotive of the set must sit at the origin yard, and the weakest + // one must still be able to pull the train (min limits across the set). + const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); + const setLimits = minLocomotiveLimits(assignedLocomotives); + if (offYard) { violations.push( - `Locomotive ${assignedLocomotive.code} is not at the schedule origin yard`, + `Locomotive ${offYard.code} is not at the schedule origin yard`, ); } else if ( - Number(assignedLocomotive.maxPullWeightTons) < totalWeightTons || - Number(assignedLocomotive.maxTrainLengthMeters) < totalLengthMeters + setLimits && + (setLimits.maxPullWeightTons < totalWeightTons || + setLimits.maxTrainLengthMeters < totalLengthMeters) ) { violations.push( - 'Assigned locomotive cannot support the total train weight and length', + 'Assigned locomotives cannot support the total train weight and length', ); } } else { @@ -1818,6 +1894,22 @@ export class TrainSchedulingService { } } + /** + * All locomotives attached to a loaded train set. Prefers the `locomotives` + * link rows; falls back to the legacy single `locomotive` for train sets + * created before multi-loco support. + */ + private locomotivesOfTrainSet( + trainSet: TrainSet | null | undefined, + ): Locomotive[] { + if (!trainSet) return []; + const linked = (trainSet.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)); + if (linked.length) return linked; + return trainSet.locomotive ? [trainSet.locomotive] : []; + } + async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, @@ -1841,15 +1933,28 @@ export class TrainSchedulingService { return locomotive; } - private async buildEmptyTrainSet(manager: EntityManager, locomotive: Locomotive) { + private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) { + const [primary] = locomotives; const trainSet = manager.getRepository(TrainSet).create({ - locomotiveId: locomotive.id, + // `locomotiveId` retained as the primary locomotive for single-loco read paths. + locomotiveId: primary.id, totalWeightTons: 0, totalLengthMeters: 0, wagonCount: 0, status: 'DRAFT', }); - return manager.getRepository(TrainSet).save(trainSet); + const saved = await manager.getRepository(TrainSet).save(trainSet); + + const links = locomotives.map((loco, index) => + manager.getRepository(TrainSetLocomotive).create({ + trainSetId: saved.id, + locomotiveId: loco.id, + sequenceNo: index, + }), + ); + await manager.getRepository(TrainSetLocomotive).save(links); + + return saved; } private async getActiveRoute(routeId: string) { @@ -1915,6 +2020,12 @@ export class TrainSchedulingService { currentYardId: schedule.trainSet.locomotive.currentYardId ?? null, } : null, + locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({ + id: loco.id, + code: loco.code, + name: loco.name ?? null, + currentYardId: loco.currentYardId ?? null, + })), wagonCount: schedule.trainSet?.wagonCount ?? 0, totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), @@ -1952,7 +2063,7 @@ export class TrainSchedulingService { bookingWindowStatus: 'OPEN', }, relations: { - trainSet: { locomotive: true }, + trainSet: { locomotive: true, locomotives: { locomotive: true } }, route: { milestones: true }, originStation: true, destinationStation: true, @@ -2099,6 +2210,15 @@ export class TrainSchedulingService { ), } : null, + locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({ + id: loco.id, + code: loco.code, + name: loco.name ?? null, + status: loco.status, + currentYardId: loco.currentYardId ?? null, + maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)), + maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)), + })), wagons: [...(schedule.trainSet.wagons ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((wagon) => ({ diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts new file mode 100644 index 000000000..4ad52a226 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-locomotive.entity.ts @@ -0,0 +1,31 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { TrainSet } from './train-set.entity'; + +/** + * Link row joining a train set to one of its locomotives. A train set must be + * pulled by at least two locomotives (front + back); `sequenceNo` is a plain + * order index — no front/rear semantics are modelled yet. + */ +@Entity({ schema: 'freight', name: 'train_set_locomotives' }) +@Index(['trainSetId', 'locomotiveId'], { unique: true }) +export class TrainSetLocomotive extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid' }) + trainSetId!: string; + + @ManyToOne(() => TrainSet, (trainSet) => trainSet.locomotives, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'sequence_no', type: 'int', default: 0 }) + sequenceNo!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index 9099824d5..fde6d75c6 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -3,6 +3,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } fro import { Locomotive } from '../../locomotives/entities/locomotive.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetLocomotive } from './train-set-locomotive.entity'; import { TrainSetWagon } from './train-set-wagon.entity'; export const TRAIN_SET_STATUSES = [ @@ -19,6 +20,7 @@ export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number]; @Index(['locomotiveId']) @Index(['status']) export class TrainSet extends BaseEntity { + /** Primary locomotive (first of the set). Kept for back-compat with single-loco read paths. */ @Column({ name: 'locomotive_id', type: 'uuid' }) locomotiveId!: string; @@ -26,6 +28,10 @@ export class TrainSet extends BaseEntity { @JoinColumn({ name: 'locomotive_id' }) locomotive?: Locomotive; + /** All locomotives pulling this train set (minimum 2). */ + @OneToMany(() => TrainSetLocomotive, (link) => link.trainSet) + locomotives?: TrainSetLocomotive[]; + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) totalWeightTons!: number; diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts index f11052727..19ed7ea73 100644 --- a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainSet } from './entities/train-set.entity'; +import { TrainSetLocomotive } from './entities/train-set-locomotive.entity'; import { TrainSetWagon } from './entities/train-set-wagon.entity'; import { TrainSetWagonsRepository } from './train-set-wagons.repository'; import { TrainSetsRepository } from './train-sets.repository'; @Module({ - imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])], + imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon, TrainSetLocomotive])], providers: [TrainSetsRepository, TrainSetWagonsRepository], exports: [TrainSetsRepository, TrainSetWagonsRepository], }) diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 669ea2153..224c72892 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -51,6 +51,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), + perm('a1000001-0001-4000-8000-000000000023', 'edr_freight_app:bookings:clearance_view', 'View customs-clearance queue'), perm('a1000001-0001-4000-8000-000000000020', 'edr_freight_app:bookings:review_documents', 'Review clearance documents'), perm('a1000001-0001-4000-8000-000000000021', 'edr_freight_app:bookings:upload_clearance_output', 'Upload customs output documents'), perm('a1000001-0001-4000-8000-000000000022', 'edr_freight_app:bookings:finalize_clearance', 'Finalize document clearance'), @@ -97,6 +98,7 @@ export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIO export const FREIGHT_PERMS = { bookings: { view: 'edr_freight_app:bookings:view', + clearanceView: 'edr_freight_app:bookings:clearance_view', staffAccept: 'edr_freight_app:bookings:staff_accept', requestChanges: 'edr_freight_app:bookings:request_changes', reject: 'edr_freight_app:bookings:reject', @@ -171,10 +173,13 @@ export const ROLE_PERMISSION_PRESETS = { ...allRuleEngineViewKeys(), ], finance: [FREIGHT_PERMS.bookings.view], - // Global Logistics: reviews post-counter-sign clearance documents, uploads - // customs output documents, and finalizes the clearance gate. + // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of + // the general booking-request list (no bookings:view) — instead a dedicated + // clearance:view permission lists the clearance bookings. Reviews customer + // clearance documents, uploads customs output documents, and finalizes the + // clearance gate. globalLogistics: [ - FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 462c97f9a..05b976c85 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -93,6 +93,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Booking requests", href: "/dashboard/booking-requests", icon: , + permission: FREIGHT_PERMS.bookings.view, }, { label: "Customers", @@ -367,7 +368,17 @@ const App = () => { } /> } /> - } /> + + + + } + /> { /> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> } + element={ + + + + } /> (null); const [routeId, setRouteId] = useState(""); const scheduleDate = booking.scheduledDate; - const [locomotiveId, setLocomotiveId] = useState(""); + const [locomotiveIds, setLocomotiveIds] = useState([]); const [extraBookingIds, setExtraBookingIds] = useState([]); const [forceAssign, setForceAssign] = useState(false); const [previewResult, setPreviewResult] = useState(null); @@ -152,7 +153,7 @@ export function AllocateBookingWizard({ useEffect(() => { if (scheduleMode === "new") { - setLocomotiveId(""); + setLocomotiveIds([]); } }, [routeId, scheduleMode]); @@ -264,11 +265,11 @@ export function AllocateBookingWizard({ const ensureSchedule = async (): Promise => { if (scheduleMode === "existing" && selectedScheduleId) return selectedScheduleId; - if (!routeId || !scheduleDate || !locomotiveId) { - throw new Error("Select route, date, and locomotive"); + if (!routeId || !scheduleDate || locomotiveIds.length < 2) { + throw new Error("Select route, date, and at least two locomotives"); } const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveId }, + payload: { routeId, scheduleDate, locomotiveIds }, }); setSelectedScheduleId(created.id); return created.id; @@ -526,17 +527,25 @@ export function AllocateBookingWizard({ onChange={(v) => setRouteId(v ?? "")} searchable /> - ({ value: l.id, label: `${l.code}${l.name ? ` — ${l.name}` : ""}`, }))} - value={locomotiveId || null} - onChange={(v) => setLocomotiveId(v ?? "")} + value={locomotiveIds} + onChange={setLocomotiveIds} searchable disabled={!routeId} + error={ + locomotiveIds.length > 0 && locomotiveIds.length < 2 + ? "Select at least two locomotives" + : undefined + } nothingFoundMessage={ routeId ? "No available locomotives for this corridor" : "Select a route first" } diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 3195281b2..cf3c43196 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -154,6 +154,13 @@ export interface TrainScheduleListItem { currentYardId?: string | null; } | null; + /** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */ + locomotives?: Array<{ + id: string; + code: string; + name?: string | null; + currentYardId?: string | null; + }>; wagonCount: number; totalWeightTons: number; totalLengthMeters: number; @@ -354,6 +361,16 @@ export interface TrainScheduleDetail { maxPullWeightTons: number; maxTrainLengthMeters?: number; } | null; + /** All locomotives pulling this train (≥2). Falls back to `locomotive` for legacy rows. */ + locomotives?: Array<{ + id: string; + code: string; + name?: string | null; + status: string; + currentYardId?: string | null; + maxPullWeightTons: number; + maxTrainLengthMeters?: number; + }>; wagons: Array<{ id: string; sequenceNo: number; @@ -462,7 +479,8 @@ export interface ReschedulePlan { export interface CreateTrainSchedulePayload { routeId: string; scheduleDate: string; - locomotiveId: string; + /** Locomotives pulling the train (minimum 2 — front and back). */ + locomotiveIds: string[]; maxTrainWeightTons?: number; maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 284497af2..761f59e08 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -478,7 +478,7 @@ export function AppLayout({ } color="edr-green" - onClick={() => navigate("/bookings/new")} + onClick={() => navigate("/bookings/new", { state: { fresh: true } })} > New Booking diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx index 3aaff33ab..ae7aa6c02 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/HelloSection.tsx @@ -26,7 +26,7 @@ export const HelloSection = memo(function HelloSection({ - + {} : undefined, - onRebook: () => navigate("/bookings/new"), + onRebook: () => navigate("/bookings/new", { state: { fresh: true } }), onSupport: () => navigate("/support"), }} /> @@ -110,14 +110,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) : "This booking process has been terminated." } reason={booking.latestChangeRequestNote} - onRebook={() => navigate("/bookings/new")} + onRebook={() => navigate("/bookings/new", { state: { fresh: true } })} /> ) : isExpired ? ( navigate("/bookings/new")} + onRebook={() => navigate("/bookings/new", { state: { fresh: true } })} /> ) : isPendingConsolidation ? ( } @@ -826,6 +827,7 @@ export default function MyBookings() { From 6ab9699c947e68fed1c1410704065d360a9cc163 Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 25 Jun 2026 03:01:56 +0300 Subject: [PATCH 23/50] refactor(first-mile): update module imports to use forwardRef for circular dependency resolution --- .../src/modules/first-mile/first-mile.module.ts | 4 ++-- apps/edr-freight-api/src/modules/payment/payment.module.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 713efa52d..382499da8 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,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; @@ -8,7 +8,7 @@ import { FirstMileRepository } from './first-mile.repository'; import { FirstMileService } from './first-mile.service'; @Module({ - imports: [TypeOrmModule.forFeature([FirstMile]), BookingsModule], + imports: [TypeOrmModule.forFeature([FirstMile]), forwardRef(() => BookingsModule)], controllers: [FirstMileController], providers: [FirstMileRepository, FirstMileService], exports: [FirstMileRepository, FirstMileService], diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e496bb867..d0eb2eecb 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -19,6 +19,7 @@ import { InternalPaymentController } from "./internal-payment.controller"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; +import { FirstMileModule } from "../first-mile/first-mile.module"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -29,6 +30,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; HttpModule.register({ timeout: 10_000 }), ConfigModule, DropdownSettingsModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), RabbitMQModule.forRootAsync({ From 08977fcd19783576ee45f34f006e82dd0a290f16 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 00:28:54 +0000 Subject: [PATCH 24/50] feat(bookings): add estimated shipment date handling and validation for binding shipment day --- .../1820000000012-AddEstimatedShipmentDate.ts | 26 ++ .../bookings/booking-transition.service.ts | 14 + .../src/modules/bookings/bookings.service.ts | 36 ++- .../bookings/dto/create-booking.dto.ts | 16 +- .../bookings/entities/booking.entity.ts | 13 + .../components/ClearanceCard.tsx | 245 +++++++++++++++++- .../src/pages/bookings/NewBookingPage.tsx | 11 +- .../new-booking-form/step2-service-type.tsx | 96 ++++++- .../portal/src/services/api.ts | 8 +- .../portal/src/services/bookings.service.ts | 10 +- packages/types/src/freight/index.ts | 4 +- 11 files changed, 452 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts new file mode 100644 index 000000000..6b77f53a3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000012-AddEstimatedShipmentDate.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The booking wizard now captures a NON-BINDING estimated shipment date instead + * of the binding scheduledDate. The binding scheduledDate (validated against + * open train departures) is set later, at the operation-request step. + */ +export class AddEstimatedShipmentDate1820000000012 + implements MigrationInterface +{ + name = 'AddEstimatedShipmentDate1820000000012'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS estimated_shipment_date timestamptz NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS estimated_shipment_date; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 2c2e0ce0e..b85f55206 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -795,6 +795,20 @@ export class BookingTransitionService { throw new BadRequestException('A valid schedule date is required'); } + // The binding shipment day must have at least one OPEN departure on the + // route — only schedule-backed days are selectable. The batch engine + // assigns the specific train within that (route, day) pool later. + const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay( + booking.originYardId, + booking.destinationYardId, + eatDay(date), + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + await this.bookingsRepository.update(bookingId, { status: 'OPERATION_REQUEST_PENDING', scheduledDate: date, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 486e7eae3..699d6ac22 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -317,12 +317,14 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } - } else if (!isGeneralContract) { - // Day-level pool: the customer picked a DAY — require that the route has at - // least one OPEN departure on that EAT day. The batch engine assigns the - // train later. General contracts skip this — they have no shipment date at - // creation; each drawdown order validates its own day. - const day = eatDay(new Date(dto.scheduledDate!)); + } else if (dto.scheduledDate) { + // A real (binding) scheduledDate was supplied (e.g. staff pinning a day + // directly). Require that the route has at least one OPEN departure on + // that EAT day. The booking wizard does NOT send scheduledDate at creation + // — it captures a non-binding estimatedShipmentDate instead, and the + // binding day is chosen later at the operation-request step. General + // contracts also skip this (each drawdown order validates its own day). + const day = eatDay(new Date(dto.scheduledDate)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, @@ -428,6 +430,9 @@ export class BookingsService { financialTerms: dto.financialTerms, bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, + estimatedShipmentDate: dto.estimatedShipmentDate + ? new Date(dto.estimatedShipmentDate) + : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', @@ -621,6 +626,8 @@ export class BookingsService { ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); + if (dto.estimatedShipmentDate) + updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); delete updates.containers; @@ -696,6 +703,23 @@ export class BookingsService { } /** Return a paginated list of bookings matching the filter. */ + /** + * Whether a route has at least one OPEN train departure on the given EAT day. + * Used to validate the binding shipment day chosen at the operation-request + * step (only days with a schedule are selectable). + */ + async hasOpenDepartureOnDay( + originYardId: string, + destinationYardId: string, + day: string, + ): Promise { + return this.trainSchedulingService.existsOpenScheduleOnRouteDay( + originYardId, + destinationYardId, + day, + ); + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index e66c85522..7f420d3f1 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -155,14 +155,24 @@ export class CreateBookingDto { bookingType?: string; /** - * The day the customer wants to ship (the pool day key). Required for one-time - * bookings; omitted for general contracts, which pick the date per order. + * The BINDING shipment day (the pool day key), validated against open train + * departures. Set later at the operation-request step — NOT at booking + * creation. Optional here; staff may still pin it directly. */ @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) - @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') + @IsOptional() @IsDateString() scheduledDate?: string; + /** + * Non-binding shipment-date estimate captured in the booking wizard. Purely + * informational — NOT validated against train departures. + */ + @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) + @IsOptional() + @IsDateString() + estimatedShipmentDate?: string; + @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) contractType!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 5693cfaf5..00f6e41c1 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -155,10 +155,23 @@ export class Booking extends BaseEntity { /** * Nullable: general contracts have no shipment date at creation — the date is * chosen per drawdown order. One-time bookings always set this (the pool day key). + * + * NOTE: this is the BINDING shipment day, validated against actual open train + * departures. It is set later, when the customer requests the operation — NOT + * at booking creation. See estimatedShipmentDate for the non-binding estimate + * captured in the booking wizard. */ @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) scheduledDate?: Date | null; + /** + * Non-binding shipment-date estimate captured in the booking wizard. Purely + * informational — NOT validated against train departures. The binding + * scheduledDate is chosen later at the operation-request step. + */ + @Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true }) + estimatedShipmentDate?: Date | null; + /** * General contracts only: when the ordering window closes, computed from the * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 59ab71ff9..e54b03be1 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -9,9 +9,24 @@ import { TextInput, } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + isToday, + startOfMonth, + startOfWeek, +} from "date-fns"; import { AlertCircle, + Calendar as CalendarIcon, + Check, CheckCircle2, + ChevronLeft, + ChevronRight, Clock, Download, FileText, @@ -86,6 +101,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const [adHoc, setAdHoc] = useState>( [], ); + // Binding shipment day chosen for the operation request (yyyy-MM-dd). + const [scheduledDate, setScheduledDate] = useState(""); const refresh = () => { queryClient.invalidateQueries({ @@ -339,6 +356,32 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { )} + {isReady && ( + + + Choose your shipment day + + + Only days with a scheduled departure on your route can be selected. + The operations team assigns the specific train for that day. + + + + )} + + {proceedMutation.isError && ( + } mt="md"> + {proceedMutation.error instanceof Error + ? proceedMutation.error.message + : "Could not request the operation. Please try again."} + + )} + {canUpload && ( @@ -375,3 +419,202 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { ); } + +/** + * Compact month calendar for picking the binding shipment day at the + * operation-request step. Only days that have an OPEN scheduled departure on the + * booking route are selectable; all other days are disabled. + */ +function OperationDatePicker({ + originYardId, + destinationYardId, + value, + onChange, +}: { + originYardId?: string; + destinationYardId?: string; + value: string; + onChange: (date: string) => void; +}) { + const [month, setMonth] = useState(() => startOfMonth(new Date())); + + const { data: availableDays, isLoading } = useQuery( + api.bookings.getAvailableDays.queryOptions({ + input: { originYardId, destinationYardId }, + enabled: !!originYardId && !!destinationYardId, + }), + ); + + const departureDays = useMemo( + () => new Set(availableDays ?? []), + [availableDays], + ); + + const cells = useMemo(() => { + const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 }); + const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 }); + return eachDayOfInterval({ start, end }).map((date) => { + const dateString = format(date, "yyyy-MM-dd"); + return { + date, + dateString, + day: date.getDate(), + inMonth: isSameMonth(date, month), + today: isToday(date), + selected: value === dateString, + hasDeparture: departureDays.has(dateString), + }; + }); + }, [month, departureDays, value]); + + return ( + + + + + {format(month, "MMMM yyyy")} + + + + + {isLoading ? ( + + + + Loading available days… + + + ) : ( + <> + + {["M", "T", "W", "T", "F", "S", "S"].map((d, i) => ( + + {d} + + ))} + + + {cells.map((c) => { + const clickable = c.hasDeparture && c.inMonth; + return ( + + ); + })} + + {value && ( + + Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")} + + )} + {!isLoading && departureDays.size === 0 && ( + + No scheduled departures found for this route yet. + + )} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 53fff0f1b..9ce4f4194 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -422,13 +422,14 @@ export default function NewBookingPage() { bookingType: isContract ? Freight.BookingType.GeneralContract : Freight.BookingType.OneTime, - // General contracts omit the shipment date — chosen per order later. - ...(isContract + // The wizard captures a NON-BINDING estimate only — never the binding + // scheduledDate (that is chosen later at the operation-request step and + // validated against open departures). General contracts omit even the + // estimate; the date is chosen per order later. + ...(isContract || !data.scheduledDate ? {} : { - scheduledDate: data.scheduledDate - ? new Date(data.scheduledDate).toISOString() - : new Date().toISOString(), + estimatedShipmentDate: new Date(data.scheduledDate).toISOString(), }), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx index d11d09415..2b72393f6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step2-service-type.tsx @@ -1,12 +1,11 @@ import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core"; import type { ReactNode } from "react"; -import { FileText, Info, Layers, Train, Truck } from "lucide-react"; +import { Check, FileText, Info, Layers, Train, Truck } from "lucide-react"; import { useEffect, useRef } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { fieldStyles, - OptionCard, OptionFieldError, StepCard, StepHeader, @@ -88,17 +87,14 @@ export function Step2ServiceType({ control={form.control} render={({ field, fieldState }) => (
-
+
{referenceData?.service .filter((s) => s.canBeBookedAlone) .map((s) => ( - field.onChange(s.id)} - icon={} - iconBg="#EEF0FB" - iconColor="#4F46E5" title={s.serviceName} description={s.description} /> @@ -365,6 +361,92 @@ export function Step2ServiceType({ ); } +/** + * Compact service-type selection card. A single horizontal row (icon · text · + * radio) — deliberately smaller than the shared OptionCard so the service list + * stays scannable. + */ +function ServiceTypeCard({ + selected, + onClick, + title, + description, +}: { + selected: boolean; + onClick: () => void; + title?: ReactNode; + description?: ReactNode; +}) { + return ( + + ); +} + function ServiceToggle({ icon, title, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index a8f76a1bf..6ba3aa84b 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -269,10 +269,14 @@ export const api = { bookingsService.submitClearanceDocuments(id, files), ), - proceedToOperation: endpoint<{ id: string }, Freight.IBooking>( + proceedToOperation: endpoint< + { id: string; scheduledDate: string }, + Freight.IBooking + >( "bookings", "proceedToOperation", - ({ id }) => bookingsService.proceedToOperation(id), + ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), ), checkPayment: endpoint<{ orderId: string }, { status: string }>( diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 2cebf6fea..6a8c76795 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -206,8 +206,14 @@ export const bookingsService = { return data.data; }, - proceedToOperation: async (id: string): Promise => { - const { data } = await client.post(`/api/bookings/${id}/clearance/proceed`); + proceedToOperation: async ( + id: string, + scheduledDate: string, + ): Promise => { + const { data } = await client.post( + `/api/bookings/${id}/clearance/proceed`, + { scheduledDate }, + ); return data.data; }, diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index adb1e85a5..f9b5ac92f 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -646,8 +646,10 @@ export interface CreateBookingDto { companyId?: string | undefined; trainId?: string | undefined; trainScheduleId?: string | undefined; - /** Optional for general contracts — they pick the date per order, not at creation. */ + /** Binding shipment day — set at the operation-request step, not at creation. */ scheduledDate?: string | undefined; + /** Non-binding shipment-date estimate captured in the booking wizard. */ + estimatedShipmentDate?: string | undefined; /** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */ bookingType?: BookingType | undefined; contractType: string; From c8377958a890e360fbd565f3bbdb9d16d9865aa5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 00:48:14 +0000 Subject: [PATCH 25/50] fix(routes): prevent synthetic event recycling issue in name input handler --- .../backoffice/src/pages/fleet/RoutesPage.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx index 7d530697f..30acf50ba 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/RoutesPage.tsx @@ -408,7 +408,13 @@ export default function RoutesPage() { setForm((current) => ({ ...current, name: e.currentTarget.value }))} + onChange={(e) => { + // Capture the value before the state updater runs — React may + // recycle the synthetic event, nulling currentTarget by the time + // the updater executes ("Cannot read properties of null"). + const name = e.currentTarget.value; + setForm((current) => ({ ...current, name })); + }} /> From 23b353999a9ac2e8a235ff3a0ba0c61edc4a78cd Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 25 Jun 2026 01:28:45 +0000 Subject: [PATCH 26/50] feat: enhance cargo details handling and add document clearance features - Improved handling of commodity selection in the cargo details form to prevent unwanted resets on re-renders. - Added `isReefer` flag to the CreateBookingDto interface for booking-level refrigerated status. - Introduced a new configuration file for clearance tabs to manage document clearance views. - Implemented DocumentClearanceDetailPage for detailed review and management of clearance documents. - Created DocumentClearanceListPage for listing and filtering clearance bookings with enhanced UI components. --- .../bookings/booking-pricing.service.ts | 6 + .../src/modules/bookings/bookings.service.ts | 20 + .../bookings/dto/create-booking.dto.ts | 11 + .../rule-engine/rule-engine.service.ts | 54 +- .../src/seed/pricing-data.seeder.ts | 8 +- apps/edr-freight-web/backoffice/src/App.tsx | 13 +- .../clearance/clearance-tabs.config.ts | 26 + .../bookings/DocumentClearanceDetailPage.tsx | 731 +++++++++++++++++ .../bookings/DocumentClearanceListPage.tsx | 615 ++++++++++++++ .../src/pages/bookings/GlClearancePage.tsx | 765 ------------------ .../src/pages/bookings/NewBookingPage.tsx | 22 +- .../bookings/new-booking-form/shared.tsx | 6 +- .../bookings/new-booking-form/step4-route.tsx | 43 +- .../new-booking-form/step5-cargo-details.tsx | 22 +- packages/types/src/freight/index.ts | 2 + 15 files changed, 1545 insertions(+), 799 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceListPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 0315de60d..f9d8fb17e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -276,6 +276,12 @@ export class BookingPricingService { allowConsolidation, shippingLineId: booking.shippingLineId, totalWagons, + // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). + // Container freight carries 0 here — its surcharges scale by container count. + bulkTons: + booking.freightType === 'BULK' + ? Number(booking.cargoTotalWeightVgm ?? 0) + : 0, containers, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 699d6ac22..9db946d26 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -126,8 +126,10 @@ export class BookingsService { paymentCurrency: string; tradeDirection: string; isHazardous?: boolean; + isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { const containerLines = @@ -166,10 +168,14 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection: dto.tradeDirection, isHazardous: dto.isHazardous ?? false, + // Bulk reefer comes from the customer toggle; container reefer is derived + // from the container type and ORed in by the engine. + isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, totalWagons, + bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, }; } @@ -387,8 +393,10 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous, + isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + bulkTons: dto.cargoTotalWeightVgm, containers, }); const ruleResult = await this.ruleEngineService.evaluate(evalInput); @@ -425,6 +433,10 @@ export class BookingsService { shippingLineId: dto.shippingLineId, cargoTotalWeightVgm: dto.cargoTotalWeightVgm, isHazardous: dto.isHazardous ?? false, + // Bulk reefer is the customer's toggle; container reefer is derived from + // the container type at pricing time, so the booking-level flag stays off + // for container freight to avoid double-counting. + isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, @@ -586,7 +598,9 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, tradeDirection, isHazardous: dto.isHazardous ?? existing.isHazardous, + isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); @@ -606,6 +620,12 @@ export class BookingsService { ...dto, freightType, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, + // Booking-level reefer is only meaningful for bulk; container reefer is + // derived from the container type at pricing time. + isReefer: + freightType === 'BULK' + ? (dto.isReefer ?? existing.isReefer ?? false) + : false, priorityScore: ruleResult.priorityScore, tradeDirection, }; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 7f420d3f1..ee5faa465 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -306,6 +306,17 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; + /** + * Booking-level refrigerated flag. For bulk freight this is the customer's + * reefer choice (containers derive reefer from the container type instead). + * ORed with per-container reefer when the REEFER surcharge is evaluated. + */ + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReefer?: boolean; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index ce0082f83..16027f9c3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -57,6 +57,12 @@ export interface BookingEvaluationInput { allowConsolidation?: boolean; shippingLineId?: string | null; totalWagons: number; + /** + * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale + * PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for + * container freight, which is scaled by container count instead. + */ + bulkTons?: number; containers: BookingContainerEvalInput[]; } @@ -224,16 +230,46 @@ export class RuleEngineService { }); if (!triggered) continue; - let triggerValue: number | null = null; - let calculatedAmount = Number(rate.rateValue); + // Surcharges scale by their own rateUnit, so the same trigger can bill the + // right way per freight shape — e.g. a PER_TON reefer rate multiplies the + // bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container + // count. triggerValue records the quantity billed (shown on the breakdown). + const rateValue = Number(rate.rateValue); + const containerCount = input.containers.reduce( + (sum, c) => sum + Number(c.quantity || 0), + 0, + ); + const overweightExcessTons = containerWeightResults.reduce( + (sum, r) => sum + (r.overweightExcessTons ?? 0), + 0, + ); - // Per-ton surcharges (typically OVERWEIGHT) bill against the excess tons. - if (rate.rateUnit === 'PER_TON' && rate.trigger === 'OVERWEIGHT') { - triggerValue = containerWeightResults.reduce( - (sum, r) => sum + (r.overweightExcessTons ?? 0), - 0, - ); - calculatedAmount = triggerValue * Number(rate.rateValue); + let triggerValue: number | null = null; + let calculatedAmount: number; + + switch (rate.rateUnit) { + case 'PER_TON': + // OVERWEIGHT bills the excess tons; every other PER_TON surcharge + // (e.g. bulk reefer) bills the full bulk tonnage. + triggerValue = + rate.trigger === 'OVERWEIGHT' + ? overweightExcessTons + : Number(input.bulkTons ?? 0); + calculatedAmount = triggerValue * rateValue; + break; + case 'PER_CONTAINER': + triggerValue = containerCount; + calculatedAmount = triggerValue * rateValue; + break; + case 'PER_WAGON': + triggerValue = input.totalWagons; + calculatedAmount = triggerValue * rateValue; + break; + case 'FLAT': + default: + // FLAT (and any unknown unit) bills once. + calculatedAmount = rateValue; + break; } // Safety guard: never include a surcharge with a non-positive amount (a 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 e4153c747..57fa1dc23 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -474,7 +474,13 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { // ── Surcharges (trigger-based) ────────────────────────────────────── { appliesTo: "OTHER", trigger: "OVERWEIGHT", rateType: "OVERWEIGHT_PER_TON", rateValue: 25, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "HAZARDOUS", rateType: "HAZARD_SURCHARGE", rateValue: 150, rateUnit: "FLAT" }, - { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 200, rateUnit: "FLAT" }, + // Reefer surcharge scales with the freight shape: container bookings bill + // per reefer container, bulk bookings bill per ton. The engine now honors + // each rate's unit, so both rows can coexist — only the matching one + // produces a non-zero line (the other multiplies by 0 and is dropped). + // Small test values (< 20) so the surcharge stays a minor add for now. + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" }, + { appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 05b976c85..7a14e3ffa 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -28,7 +28,8 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import GlClearancePage from "./pages/bookings/GlClearancePage"; +import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; +import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; @@ -417,7 +418,15 @@ const App = () => { path="clearance" element={ - + + + } + /> + + } /> diff --git a/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts b/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts new file mode 100644 index 000000000..b0166aa3f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/clearance/clearance-tabs.config.ts @@ -0,0 +1,26 @@ +import type { LucideIcon } from "lucide-react"; +import { Layers, ShieldCheck, ShipWheel, Truck } from "lucide-react"; + +/** + * The document-clearance queue is a single backend status + * (`DOCUMENTS_UNDER_REVIEW`); the tabs slice that queue by the operational axis + * that matters to a clearance officer — trade direction and customs scope — + * rather than by booking status (which is uniform here). + */ +export type ClearanceTabKey = "all" | "import" | "export" | "customs"; + +export interface ClearanceTab { + key: ClearanceTabKey; + label: string; + icon: LucideIcon; +} + +export const CLEARANCE_TABS: ClearanceTab[] = [ + { key: "all", label: "All", icon: Layers }, + { key: "import", label: "Import", icon: Truck }, + { key: "export", label: "Export", icon: ShipWheel }, + { key: "customs", label: "With customs", icon: ShieldCheck }, +]; + +/** The backend booking status that places a booking in the clearance queue. */ +export const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx new file mode 100644 index 000000000..9109ff9fe --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -0,0 +1,731 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Alert, + Badge, + Box, + Button, + FileButton, + Grid, + Group, + Loader, + Paper, + Progress, + RingProgress, + Stack, + Text, + Textarea, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { + AlertCircle, + ArrowRight, + CheckCircle2, + Clock, + Download, + ExternalLink, + FileCheck2, + FileText, + MessageSquareWarning, + PackageCheck, + ShieldCheck, + Upload, +} from "lucide-react"; +import toast from "react-hot-toast"; +import type { Freight } from "@edr/types"; + +import { PageContainer } from "@/components/page/PageContainer"; +import { PageHeader } from "@/components/page/PageHeader"; +import { SectionCard } from "@/components/bookings/detail"; +import { bookingsService } from "@/services/bookings.service"; +import { useBookingDetail } from "@/hooks/bookings/useBookings"; + +export default function DocumentClearanceDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + + const { data: booking } = useBookingDetail(id); + const { + data: clearance, + isLoading, + isError, + } = useQuery({ + queryKey: ["clearance", id], + queryFn: () => bookingsService.getClearance(id!), + enabled: Boolean(id), + }); + + const [queryNotes, setQueryNotes] = useState>({}); + const [openQuery, setOpenQuery] = useState>({}); + const [outputFiles, setOutputFiles] = useState>({}); + + const refresh = () => { + qc.invalidateQueries({ queryKey: ["clearance", id] }); + qc.invalidateQueries({ queryKey: ["clearance", "list"] }); + }; + + const reviewMutation = useMutation({ + mutationFn: (p: { + fileKey: string; + status: "APPROVED" | "QUERIED"; + note?: string; + }) => bookingsService.reviewClearanceDocument(id!, p), + onSuccess: (_d, p) => { + toast.success( + p.status === "APPROVED" ? "Document approved" : "Query sent to customer", + ); + if (p.status === "QUERIED") + setOpenQuery((o) => ({ ...o, [p.fileKey]: false })); + refresh(); + }, + onError: () => toast.error("Could not update document"), + }); + + const outputMutation = useMutation({ + mutationFn: () => bookingsService.uploadClearanceOutput(id!, outputFiles), + onSuccess: () => { + toast.success("Output documents uploaded"); + setOutputFiles({}); + refresh(); + }, + onError: () => toast.error("Upload failed"), + }); + + const finalizeMutation = useMutation({ + mutationFn: () => bookingsService.finalizeClearance(id!), + onSuccess: () => { + toast.success("Clearance finalized"); + refresh(); + navigate("/dashboard/clearance"); + }, + onError: (e) => + toast.error( + e instanceof Error ? e.message : "Could not finalize clearance", + ), + }); + + const customerDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), + [clearance], + ); + const glDocs = useMemo( + () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"), + [clearance], + ); + + const stats = useMemo(() => { + const total = customerDocs.length; + const approved = customerDocs.filter( + (d) => d.reviewStatus === "APPROVED", + ).length; + const queried = customerDocs.filter( + (d) => d.reviewStatus === "QUERIED", + ).length; + const pending = total - approved - queried; + const pct = total === 0 ? 0 : Math.round((approved / total) * 100); + return { total, approved, queried, pending, pct }; + }, [customerDocs]); + + const reference = booking?.reference ?? "Clearance"; + + if (isLoading) { + return ( + + + + Loading clearance… + + + ); + } + + if (isError || !clearance) { + return ( + + + }> + We couldn’t load this booking’s clearance. + + + ); + } + + return ( + + + } + > + All approved + + ) : ( + } + > + Review pending + + ) + } + /> + + {/* Hero */} + + + + {/* LEFT — document review */} + + + + {stats.approved}/{stats.total} approved + + } + > + + {customerDocs.length === 0 ? ( + + No customer documents are required for this booking. + + ) : ( + customerDocs.map((doc) => ( + + setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) + } + onNote={(v) => + setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) + } + onApprove={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "APPROVED", + }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + )) + )} + + + + {clearance.outputCode && ( + + + {glDocs.map((doc) => ( + + + + + {doc.label} + {doc.required ? " *" : ""} + + + + {doc.file ? ( + + + + + + ) : ( + + Not uploaded + + )} + + f && + setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + + + ))} + + + + + + )} + + + + {/* RIGHT — sticky summary + finalize */} + + + + + + + + {stats.pct}% + + + approved + + + } + /> + + + + + + + + + {finalizeMutation.isError && ( + } + > + {finalizeMutation.error instanceof Error + ? finalizeMutation.error.message + : "Could not finalize clearance."} + + )} + + + + + + + + Finalize clearance + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + + + + + ); +} + +function ClearanceHero({ + booking, + clearance, + stats, +}: { + booking: ReturnType["data"]; + clearance: Freight.ClearanceView; + stats: { pct: number; approved: number; total: number }; +}) { + const direction = booking?.tradeDirection ?? "—"; + const origin = + booking?.originYard?.label ?? booking?.originYard?.code ?? "Origin"; + const destination = + booking?.destinationYard?.label ?? + booking?.destinationYard?.code ?? + "Destination"; + + return ( + + + + + + + + + + {booking?.reference ?? "Clearance"} + + + {direction} + + {clearance.includesCustoms ? ( + } + > + Customs + + ) : null} + + + + {origin} + + + + {destination} + + + + + + + + + Document review + + + {stats.approved}/{stats.total} + + + + + + + ); +} + +function ProgressStat({ + color, + label, + value, +}: { + color: string; + label: string; + value: number; +}) { + return ( + + + {value} + + + + + {label} + + + + ); +} + +const STATUS_META: Record< + Freight.DocumentReviewStatus, + { label: string; color: string } +> = { + APPROVED: { label: "Approved", color: "edr-green" }, + QUERIED: { label: "Queried", color: "red" }, + PENDING: { label: "Pending", color: "edr-slate" }, +}; + +function DocReviewCard({ + doc, + note, + queryOpen, + onToggleQuery, + onNote, + onApprove, + onQuery, + busy, +}: { + doc: Freight.ClearanceDocument; + note: string; + queryOpen: boolean; + onToggleQuery: (open: boolean) => void; + onNote: (v: string) => void; + onApprove: () => void; + onQuery: () => void; + busy: boolean; +}) { + const status = doc.reviewStatus ?? "PENDING"; + const meta = STATUS_META[status]; + const hasFile = !!doc.file; + + return ( + + + + + + + + + {doc.label} + {doc.required ? " *" : ""} + + + {hasFile ? doc.file!.name : "Not uploaded by customer"} + + + + + + + {meta.label} + + {hasFile && ( + + + + )} + + + + {status === "QUERIED" && doc.note && ( + } + p="xs" + > + + {doc.note} + + + )} + + {hasFile && ( + + {!queryOpen ? ( + + + + + ) : ( + + + + + Describe the problem for the customer + + +