From 9d12d334d472158237f1543d9a98f2fdca14e6d4 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 4 Jun 2026 14:16:48 +0300 Subject: [PATCH 01/14] fix: key --- .../backoffice/src/hooks/rule-engine/useRuleEngine.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index 858975a41..a8f2536b9 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -76,9 +76,7 @@ export const useContainerTypeOptions = ( enabled = true, ) => useQuery({ - queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("container-types", { - includeNone, - }), + queryKey: api.ruleEngine.list.queryKey(), queryFn: () => api.ruleEngine.list.call({ resource: "container-types", From d61c3813cb9fcc5a07a58eee725f3a18040a5cc4 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 4 Jun 2026 14:17:33 +0300 Subject: [PATCH 02/14] feat(seed): Add file upload settings seeder for company onboarding --- apps/edr-freight-api/src/app.module.ts | 5 +- .../src/seed/file-upload-settings.seeder.ts | 125 ++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a4ecf321e..580e77e0c 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -30,6 +30,7 @@ import { } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; @Module({ imports: [ @@ -71,18 +72,20 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder"; BackofficeModule, DemoPermissionsModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, + private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, ) { } async onApplicationBootstrap() { await this.seeder.run(); await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); + await this.fileUploadSettingsSeeder.run(); } } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts new file mode 100644 index 000000000..2ef1f79f0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -0,0 +1,125 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; +import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; + +const COMPANY_ONBOARDING_DOCUMENTS = [ + { + code: "company_onboarding_documents_customer", + label: "Customer onboarding documents", + entity: "customer", + }, + { + code: "company_onboarding_documents_forwarder", + label: "Forwarder onboarding documents", + entity: "other", + }, + { + code: "company_onboarding_documents_transporter", + label: "Transporter onboarding documents", + entity: "other", + }, + { + code: "company_onboarding_documents_forwarder_dj", + label: "Djibouti forwarder onboarding documents", + entity: "other", + }, +] as const; + +const COMPANY_ONBOARDING_DESCRIPTION = + "Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers."; + +const COMPANY_ONBOARDING_FIELDS = [ + { + fileKey: "business_license", + fileLabel: "Business License / Trade License", + helpText: "Verified against the government trade system during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 1, + }, + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 2, + }, + { + fileKey: "national_id_passport", + fileLabel: "National ID / Passport", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + maxSizeMb: 10, + displayOrder: 3, + }, +] as const; + +@Injectable() +export class FileUploadSettingsSeeder { + private readonly logger = new Logger(FileUploadSettingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + const settingRepository = manager.getRepository(FileUploadSetting); + const fieldRepository = manager.getRepository(FileUploadField); + + for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) { + await settingRepository.upsert( + { + code: documentSetting.code, + label: documentSetting.label, + description: COMPANY_ONBOARDING_DESCRIPTION, + entity: documentSetting.entity, + }, + { + conflictPaths: { code: true }, + }, + ); + + const setting = await settingRepository.findOne({ + where: { code: documentSetting.code }, + select: { id: true, code: true }, + }); + + if (!setting) { + throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`); + } + + await fieldRepository.delete({ settingId: setting.id }); + + await fieldRepository.insert( + COMPANY_ONBOARDING_FIELDS.map((field, index) => ({ + settingId: setting.id, + fileKey: field.fileKey, + fileLabel: field.fileLabel, + helpText: field.helpText, + isRequired: field.isRequired, + isMultiple: field.isMultiple, + maxFiles: field.maxFiles, + allowedExtensions: [...field.allowedExtensions], + maxSizeMb: field.maxSizeMb, + displayOrder: field.displayOrder ?? index + 1, + })), + ); + } + }); + + this.logger.log( + "Ensured company onboarding file upload settings for external companies", + ); + } +} From 8ddd166dca1b89a39eb47606f99b0d47b08f7e2c Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 4 Jun 2026 14:30:49 +0300 Subject: [PATCH 03/14] feat: setup the ui for document uploading in accounts onboarding --- .../src/pages/accounts/CompanyProfileForm.tsx | 230 +++++--- .../src/pages/accounts/DjiboutiAgentForm.tsx | 207 ++++++-- .../ForwarderForm.tsx} | 412 ++++++++++----- .../src/pages/accounts/OnboardingPage.tsx | 30 +- .../src/pages/accounts/TransporterForm.tsx | 493 ++++++++++++------ 5 files changed, 981 insertions(+), 391 deletions(-) rename apps/edr-freight-web/portal/src/pages/{customers/on_boarding/CustomerOnboardingPage.tsx => accounts/ForwarderForm.tsx} (58%) 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 eb8d5a343..3f22d6179 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -14,10 +14,8 @@ import { ChevronLeft, UploadCloud, } from "lucide-react"; -import type { OnboardingUserType } from "./types"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import type { FileUploadSetting } from "@/types/fileUploadSettings"; import PhoneInput from "@/components/auth/PhoneInput"; import { Button, @@ -30,7 +28,7 @@ import { } from "@edr/ui-common"; import { api } from "@/services/api"; -type CompanyStep = "company" | "personnel" | "poa" | "documents"; +type CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm"; const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -85,6 +83,7 @@ const stepFields: Record = { ], poa: [], documents: [], + confirm: [], }; function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { @@ -116,13 +115,13 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } export default function CompanyProfileForm({ - userType, + documentSettingCode, user, onSubmit, isPending, onBack, }: { - userType: OnboardingUserType; + documentSettingCode: string; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; @@ -133,9 +132,9 @@ export default function CompanyProfileForm({ Record >({}); - const { data: uploadSettings = [], isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByEntity.queryOptions({ - input: { entity: "customer" }, + const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode }, refetchOnMount: false, }), ); @@ -144,6 +143,7 @@ export default function CompanyProfileForm({ register, handleSubmit, trigger, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(onboardingSchema), @@ -173,18 +173,20 @@ export default function CompanyProfileForm({ }, }); - const hasDocuments = uploadSettings.length > 0; + const formValues = watch(); + const hasDocuments = Boolean(uploadSetting?.fields?.length); + const totalSteps = 5; const nextStep = async () => { if (step === "poa") { - if (hasDocuments) { - setStep("documents"); - } else { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - } + setStep("documents"); return; } if (step === "documents") { + setStep("confirm"); + return; + } + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } @@ -201,8 +203,10 @@ export default function CompanyProfileForm({ setStep("company"); } else if (step === "poa") { setStep("personnel"); - } else { + } else if (step === "documents") { setStep("poa"); + } else { + setStep("documents"); } }; @@ -228,31 +232,41 @@ export default function CompanyProfileForm({ } active={step === "personnel"} - completed={step === "poa"} + completed={ + step === "poa" || step === "documents" || step === "confirm" + } /> } active={step === "poa"} - completed={hasDocuments ? step === "documents" : step === "personnel"} + completed={step === "documents" || step === "confirm"} + /> + } + active={step === "documents"} + completed={step === "confirm"} + /> + } + active={step === "confirm"} + completed={false} /> - {hasDocuments && ( - } - active={step === "documents"} - completed={false} - /> - )}

- {step === "company" && `Step 1 of ${hasDocuments ? 4 : 3} — Company Information`} - {step === "personnel" && `Step 2 of ${hasDocuments ? 4 : 3} — Personnel Details`} - {step === "poa" && `Step 3 of ${hasDocuments ? 4 : 3} — Power of Attorney (Optional)`} - {step === "documents" && "Step 4 of 4 — Upload Documents (Optional)"} + {step === "company" && + `Step 1 of ${totalSteps} — Company Information`} + {step === "personnel" && + `Step 2 of ${totalSteps} — Personnel Details`} + {step === "poa" && + `Step 3 of ${totalSteps} — Power of Attorney (Optional)`} + {step === "documents" && + `Step 4 of ${totalSteps} — Upload Documents (Optional)`} + {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}

onSubmit(buildPayload(data, user)))} + onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4" > @@ -353,11 +367,6 @@ export default function CompanyProfileForm({ {step === "personnel" && ( <> -

- Personal details are pulled from your account. Contact and - management info is collected below. -

-

Contact Person @@ -500,62 +509,155 @@ export default function CompanyProfileForm({ {step === "documents" && ( <> -

- Upload required documents for your registration. You can skip - this step and upload later from your account settings. -

- {loadingDocuments ? (
- ) : uploadSettings.length === 0 ? ( + ) : !uploadSetting ? (

No document requirements found for your account type.

) : (
- {uploadSettings.map((setting) => ( - - ))} +
)} )} + + {step === "confirm" && ( +
+
+

+ Review your registration +

+

+ Confirm the company details below before saving. +

+
+ +
+ + + + + + + + + + + + + + + + + +
+
+ )}
- +
+ +
); } +function ReviewRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+
+ {label} +
+
+ {value?.trim() ? value : "Not provided"} +
+
+ ); +} + function StepIcon({ icon, active, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index 9422dd3e2..e0d002858 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { @@ -10,6 +11,7 @@ import { CheckCircle2, Loader2, ChevronLeft, + UploadCloud, } from "lucide-react"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; @@ -21,9 +23,11 @@ import { FieldLabel, FieldError, FieldGroup, + SmartFileInput, } from "@edr/ui-common"; +import { api } from "@/services/api"; -type DjiboutiStep = "company" | "representative"; +type DjiboutiStep = "company" | "representative" | "documents" | "confirm"; const djiboutiSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -40,9 +44,23 @@ const djiboutiSchema = z.object({ type FormData = z.infer; -const stepLabels: Record = { - company: "Step 1 of 2 — Company Information", - representative: "Step 2 of 2 — Representative Details", +const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyPhoneCountryCode", + "companyLocation", + "companyAddress", + ], + representative: [ + "repName", + "repEmail", + "repPhone", + "repPhoneCountryCode", + ], + documents: [], + confirm: [], }; function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { @@ -64,22 +82,35 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { } export default function DjiboutiAgentForm({ + documentSettingCode, user, onSubmit, isPending, onBack, }: { + documentSettingCode: string; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode }, + refetchOnMount: false, + }), + ); const { register, handleSubmit, trigger, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(djiboutiSchema), @@ -97,32 +128,42 @@ export default function DjiboutiAgentForm({ }, }); + const formValues = watch(); + const hasDocuments = Boolean(uploadSetting?.fields?.length); + const totalSteps = 4; + const nextStep = async () => { if (step === "representative") { + setStep("documents"); + return; + } + if (step === "documents") { + setStep("confirm"); + return; + } + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } - const fields: (keyof FormData)[] = - step === "company" - ? [ - "companyName", - "companyEmail", - "companyPhone", - "companyPhoneCountryCode", - "companyLocation", - "companyAddress", - ] - : ["repName", "repEmail", "repPhone", "repPhoneCountryCode"]; + const fields = stepFields[step]; const isValid = await trigger(fields); if (!isValid) return; setStep("representative"); }; + const skipDocuments = () => { + setStep("confirm"); + }; + const prevStep = () => { if (step === "company") { onBack(); - } else { + } else if (step === "representative") { setStep("company"); + } else if (step === "documents") { + setStep("representative"); + } else { + setStep("documents"); } }; @@ -143,21 +184,34 @@ export default function DjiboutiAgentForm({ } active={step === "company"} - completed={step === "representative"} + completed={step !== "company"} /> } active={step === "representative"} + completed={step === "documents" || step === "confirm"} + /> + } + active={step === "documents"} + completed={step === "confirm"} + /> + } + active={step === "confirm"} completed={false} />

- {stepLabels[step]} + {step === "company" && `Step 1 of ${totalSteps} — Company Information`} + {step === "representative" && `Step 2 of ${totalSteps} — Representative Details`} + {step === "documents" && `Step 3 of ${totalSteps} — Upload Documents (Optional)`} + {step === "confirm" && `Step 4 of ${totalSteps} — Review & Confirm`}

onSubmit(buildPayload(data, user)))} + onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4" > @@ -262,35 +316,120 @@ export default function DjiboutiAgentForm({ )} + + {step === "documents" && ( + <> + {loadingDocuments ? ( +
+ +
+ ) : !uploadSetting ? ( +

+ No document requirements found for your account type. +

+ ) : ( +
+ +
+ )} + + )} + + {step === "confirm" && ( +
+
+

+ Review your registration +

+

+ Confirm the company details below before saving. +

+
+ +
+ + + + + + + + +
+
+ )}
- )} - + + +
); } +function ReviewRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+
+ {label} +
+
+ {value?.trim() ? value : "Not provided"} +
+
+ ); +} + function StepIcon({ icon, active, diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx similarity index 58% rename from apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx rename to apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 994dd751e..b933c4707 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/CustomerOnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { @@ -11,11 +11,11 @@ import { FileText, CheckCircle2, Loader2, + ChevronLeft, + UploadCloud, } from "lucide-react"; -import useAuth from "@/hooks/useAuth"; -import { api } from "@/services/api"; -import type { CreateCustomerDto } from "@/types/customers"; -import AuthLayout from "@/components/auth/AuthLayout"; +import type { AuthUser } from "@/types/auth"; +import type { CreateCompanyPayload } from "@/services/companies.service"; import PhoneInput from "@/components/auth/PhoneInput"; import { Button, @@ -24,14 +24,13 @@ import { FieldLabel, FieldError, FieldGroup, + SmartFileInput, } from "@edr/ui-common"; -import TransporterOnboarding from "./TransportrOnBoarding"; -import DjiboutiForwardingAgentForm from "./DjiboutiFreightForwardingAgent"; -import ImportExportOnBoarding from "./ImportExportOnBoarding"; +import { api } from "@/services/api"; -type OnboardingStep = "company" | "personnel" | "poa"; +type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm"; -const onboardingSchema = z.object({ +const forwarderSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), companyPhone: z.string().min(1, "Company phone is required"), @@ -59,9 +58,9 @@ const onboardingSchema = z.object({ poaLocation: z.string().optional(), }); -type FormData = z.infer; +type FormData = z.infer; -const stepFields: Record = { +const stepFields: Record = { company: [ "companyName", "companyEmail", @@ -83,20 +82,71 @@ const stepFields: Record = { "generalManagerPhoneCountryCode", ], poa: [], + documents: [], + confirm: [], }; -export default function CustomerOnboardingPage() { - const queryClient = useQueryClient(); - const { user } = useAuth(); - const [step, setStep] = useState("company"); +function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { + return { + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + tin: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + attributes: { + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }, + }; +} + +export default function ForwarderForm({ + documentSettingCode, + user, + onSubmit, + isPending, + onBack, +}: { + documentSettingCode: string; + user: AuthUser; + onSubmit: (data: CreateCompanyPayload) => void; + isPending: boolean; + onBack: () => void; +}) { + const [step, setStep] = useState("company"); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode }, + refetchOnMount: false, + }), + ); const { register, handleSubmit, trigger, + watch, formState: { errors }, } = useForm({ - resolver: zodResolver(onboardingSchema), + resolver: zodResolver(forwarderSchema), defaultValues: { companyName: "", companyEmail: "", @@ -123,20 +173,21 @@ export default function CustomerOnboardingPage() { }, }); - const createCustomerMutation = useMutation({ - mutationFn: (payload: CreateCustomerDto) => - api.customers.create.call(payload), - onSuccess: () => { - if (user) - queryClient.invalidateQueries({ - queryKey: api.customers.getByUserId.queryKey({ id: user.id }), - }); - }, - }); + const formValues = watch(); + const hasDocuments = Boolean(uploadSetting?.fields?.length); + const totalSteps = 5; const nextStep = async () => { if (step === "poa") { - handleSubmit(onSubmit)(); + setStep("documents"); + return; + } + if (step === "documents") { + setStep("confirm"); + return; + } + if (step === "confirm") { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } const fields = stepFields[step]; @@ -145,69 +196,36 @@ export default function CustomerOnboardingPage() { setStep(step === "company" ? "personnel" : "poa"); }; - const prevStep = () => { - if (step === "personnel") setStep("company"); - else if (step === "poa") setStep("personnel"); + const skipDocuments = () => { + setStep("confirm"); }; - const onSubmit = async (data: FormData) => { - const nameParts = (user?.name?.en ?? "").split(" "); - const payload: CreateCustomerDto = { - userId: user!.id, - firstName: nameParts[0] || "", - lastName: nameParts.slice(-1)[0] || "", - email: user!.email, - phone: user!.phoneNumber, - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, - tinNumber: data.tinNumber, - vatNumber: data.vatNumber, - fanNumber: data.fanNumber, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, - poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - }; - createCustomerMutation.mutate(payload); + const prevStep = () => { + if (step === "company") { + onBack(); + } else if (step === "personnel") { + setStep("company"); + } else if (step === "poa") { + setStep("personnel"); + } else if (step === "documents") { + setStep("poa"); + } else { + setStep("documents"); + } }; return ( - + <> +
+ - - {/* */} - {/* */} - {/*
} active={step === "personnel"} - completed={step === "poa"} + completed={step === "poa" || step === "documents" || step === "confirm"} /> } active={step === "poa"} + completed={step === "documents" || step === "confirm"} + /> + } + active={step === "documents"} + completed={step === "confirm"} + /> + } + active={step === "confirm"} completed={false} />

- {step === "company" && "Step 1 of 3 — Company Information"} - {step === "personnel" && "Step 2 of 3 — Personnel Details"} - {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"} + {step === "company" && + `Step 1 of ${totalSteps} — Company Information`} + {step === "personnel" && + `Step 2 of ${totalSteps} — Personnel Details`} + {step === "poa" && + `Step 3 of ${totalSteps} — Power of Attorney (Optional)`} + {step === "documents" && + `Step 4 of ${totalSteps} — Upload Documents (Optional)`} + {step === "confirm" && `Step 5 of ${totalSteps} — Review & Confirm`}

-
*/} +
- {/*
+ e.preventDefault()} + className="flex flex-col gap-4" + > {step === "company" && ( <> @@ -332,11 +369,6 @@ export default function CustomerOnboardingPage() { {step === "personnel" && ( <> -

- Personal details are pulled from your account. Contact and - management info is collected below. -

-

Contact Person @@ -418,88 +450,212 @@ export default function CustomerOnboardingPage() { {step === "poa" && ( <>

- Power of Attorney details are optional. Skip if not applicable. + Power of Attorney details are optional. Fill them in if you have + them, or skip to continue.

- + PoA Name +
- + PoA Email +
- + PoA Location + - + PoA Address +
)} + + {step === "documents" && ( + <> + {loadingDocuments ? ( +
+ +
+ ) : !uploadSetting ? ( +

+ No document requirements found for your account type. +

+ ) : ( +
+ +
+ )} + + )} + + {step === "confirm" && ( +
+
+

+ Review your registration +

+

+ Confirm the company details below before saving. +

+
+ +
+ + + + + + + + + + + + + + + + + +
+
+ )}
- - )} - + + +

- */} - + + + ); +} + +function ReviewRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+
+ {label} +
+
+ {value?.trim() ? value : "Not provided"} +
+
); } diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index a53b37ee5..8e8952ef7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowDownToLine, ArrowUpFromLine, @@ -13,6 +13,7 @@ import { api } from "@/services/api"; import type { CreateCompanyPayload } from "@/services/companies.service"; import AuthLayout from "@/components/auth/AuthLayout"; import CompanyProfileForm from "./CompanyProfileForm"; +import ForwarderForm from "./ForwarderForm"; import DjiboutiAgentForm from "./DjiboutiAgentForm"; import TransporterForm from "./TransporterForm"; import type { OnboardingUserType } from "./types"; @@ -113,18 +114,19 @@ const PREFLIGHT_LEFT = { }, }; +const DOCUMENT_SETTING_CODE_MAP: Record = { + importer: "company_onboarding_documents_customer", + exporter: "company_onboarding_documents_customer", + "freight-forwarder-et": "company_onboarding_documents_forwarder", + "freight-forwarder-dj": "company_onboarding_documents_forwarder_dj", + transporter: "company_onboarding_documents_transporter", +}; + export default function OnboardingPage() { const queryClient = useQueryClient(); const { user } = useAuth(); const [userType, setUserType] = useState(null); - useQuery( - api.fileUploadSettings.getByEntity.queryOptions({ - input: { entity: "customer" }, - refetchOnMount: false, - }), - ); - const COMPANY_TYPE_MAP: Record = { importer: "customer", exporter: "customer", @@ -237,6 +239,7 @@ export default function OnboardingPage() { {userType === "transporter" ? ( ) : userType === "freight-forwarder-dj" ? ( + ) : userType === "freight-forwarder-et" ? ( + ) : ( { - if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) { + if ( + data.truckType === "Casoni" && + (!data.plateNumber2 || data.plateNumber2.trim().length === 0) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["plateNumber2"], @@ -77,19 +89,34 @@ function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload { } export default function TransporterForm({ + documentSettingCode, user, onSubmit, isPending, onBack, }: { + documentSettingCode: string; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { + const [step, setStep] = useState("vehicle"); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode }, + refetchOnMount: false, + }), + ); + const { register, handleSubmit, + trigger, watch, control, formState: { errors }, @@ -108,187 +135,341 @@ export default function TransporterForm({ const truckType = watch("truckType"); const isCasoni = truckType === "Casoni"; + const formValues = watch(); + const hasDocuments = Boolean(uploadSetting?.fields?.length); + const totalSteps = 3; + + const nextStep = async () => { + if (step === "documents") { + setStep("confirm"); + return; + } + if (step === "confirm") { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + return; + } + const fields: (keyof FormData)[] = [ + "tinNumber", + "fanNumber", + "truckType", + "plateNumber", + "vehicleModel", + "yearOfManufacturing", + ]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep("documents"); + }; + + const skipDocuments = () => { + setStep("confirm"); + }; + + const prevStep = () => { + if (step === "vehicle") { + onBack(); + } else if (step === "documents") { + setStep("vehicle"); + } else { + setStep("documents"); + } + }; return ( <>
-
-
- -
+
+
+ } + active={step === "vehicle"} + completed={step !== "vehicle"} + /> + } + active={step === "documents"} + completed={step === "confirm"} + /> + } + active={step === "confirm"} + completed={false} + />

- Transporter Registration + {step === "vehicle" && `Step 1 of ${totalSteps} — Vehicle Information`} + {step === "documents" && `Step 2 of ${totalSteps} — Upload Documents (Optional)`} + {step === "confirm" && `Step 3 of ${totalSteps} — Review & Confirm`}

onSubmit(buildPayload(data, user)))} + onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4" > - {/* Personal Info (read-only) */} -
-
- - Account Holder + {step === "vehicle" && ( + <> +
+ + TIN Number (10 digits) + + + + + + FAN Number (16 digits) + + + +
+ +
+ +

+ Vehicle / Truck Information +

+ + ( + + Truck Type + + + + )} + /> + +
+ + Plate Number{isCasoni ? " (Front)" : ""} + + + + + {isCasoni && ( + + Plate Number (Trailer) + + + + )} + + {!isCasoni && ( + + Vehicle Model + + + + )} +
+ +
+ {isCasoni && ( + + Vehicle Model + + + + )} + + + Year of Manufacturing + + + +
+ + )} + + {step === "documents" && ( + <> + {loadingDocuments ? ( +
+ +
+ ) : !uploadSetting ? ( +

+ No document requirements found for your account type. +

+ ) : ( +
+ +
+ )} + + )} + + {step === "confirm" && ( +
+
+

+ Review your registration +

+

+ Confirm the details below before saving. +

+
+ +
+ + + + + {formValues.plateNumber2 && ( + + )} + + +
-

- {user.name?.en} — {user.email} — {user.phoneNumber} -

-
- -
- - TIN Number (10 digits) - - - - - - FAN Number (16 digits) - - - -
- -
- -

- Vehicle / Truck Information -

- - ( - - Truck Type - - - - )} - /> - -
- - - Plate Number{isCasoni ? " (Front)" : ""} - - - - - - {isCasoni && ( - - Plate Number (Trailer) - - - - )} - - {!isCasoni && ( - - Vehicle Model - - - - )} -
- -
- {isCasoni && ( - - Vehicle Model - - - - )} - - - Year of Manufacturing - - - -
+ )}
- - )} - + + +
); } + +function ReviewRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+
+ {label} +
+
+ {value?.trim() ? value : "Not provided"} +
+
+ ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} From 80454151132822e240b04100fffdba3900e2a8e1 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 4 Jun 2026 14:33:28 +0300 Subject: [PATCH 04/14] refactor: clean up --- .../portal/src/services/api.ts | 34 ----------- .../portal/src/services/customers.service.ts | 57 ------------------- 2 files changed, 91 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/services/customers.service.ts diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index bb5fcdcc7..115395f1a 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -89,40 +89,6 @@ export const api = { logout: endpoint("auth", "logout", authService.logout), }, - customers: { - list: endpoint( - "customers", - "list", - customersService.list, - ), - - get: endpoint<{ id: string }, Customer>("customers", "get", ({ id }) => - customersService.getById(id), - ), - - create: endpoint( - "customers", - "create", - customersService.create, - ), - - update: endpoint<{ id: string; dto: UpdateCustomerDto }, Customer>( - "customers", - "update", - ({ id, dto }) => customersService.update(id, dto), - ), - - remove: endpoint<{ id: string }, void>("customers", "remove", ({ id }) => - customersService.remove(id), - ), - - getByUserId: endpoint<{ id: string }, Customer | null>( - "customers", - "getByUserId", - ({ id }) => customersService.getByUserId(id), - ), - }, - companies: { getInfo: endpoint( "companies", diff --git a/apps/edr-freight-web/portal/src/services/customers.service.ts b/apps/edr-freight-web/portal/src/services/customers.service.ts deleted file mode 100644 index 3d809709d..000000000 --- a/apps/edr-freight-web/portal/src/services/customers.service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { client } from "@/utils/api"; -import { unwrap } from "@/utils/endpoint"; -import { URL_CONSTANTS } from "@/constants/URLS"; -import type { ApiResponse } from "@/types/apiResponse"; -import type { - CreateCustomerDto, - Customer, - UpdateCustomerDto, -} from "@/types/customers"; -import { isAxiosError } from "axios"; - -const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE; - -export const customersService = { - list: async (): Promise => { - const response = await client.get>(BASE); - return unwrap(response.data); - }, - - getById: async (id: string): Promise => { - const response = await client.get>( - URL_CONSTANTS.CUSTOMERS_API.BY_ID(id), - ); - return unwrap(response.data); - }, - - getByUserId: async (userId: string): Promise => { - try { - const response = await client.get>( - URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), - ); - return unwrap(response.data); - } catch (e) { - if (isAxiosError(e) && e.response?.status === 404) { - return null; - } - throw e; - } - }, - - create: async (payload: CreateCustomerDto): Promise => { - const response = await client.post>(BASE, payload); - return unwrap(response.data); - }, - - update: async (id: string, payload: UpdateCustomerDto): Promise => { - const response = await client.patch>( - URL_CONSTANTS.CUSTOMERS_API.BY_ID(id), - payload, - ); - return unwrap(response.data); - }, - - remove: async (id: string): Promise => { - await client.delete(URL_CONSTANTS.CUSTOMERS_API.BY_ID(id)); - }, -}; From ad895d695edf3eb1d4c10f81b1fe2ab945c6e175 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 4 Jun 2026 14:41:37 +0300 Subject: [PATCH 05/14] feat(companies): Introduce API endpoint and UI for company registration document uploads --- .../modules/companies/companies.controller.ts | 22 ++++++++++++++++--- .../src/modules/companies/companies.module.ts | 3 ++- apps/edr-freight-web/portal/src/App.tsx | 1 + .../portal/src/constants/URLS.ts | 1 + .../src/pages/accounts/CompanyProfileForm.tsx | 10 ++++++++- .../src/pages/accounts/DjiboutiAgentForm.tsx | 10 ++++++++- .../src/pages/accounts/ForwarderForm.tsx | 10 ++++++++- .../src/pages/accounts/OnboardingPage.tsx | 22 +++++++++++++++++-- .../src/pages/accounts/TransporterForm.tsx | 10 ++++++++- .../portal/src/services/companies.service.ts | 18 +++++++++++++++ 10 files changed, 97 insertions(+), 10 deletions(-) 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 5c646279e..31487e2bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -1,6 +1,8 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; +import { FilesService } from '../files/files.service'; import { CompaniesService } from './companies.service'; import { CreateCompanyDto } from './dto/create-company.dto'; import { UpdateCompanyDto } from './dto/update-company.dto'; @@ -22,7 +24,10 @@ interface CurrentIamUser { @ApiTags('Companies') @Controller('companies') export class CompaniesController { - constructor(private readonly companiesService: CompaniesService) {} + constructor( + private readonly companiesService: CompaniesService, + private readonly filesService: FilesService, + ) {} @Get('getInfo') @ApiOperation({ summary: 'Get company info for the current user' }) @@ -105,6 +110,17 @@ export class CompaniesController { await this.companiesService.deleteCompany(id); } + @Post(':companyId/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a company (onboarding)' }) + async uploadDocuments( + @Param('companyId', ParseUUIDPipe) companyId: string, + @UploadedFiles() files: Array, + ) { + return this.filesService.uploadMany(companyId, 'companies', files); + } + @Post(':companyId/profiles') @ApiOperation({ summary: 'Add a profile (employee) to a company' }) async createProfile( 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 8fac2f901..d18573460 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { FilesModule } from '../files/files.module'; import { CompaniesController } from './companies.controller'; import { CompaniesService } from './companies.service'; import { CompaniesRepository } from './companies.repository'; @@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity'; import { FFClient } from './entities/ff-client.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])], + imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule], controllers: [CompaniesController], providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository], exports: [CompaniesService], diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index cab3bfd1f..3e065169f 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -46,6 +46,7 @@ const App = () => { const navigate = useNavigate(); const location = useLocation(); const { user, isPending, logout, customer, customerQuery } = useAuth(); + useEffect(() => { if (isPending) return; const isInProtectedRoutes = sidebarItems.find((item) => diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 032f06fee..4fb326462 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -82,6 +82,7 @@ export const URL_CONSTANTS = { COMPANIES_API: { GET_INFO: "/api/companies/getInfo", CREATE: "/api/companies/create", + DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, BOOKINGS: { 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 3f22d6179..e86c30132 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -116,21 +116,29 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { export default function CompanyProfileForm({ documentSettingCode, + documentFiles: controlledFiles, + onDocumentFilesChange, user, onSubmit, isPending, onBack, }: { documentSettingCode: string; + documentFiles?: Record; + onDocumentFilesChange?: ( + files: Record, + ) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); - const [documentFiles, setDocumentFiles] = useState< + const [internalFiles, setInternalFiles] = useState< Record >({}); + const documentFiles = controlledFiles ?? internalFiles; + const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index e0d002858..2730c878d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -83,21 +83,29 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { export default function DjiboutiAgentForm({ documentSettingCode, + documentFiles: controlledFiles, + onDocumentFilesChange, user, onSubmit, isPending, onBack, }: { documentSettingCode: string; + documentFiles?: Record; + onDocumentFilesChange?: ( + files: Record, + ) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); - const [documentFiles, setDocumentFiles] = useState< + const [internalFiles, setInternalFiles] = useState< Record >({}); + const documentFiles = controlledFiles ?? internalFiles; + const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index b933c4707..418b8bcc4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -116,21 +116,29 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { export default function ForwarderForm({ documentSettingCode, + documentFiles: controlledFiles, + onDocumentFilesChange, user, onSubmit, isPending, onBack, }: { documentSettingCode: string; + documentFiles?: Record; + onDocumentFilesChange?: ( + files: Record, + ) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("company"); - const [documentFiles, setDocumentFiles] = useState< + const [internalFiles, setInternalFiles] = useState< Record >({}); + const documentFiles = controlledFiles ?? internalFiles; + const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index 8e8952ef7..ec1d724f8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; +import { companiesService } from "@/services/companies.service"; import type { CreateCompanyPayload } from "@/services/companies.service"; import AuthLayout from "@/components/auth/AuthLayout"; import CompanyProfileForm from "./CompanyProfileForm"; @@ -126,6 +127,9 @@ export default function OnboardingPage() { const queryClient = useQueryClient(); const { user } = useAuth(); const [userType, setUserType] = useState(null); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); const COMPANY_TYPE_MAP: Record = { importer: "customer", @@ -138,8 +142,14 @@ export default function OnboardingPage() { const createCompanyMutation = useMutation({ mutationFn: (payload: CreateCompanyPayload) => api.companies.create.call(payload), - onSuccess: () => { - queryClient.invalidateQueries({ + onSuccess: async (data) => { + const hasFiles = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (hasFiles) { + await companiesService.uploadDocuments(data.company.id, documentFiles); + } + await queryClient.invalidateQueries({ queryKey: api.companies.getInfo.queryKey(), }); }, @@ -240,6 +250,8 @@ export default function OnboardingPage() { {userType === "transporter" ? ( ; + onDocumentFilesChange?: ( + files: Record, + ) => void; user: AuthUser; onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; }) { const [step, setStep] = useState("vehicle"); - const [documentFiles, setDocumentFiles] = useState< + const [internalFiles, setInternalFiles] = useState< Record >({}); + const documentFiles = controlledFiles ?? internalFiles; + const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( api.fileUploadSettings.getByCode.queryOptions({ 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 4a68b5aa2..08f757529 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -80,4 +80,22 @@ export const companiesService = { ); return unwrap(response.data); }, + + uploadDocuments: async ( + companyId: string, + files: Record, + ): Promise => { + const formData = new FormData(); + for (const [fieldName, fileOrFiles] of Object.entries(files)) { + if (!fileOrFiles) continue; + if (Array.isArray(fileOrFiles)) { + for (const f of fileOrFiles) { + formData.append(fieldName, f); + } + } else { + formData.append(fieldName, fileOrFiles); + } + } + await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); + }, }; From ac964bace8312dfaa68f827efe3702775f7c547b Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 4 Jun 2026 15:39:36 +0300 Subject: [PATCH 06/14] feat(WIP): setting and upload logic --- .../modules/companies/companies.controller.ts | 18 + .../modules/companies/companies.service.ts | 38 ++ .../companies/dto/profile-response.dto.ts | 53 ++ .../companies/dto/update-profile.dto.ts | 83 +++ apps/edr-freight-web/portal/src/App.tsx | 4 + .../portal/src/constants/URLS.ts | 1 + .../portal/src/hooks/useAuth.ts | 1 - .../portal/src/pages/MyPortalPage.tsx | 33 +- .../portal/src/pages/ProfilePage.tsx | 414 ++++-------- .../portal/src/pages/SettingsPage.tsx | 617 ++++++++++++++++++ .../src/pages/customers/customers.mock.ts | 5 + .../portal/src/services/api.ts | 13 + .../portal/src/services/companies.service.ts | 16 + .../portal/src/types/profile.ts | 43 ++ 14 files changed, 1059 insertions(+), 280 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts create mode 100644 apps/edr-freight-web/portal/src/pages/SettingsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/types/profile.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 31487e2bb..b1481d0d4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -13,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto'; import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; import { ResponseFFClientDto } from './dto/response-ff-client.dto'; import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; interface CurrentIamUser { id: string; @@ -36,6 +38,22 @@ export class CompaniesController { return new CompanyInfoResponseDto(profile, company); } + @Get('profile') + @ApiOperation({ summary: 'Get flattened profile for the settings page' }) + async getProfile(@CurrentUser() user: CurrentIamUser): Promise { + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + return new ProfileResponseDto(profile, company); + } + + @Patch('profile') + @ApiOperation({ summary: 'Update profile (flattened settings page)' }) + async updateProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: UpdateProfileDto, + ): Promise { + return this.companiesService.updateProfile(user.id, dto); + } + @Post('create') @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) async createWithProfile( 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 03c104798..f383b9e55 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto'; import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; import { CreateFFClientDto } from './dto/create-ff-client.dto'; import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { ProfileResponseDto } from './dto/profile-response.dto'; import { Company } from './entities/company.entity'; import { ExternalProfile } from './entities/external-profile.entity'; import { FFClient } from './entities/ff-client.entity'; @@ -103,6 +105,42 @@ export class CompaniesService { return updated; } + async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + const companyUpdates: Record = {}; + const attrUpdates: Record = { ...(company.attributes ?? {}) }; + + if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; + if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; + if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; + if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; + if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; + if (dto.fanNumber !== undefined) { + companyUpdates.businessLicense = dto.fanNumber; + companyUpdates.fanNumber = dto.fanNumber; + } + + if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; + if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; + if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; + if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; + + companyUpdates.attributes = attrUpdates; + + const updated = await this.companiesRepo.update(company.id, companyUpdates); + if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + return new ProfileResponseDto(profile, updated); + } + async deleteCompany(id: string): Promise { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts new file mode 100644 index 000000000..ee8ede34f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -0,0 +1,53 @@ +import { Company } from '../entities/company.entity'; +import { ExternalProfile } from '../entities/external-profile.entity'; + +export class ProfileResponseDto { + companyId: string; + companyName: string; + companyEmail: string | null; + companyPhone: string | null; + companyLocation: string; + companyAddress: string | null; + tinNumber: string; + vatNumber: string | null; + fanNumber: string | null; + + contactPersonName: string | null; + contactPersonPhone: string | null; + generalManagerName: string | null; + generalManagerEmail: string | null; + generalManagerPhone: string | null; + + poaName: string | null; + poaPhone: string | null; + poaEmail: string | null; + poaLocation: string | null; + poaAddress: string | null; + + profileId: string; + + constructor(profile: ExternalProfile, company: Company) { + this.companyId = company.id; + this.companyName = company.name; + this.companyEmail = company.email ?? null; + this.companyPhone = company.phone ?? null; + this.companyLocation = company.country; + this.companyAddress = company.address ?? null; + this.tinNumber = company.tin; + this.vatNumber = company.vatNumber ?? null; + this.fanNumber = company.fanNumber ?? null; + this.profileId = profile.id; + + const attrs = company.attributes ?? {}; + this.contactPersonName = attrs.contactPersonName ?? null; + this.contactPersonPhone = attrs.contactPersonPhone ?? null; + this.generalManagerName = attrs.generalManagerName ?? null; + this.generalManagerEmail = attrs.generalManagerEmail ?? null; + this.generalManagerPhone = attrs.generalManagerPhone ?? null; + this.poaName = attrs.poaName ?? null; + this.poaPhone = attrs.poaPhone ?? null; + this.poaEmail = attrs.poaEmail ?? null; + this.poaLocation = attrs.poaLocation ?? null; + this.poaAddress = attrs.poaAddress ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts new file mode 100644 index 000000000..0acdf60a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -0,0 +1,83 @@ +import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator'; + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MaxLength(200) + companyName?: string; + + @IsOptional() + @IsEmail() + @MaxLength(150) + companyEmail?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + companyPhone?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + companyLocation?: string; + + @IsOptional() + @IsString() + companyAddress?: string; + + @IsOptional() + @IsString() + @Length(10, 10) + @Matches(/^\d+$/, { message: 'TIN must contain only digits' }) + tin?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + vatNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(16) + fanNumber?: string; + + @IsOptional() + @IsString() + contactPersonName?: string; + + @IsOptional() + @IsString() + contactPersonPhone?: string; + + @IsOptional() + @IsString() + generalManagerName?: string; + + @IsOptional() + @IsEmail() + generalManagerEmail?: string; + + @IsOptional() + @IsString() + generalManagerPhone?: string; + + @IsOptional() + @IsString() + poaName?: string; + + @IsOptional() + @IsString() + poaPhone?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; + + @IsOptional() + @IsString() + poaLocation?: string; + + @IsOptional() + @IsString() + poaAddress?: string; +} diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 3e065169f..147668d91 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -14,11 +14,13 @@ import { Home, Loader2, User, + Settings, } from "lucide-react"; import useAuth from "./hooks/useAuth"; import ProfilePage from "./pages/ProfilePage"; +import SettingsPage from "./pages/SettingsPage"; import MyPortalPage from "./pages/MyPortalPage"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import SignupPage from "./pages/accounts/SignupPage"; @@ -40,6 +42,7 @@ const sidebarItems: SidebarItem[] = [ { label: "Tracking", href: "/tracking", icon: }, { label: "Billing", href: "/billing", icon: }, { label: "Profile", href: "/profile", icon: }, + { label: "Settings", href: "/settings", icon: }, ]; const App = () => { @@ -108,6 +111,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 4fb326462..b2dd0f254 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -82,6 +82,7 @@ export const URL_CONSTANTS = { COMPANIES_API: { GET_INFO: "/api/companies/getInfo", CREATE: "/api/companies/create", + PROFILE: "/api/companies/profile", 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 8d58d3ada..7f4c02585 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -29,7 +29,6 @@ const useAuth = () => { const authQuery = useQuery( api.auth.getMyInfo.queryOptions({ - enabled: !!getCookie("auth-token"), retry: false, staleTime: 10 * 60 * 1000, }), diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx index 440617f3e..6cb111bb7 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { ArrowRight, @@ -14,6 +14,8 @@ import { Plus, Receipt, Truck, + UploadCloud, + X, } from "lucide-react"; import { @@ -48,6 +50,7 @@ export default function MyPortalPage() { const outstandingInvoices = myInvoices.filter( (inv) => inv.status === "Sent" || inv.status === "Overdue", ); + const [dismissed, setDismissed] = useState(false); const totalOutstanding = outstandingInvoices .filter((inv) => inv.currency === "USD") .reduce((sum, inv) => sum + inv.amount, 0); @@ -61,6 +64,34 @@ export default function MyPortalPage() { return (
+ {/* Documents banner */} + {!me.documentsComplete && !dismissed && ( +
+ +
+

Upload your documents

+

+ To enable all account features, please upload your Business + License, TIN Certificate, and National ID / Passport. +

+ + Upload now + +
+ +
+ )} + {/* Welcome banner */}
diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index e0d364bc1..9c4354723 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -1,135 +1,46 @@ -import { useMemo } from "react"; -import { - User, - Building2, - Phone, - Mail, - MapPin, - ShieldCheck, - Briefcase, - UserCheck, - Building, - Globe, - Fingerprint, - FileCheck, - Settings2, - ExternalLink, -} from "lucide-react"; -import useAuth from "@/hooks/useAuth"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardAction, - Badge, - Separator, - SmartFileInput, - Button, -} from "@edr/ui-common"; -import type { IFileUploadSetting } from "@edr/types/freight"; -import { cn } from "@/lib/utils"; +import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common"; + +function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) { + return ( +
+ {icon &&
{icon}
} +
+

{label}

+

{value || "—"}

+
+
+ ); +} export default function ProfilePage() { - const { user, customer, isPending } = useAuth(); - - const documentSettings = useMemo(() => ({ - id: "profile-docs", - code: "customer_documents", - label: "Customer Documents", - entity: "customer", - createdAt: new Date(), - updatedAt: new Date(), - fields: [ - { - id: "doc-tin", - settingId: "profile-docs", - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 1, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-license", - settingId: "profile-docs", - fileKey: "business_license", - fileLabel: "Business/Investment License", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 2, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-reg", - settingId: "profile-docs", - fileKey: "registration_certificate", - fileLabel: "Business Registration Certificate", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 3, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-id", - settingId: "profile-docs", - fileKey: "national_id", - fileLabel: "National ID", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 4, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "doc-poa", - settingId: "profile-docs", - fileKey: "power_of_attorney", - fileLabel: "Power of Attorney", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"], - maxSizeMb: 5, - order: 5, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - }), []); + const { data: profile, isPending } = useQuery( + api.companies.getProfile.queryOptions(), + ); if (isPending) { return (
-
+
); } - const displayName = user?.name?.en || user?.username || user?.email || "User"; + if (!profile) { + return ( +
+

No company profile found.

+
+ ); + } return ( -
-
- {/* Header Section */} -
+
+
+
+ {/* Header */}
@@ -137,7 +48,7 @@ export default function ProfilePage() {

- {displayName} + {profile.companyName}

Verified @@ -145,182 +56,129 @@ export default function ProfilePage() {

- {customer?.companyName || "No Company Linked"} + {profile.companyName}

-
- -
-
- + -
- {/* Left Column - Personal & Company Info */} -
-
- {/* Personal Details Card */} +
+ {/* Left Column */} +
+
+ {/* Company Details */} + + + + + Company Details + + Business registration information + + + } label="Location" value={profile.companyLocation} /> + } label="Address" value={profile.companyAddress} /> + } label="TIN Number" value={profile.tinNumber} /> + } label="FAN Number" value={profile.fanNumber} /> + } label="Email" value={profile.companyEmail} /> + } label="Phone" value={profile.companyPhone} /> + + + + {/* Personal Details (from ExternalProfile) */} + + + + + Profile Details + + Your linked user profile + + + } label="Profile" value="Primary Contact" /> + + +
+ + {/* Personnel Card */} - - Personal Details + + Key Personnel - Your account contact information - - - + Management and contact persons - - } label="Email Address" value={user?.email} /> - } label="Phone Number" value={user?.phoneNumber} /> - } label="Username" value={user?.username} /> + +
+

+ Contact Person +

+
+ + +
+
+
+

+ General Manager +

+
+ + + +
+
- {/* Company Details Card */} - - - - - Company Details - - Business registration information - - - } label="Location" value={customer?.companyLocation} /> - } label="Address" value={customer?.companyAddress} /> - } label="TIN Number" value={customer?.tinNumber} /> - } label="FAN Number" value={customer?.fanNumber} /> + {/* Power of Attorney */} + {profile.poaName && ( + + + + + Power of Attorney + + Authorized representative details + + + + + + + + + )} +
+ + {/* Right Column */} +
+ +
+ +
+ +

Secure Account

+

+ Your information is protected by enterprise-grade security. + Contact support for verified information updates. +

+
- - {/* Personnel Card */} - - - - - Key Personnel - - Management and contact persons - - -
-

- Contact Person -

-
- - -
-
-
-

- General Manager -

-
- - - -
-
-
-
- - {/* Power of Attorney Section (Conditional) */} - {customer?.poaName && ( - - - - - Power of Attorney - - Authorized representative details - - - - - - - - - )} -
- - {/* Right Column - Documents */} -
- - - - - Documents - - Manage required business documents - - - - - - - -
- -
- -

Secure Account

-

- Your information is protected by enterprise-grade security. - Contact support for verified information updates. -

-
- -
-
-
); } - -function InfoItem({ - icon, - label, - value, -}: { - icon?: React.ReactNode; - label: string; - value?: string | null; -}) { - return ( -
- {icon && ( -
- {icon} -
- )} -
-

- {label} -

-

- {value || "—"} -

-
-
- ); -} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx new file mode 100644 index 000000000..faec1b5b2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -0,0 +1,617 @@ +import { useState, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Building2, + User, + Briefcase, + UserCheck, + FileCheck, + Loader2, + Save, + UploadCloud, + CheckCircle2, + XCircle, +} from "lucide-react"; +import { api } from "@/services/api"; +import { companiesService } from "@/services/companies.service"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, + SmartFileInput, + Badge, +} from "@edr/ui-common"; +import { cn } from "@/lib/utils"; + +type SettingsTab = + | "company" + | "contact" + | "gm" + | "poa" + | "documents"; + +const settingsSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z.string().min(1, "Company phone is required"), + companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyLocation: z.string().min(1, "Location is required"), + companyAddress: z.string().min(1, "Address is required"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPhone: z.string().min(1, "Contact person phone is required"), + contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerName: z.string().min(1, "GM name is required"), + generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerPhone: z.string().min(1, "GM phone is required"), + generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + poaName: z.string().optional(), + poaEmail: z.string().optional(), + poaPhone: z.string().optional(), + poaPhoneCountryCode: z.string().optional(), + poaLocation: z.string().optional(), + poaAddress: z.string().optional(), +}); + +type FormData = z.infer; + +const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ + { id: "company", label: "Company Profile", icon: }, + { id: "contact", label: "Contact Person", icon: }, + { id: "gm", label: "General Manager", icon: }, + { id: "poa", label: "Power of Attorney", icon: }, + { id: "documents", label: "Documents", icon: }, +]; + +function splitPhone(fullPhone?: string | null): { code: string; number: string } { + if (!fullPhone) return { code: "+251", number: "" }; + const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); + if (match) return { code: match[1], number: match[2] }; + return { code: "+251", number: fullPhone }; +} + +export default function SettingsPage() { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const tab = (searchParams.get("tab") as SettingsTab) || "company"; + const setTab = (t: SettingsTab) => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set("tab", t); + return next; + }, { replace: true }); + }; + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const profileQuery = useQuery( + api.companies.getProfile.queryOptions(), + ); + + const docSettingQuery = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: "customer_documents" }, + enabled: tab === "documents", + }), + ); + + const profile = profileQuery.data; + + const defaultValues = useMemo((): FormData => { + if (!profile) { + return { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + fanNumber: "", + contactPersonName: "", + contactPersonPhone: "", + contactPersonPhoneCountryCode: "+251", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + generalManagerPhoneCountryCode: "+251", + poaName: "", + poaEmail: "", + poaPhone: "", + poaPhoneCountryCode: "+251", + poaLocation: "", + poaAddress: "", + }; + } + const contactPhone = splitPhone(profile.contactPersonPhone); + const gmPhone = splitPhone(profile.generalManagerPhone); + const poaPhone = splitPhone(profile.poaPhone); + return { + companyName: profile.companyName, + companyEmail: profile.companyEmail ?? "", + companyPhone: profile.companyPhone ?? "", + companyPhoneCountryCode: splitPhone(profile.companyPhone).code, + companyLocation: profile.companyLocation, + companyAddress: profile.companyAddress ?? "", + tinNumber: profile.tinNumber, + fanNumber: profile.fanNumber ?? "", + contactPersonName: profile.contactPersonName ?? "", + contactPersonPhone: contactPhone.number, + contactPersonPhoneCountryCode: contactPhone.code, + generalManagerName: profile.generalManagerName ?? "", + generalManagerEmail: profile.generalManagerEmail ?? "", + generalManagerPhone: gmPhone.number, + generalManagerPhoneCountryCode: gmPhone.code, + poaName: profile.poaName ?? "", + poaEmail: profile.poaEmail ?? "", + poaPhone: poaPhone.number, + poaPhoneCountryCode: poaPhone.code, + poaLocation: profile.poaLocation ?? "", + poaAddress: profile.poaAddress ?? "", + }; + }, [profile]); + + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(settingsSchema), + values: defaultValues, + }); + + const updateMutation = useMutation({ + mutationFn: (data: FormData) => + api.companies.updateProfile.call({ + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + tin: data.tinNumber, + fanNumber: data.fanNumber, + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + poaAddress: data.poaAddress || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + }, + }); + + const docUploadMutation = useMutation({ + mutationFn: (files: Record) => + companiesService.uploadDocuments(profile!.companyId, files), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + }, + }); + + const isPending = profileQuery.isPending || updateMutation.isPending || docUploadMutation.isPending; + + if (profileQuery.isPending) { + return ( +
+
+
+ ); + } + + if (!profile) { + return ( +
+

No company profile found.

+
+ ); + } + + const onSubmit = (data: FormData) => { + updateMutation.mutate(data); + }; + + return ( +
+
+
+

+ Account Settings +

+

+ Manage your company profile, personnel, and documents +

+
+ + Verified + +
+ + {/* Tab Bar */} +
+ {TABS.map((t) => ( + + ))} +
+ +
+ + + + {tab === "company" && <> Company Profile} + {tab === "contact" && <> Contact Person} + {tab === "gm" && <> General Manager} + {tab === "poa" && <> Power of Attorney} + {tab === "documents" && <> Documents} + + + {tab === "company" && "Edit your company registration details"} + {tab === "contact" && "Manage the primary contact person for your account"} + {tab === "gm" && "Manage the general manager information"} + {tab === "poa" && "Power of Attorney details are optional"} + {tab === "documents" && "Upload and manage required business documents"} + + + + + + {/* Company Profile Tab */} + {tab === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + FAN Number (16 digits) + + + +
+ + )} + + {/* Contact Person Tab */} + {tab === "contact" && ( + <> + + Full Name + + + + + + + )} + + {/* General Manager Tab */} + {tab === "gm" && ( + <> + + Full Name + + + + +
+ + Email Address + + + + + +
+ + )} + + {/* Power of Attorney Tab */} + {tab === "poa" && ( + <> +

+ Power of Attorney details are optional. Fill them in if you have + an authorized representative, or leave blank. +

+ + + PoA Full Name + + + + +
+ + PoA Email + + + + + +
+ +
+ + PoA Location + + + + + + PoA Address + + + +
+ + )} + + {/* Documents Tab */} + {tab === "documents" && ( + <> + {docSettingQuery.isLoading ? ( +
+ +
+ ) : !docSettingQuery.data ? ( +

+ No document requirements configured for your account. +

+ ) : ( + + )} + + {docSettingQuery.data && ( +
+
+ {docUploadMutation.isSuccess && ( + + + Documents uploaded successfully + + )} + {docUploadMutation.isError && ( + + + Upload failed + + )} +
+ +
+ )} + + )} +
+
+ + {tab !== "documents" && ( + +
+ {updateMutation.isSuccess && ( + + + Saved successfully + + )} + {updateMutation.isError && ( + + + Save failed + + )} +
+
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts b/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts index 92ec6ff80..a1a9bf1c2 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts +++ b/apps/edr-freight-web/portal/src/pages/customers/customers.mock.ts @@ -14,6 +14,7 @@ export interface Customer { country: string; address: string; notes: string; + documentsComplete: boolean; } const seedCustomers: Customer[] = [ @@ -30,6 +31,7 @@ const seedCustomers: Customer[] = [ country: "Ethiopia", address: "Bole Road, Sub-City 03, Building 17", notes: "Top-tier importer. Prefers weekly invoicing.", + documentsComplete: false, }, { id: 2, @@ -44,6 +46,7 @@ const seedCustomers: Customer[] = [ country: "Ethiopia", address: "Industrial Park, Zone B, Warehouse 4", notes: "Awaiting compliance documents.", + documentsComplete: false, }, { id: 3, @@ -58,6 +61,7 @@ const seedCustomers: Customer[] = [ country: "Djibouti", address: "Port Quarter, Avenue 26, Block 9", notes: "Account paused since last quarter.", + documentsComplete: true, }, ]; @@ -99,6 +103,7 @@ const generated: Customer[] = extras.map((entry, i) => { country: entry.country, address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`, notes: `Mock customer #${id}.`, + documentsComplete: i % 3 === 0, }; }); diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 115395f1a..baca5cd34 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -33,6 +33,7 @@ import type { CompanyInfoResponse, CreateCompanyPayload, } from "./companies.service"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, GenerateVerificationCodePayload, @@ -101,6 +102,18 @@ export const api = { "create", companiesService.create, ), + + getProfile: endpoint( + "companies", + "getProfile", + companiesService.getProfile, + ), + + updateProfile: endpoint( + "companies", + "updateProfile", + companiesService.updateProfile, + ), }, 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 08f757529..359881a9b 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -2,6 +2,7 @@ import { client } from "@/utils/api"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { ApiResponse } from "@/types/apiResponse"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import { isAxiosError } from "axios"; export interface ExternalProfileResponse { @@ -81,6 +82,21 @@ export const companiesService = { return unwrap(response.data); }, + getProfile: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.PROFILE, + ); + return unwrap(response.data); + }, + + updateProfile: async (payload: UpdateProfilePayload): Promise => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.PROFILE, + payload, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts new file mode 100644 index 000000000..cab34ee23 --- /dev/null +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -0,0 +1,43 @@ +export interface ProfileResponse { + companyId: string; + companyName: string; + companyEmail: string | null; + companyPhone: string | null; + companyLocation: string; + companyAddress: string | null; + tinNumber: string; + vatNumber: string | null; + fanNumber: string | null; + contactPersonName: string | null; + contactPersonPhone: string | null; + generalManagerName: string | null; + generalManagerEmail: string | null; + generalManagerPhone: string | null; + poaName: string | null; + poaPhone: string | null; + poaEmail: string | null; + poaLocation: string | null; + poaAddress: string | null; + profileId: string; +} + +export interface UpdateProfilePayload { + companyName?: string; + companyEmail?: string; + companyPhone?: string; + companyLocation?: string; + companyAddress?: string; + tin?: string; + vatNumber?: string; + fanNumber?: string; + contactPersonName?: string; + contactPersonPhone?: string; + generalManagerName?: string; + generalManagerEmail?: string; + generalManagerPhone?: string; + poaName?: string; + poaPhone?: string; + poaEmail?: string; + poaLocation?: string; + poaAddress?: string; +} From 8b3aaad5fd94eb1f06b873abe544d61b07c61509 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 4 Jun 2026 16:50:38 +0300 Subject: [PATCH 07/14] feat(freight): basics of train scheduling --- apps/edr-freight-api/src/app.module.ts | 15 +- .../1749400000000-AddTrainScheduling.ts | 153 ++++ .../src/modules/bookings/bookings.module.ts | 2 +- .../locomotives/dto/filter-locomotives.dto.ts | 11 + .../locomotives/entities/locomotive.entity.ts | 36 + .../locomotives/locomotives.controller.ts | 18 + .../modules/locomotives/locomotives.module.ts | 15 + .../locomotives/locomotives.repository.ts | 16 + .../locomotives/locomotives.service.ts | 29 + .../entities/train-schedule-booking.entity.ts | 26 + .../entities/train-schedule.entity.ts | 54 ++ .../wagon-booking-allocation.entity.ts | 26 + .../train-schedule-bookings.repository.ts | 16 + .../train-schedules/train-schedules.module.ts | 24 + .../train-schedules.repository.ts | 16 + .../wagon-booking-allocations.repository.ts | 16 + .../create-container-train-schedule.dto.ts | 10 + .../get-eligible-container-bookings.dto.ts | 23 + .../preview-container-train-schedule.dto.ts | 22 + .../train-scheduling.controller.ts | 58 ++ .../train-scheduling.module.ts | 46 ++ .../train-scheduling.service.spec.ts | 328 ++++++++ .../train-scheduling.service.ts | 678 ++++++++++++++++ .../entities/train-set-wagon.entity.ts | 39 + .../train-sets/entities/train-set.entity.ts | 46 ++ .../train-sets/train-set-wagons.repository.ts | 16 + .../modules/train-sets/train-sets.module.ts | 14 + .../train-sets/train-sets.repository.ts | 16 + .../wagon-types/entities/wagon-type.entity.ts | 33 + .../modules/wagon-types/wagon-types.module.ts | 13 + .../wagon-types/wagon-types.repository.ts | 16 + .../wagon-types/wagon-types.service.ts | 19 + .../src/seed/demo-bookings.seeder.ts | 265 ++++++ apps/edr-freight-web/backoffice/src/App.tsx | 8 + .../backoffice/src/constants/QUERY_KEYS.ts | 11 + .../backoffice/src/constants/URLS.ts | 14 + .../src/hooks/rule-engine/useRuleEngine.ts | 6 +- .../src/pages/trains/TrainsPage.tsx | 759 +++++++++++++++++- .../src/services/trainScheduling.service.ts | 87 ++ .../backoffice/src/types/trainScheduling.ts | 154 ++++ .../backoffice/src/utils/endpoint.ts | 5 +- 41 files changed, 3147 insertions(+), 12 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts create mode 100644 apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts create mode 100644 apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts create mode 100644 apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts create mode 100644 apps/edr-freight-api/src/seed/demo-bookings.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/types/trainScheduling.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 580e77e0c..fff9ac3b5 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -13,6 +13,11 @@ import { BookingsModule } from "./modules/bookings/bookings.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { TrainsModule } from "./modules/trains/trains.module"; +import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; +import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; +import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; +import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; @@ -30,6 +35,7 @@ import { } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; @Module({ @@ -60,6 +66,11 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; FilesModule, ConsignmentsModule, TrainsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + TrainSchedulingModule, CustomersModule, CompaniesModule, TrackingModule, @@ -72,13 +83,14 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; BackofficeModule, DemoPermissionsModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, + private readonly demoBookingsSeeder: DemoBookingsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, ) { } @@ -86,6 +98,7 @@ export class AppModule implements OnApplicationBootstrap { await this.seeder.run(); await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); + await this.demoBookingsSeeder.run(); await this.fileUploadSettingsSeeder.run(); } } diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts new file mode 100644 index 000000000..5e61797da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts @@ -0,0 +1,153 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTrainScheduling1749400000000 implements MigrationInterface { + name = 'AddTrainScheduling1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + max_wagons_per_train INT NULL, + supported_load_types TEXT[] NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.locomotives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NULL, + max_pull_weight_tons NUMERIC(10,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + available_from TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + locomotive_id UUID NOT NULL, + total_weight_tons NUMERIC(10,3) NOT NULL, + total_length_meters NUMERIC(10,3) NOT NULL, + wagon_count INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_set_wagons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL, + wagon_type_id UUID NOT NULL, + sequence_no INT NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no), + CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id) ON DELETE CASCADE, + CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL UNIQUE, + origin_station_id UUID NOT NULL, + destination_station_id UUID NOT NULL, + scheduled_departure_date TIMESTAMPTZ NOT NULL, + scheduled_arrival_date TIMESTAMPTZ NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id), + CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id) + REFERENCES freight.yards(id), + CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id) + REFERENCES freight.yards(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + booking_id UUID NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id), + CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id) + REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_wagon_id UUID NOT NULL, + booking_id UUID NOT NULL, + allocated_weight_tons NUMERIC(10,3) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id) + REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE, + CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_status + ON freight.locomotives(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_sets_status + ON freight.train_sets(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status + ON freight.train_schedules(scheduled_departure_date, status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking + ON freight.wagon_booking_allocations(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 1f96176ba..2114a64b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -60,6 +60,6 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu ContractRendererService, ContractPdfService, ], - exports: [BookingsService], + exports: [BookingsService, BookingsRepository], }) export class BookingsModule {} diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts new file mode 100644 index 000000000..3ee26beb9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity'; + +export class FilterLocomotivesDto { + @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_STATUSES]) + status?: string; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts new file mode 100644 index 000000000..1676674b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSet } from '../../train-sets/entities/train-set.entity'; + +export const LOCOMOTIVE_STATUSES = [ + 'AVAILABLE', + 'ASSIGNED', + 'MAINTENANCE', + 'INACTIVE', +] as const; + +export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'locomotives' }) +@Index(['code']) +@Index(['status']) +export class Locomotive extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) + name?: string | null; + + @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + maxPullWeightTons!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) + status!: LocomotiveStatus; + + @Column({ name: 'available_from', type: 'timestamptz', nullable: true }) + availableFrom?: Date | null; + + @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) + trainSets?: TrainSet[]; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts new file mode 100644 index 000000000..9e64c1e2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { LocomotivesService } from './locomotives.service'; + +@ApiTags('locomotives') +@ApiBearerAuth() +@Controller('locomotives') +export class LocomotivesController { + constructor(private readonly locomotivesService: LocomotivesService) {} + + @Get() + @ApiOperation({ summary: 'List locomotives' }) + findAll(@Query() filter: FilterLocomotivesDto) { + return this.locomotivesService.findAll(filter); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts new file mode 100644 index 000000000..264b9cb44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { LocomotivesController } from './locomotives.controller'; +import { Locomotive } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; +import { LocomotivesService } from './locomotives.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Locomotive])], + controllers: [LocomotivesController], + providers: [LocomotivesRepository, LocomotivesService], + exports: [LocomotivesRepository, LocomotivesService], +}) +export class LocomotivesModule {} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts new file mode 100644 index 000000000..af2a40f50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Locomotive } from './entities/locomotive.entity'; + +@Injectable() +export class LocomotivesRepository extends BaseRepository { + constructor( + @InjectRepository(Locomotive) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts new file mode 100644 index 000000000..946a77d48 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -0,0 +1,29 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; + +@Injectable() +export class LocomotivesService { + constructor(private readonly locomotivesRepository: LocomotivesRepository) {} + + findAll(filter: FilterLocomotivesDto): Promise { + return this.locomotivesRepository.findAll({ + where: filter.status + ? { status: filter.status as LocomotiveStatus } + : undefined, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const locomotive = await this.locomotivesRepository.findById(id); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return locomotive; + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts new file mode 100644 index 000000000..4ffecea26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from './train-schedule.entity'; + +@Entity({ schema: 'freight', name: 'train_schedule_bookings' }) +@Index(['trainScheduleId', 'bookingId'], { unique: true }) +@Index(['bookingId'], { unique: true }) +export class TrainScheduleBooking extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts new file mode 100644 index 000000000..965723f6c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from './train-schedule-booking.entity'; + +export const TRAIN_SCHEDULE_STATUSES = [ + 'DRAFT', + 'SCHEDULED', + 'DISPATCHED', + 'ARRIVED', + 'CANCELLED', +] as const; + +export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_schedules' }) +@Index(['scheduledDepartureDate']) +@Index(['status']) +export class TrainSchedule extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid', unique: true }) + trainSetId!: string; + + @OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'origin_station_id', type: 'uuid' }) + originStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_station_id' }) + originStation?: Yard; + + @Column({ name: 'destination_station_id', type: 'uuid' }) + destinationStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_station_id' }) + destinationStation?: Yard; + + @Column({ name: 'scheduled_departure_date', type: 'timestamptz' }) + scheduledDepartureDate!: Date; + + @Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true }) + scheduledArrivalDate?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainScheduleStatus; + + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) + scheduleBookings?: TrainScheduleBooking[]; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts new file mode 100644 index 000000000..4c78256fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_booking_allocations' }) +@Index(['trainSetWagonId', 'bookingId']) +export class WagonBookingAllocation extends BaseEntity { + @Column({ name: 'train_set_wagon_id', type: 'uuid' }) + trainSetWagonId!: string; + + @ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + allocatedWeightTons!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts new file mode 100644 index 000000000..d7360226f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; + +@Injectable() +export class TrainScheduleBookingsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainScheduleBooking) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts new file mode 100644 index 000000000..9fa40897a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; +import { TrainSchedule } from './entities/train-schedule.entity'; +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from './train-schedules.repository'; +import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])], + providers: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + WagonBookingAllocationsRepository, + ], + exports: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + WagonBookingAllocationsRepository, + ], +}) +export class TrainSchedulesModule {} 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 new file mode 100644 index 000000000..b6f18eaf2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSchedule } from './entities/train-schedule.entity'; + +@Injectable() +export class TrainSchedulesRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSchedule) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts new file mode 100644 index 000000000..067dddaf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; + +@Injectable() +export class WagonBookingAllocationsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonBookingAllocation) + repository: Repository, + ) { + super(repository); + } +} 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 new file mode 100644 index 000000000..173b0a6b3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto'; + +export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + locomotiveId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts new file mode 100644 index 000000000..8712b2a1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleContainerBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' }) + @IsOptional() + @IsDateString() + scheduleDate?: string; + + @ApiPropertyOptional() + @IsOptional() + status?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts new file mode 100644 index 000000000..e3e142a61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator'; + +export class PreviewContainerTrainScheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + originStationId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + destinationStationId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts new file mode 100644 index 000000000..1dd01ffba --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { TrainSchedulingService } from './train-scheduling.service'; + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling') +export class TrainSchedulingController { + constructor(private readonly trainSchedulingService: TrainSchedulingService) {} + + @Get('container/eligible-bookings') + @ApiOperation({ summary: 'List eligible container bookings' }) + getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) { + return this.trainSchedulingService.getEligibleContainerBookings(query); + } + + @Post('container/preview') + @ApiOperation({ summary: 'Preview a container train schedule' }) + previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { + return this.trainSchedulingService.previewContainerTrainSchedule(dto); + } + + @Post('container/schedules') + @ApiOperation({ summary: 'Create a container train schedule' }) + createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Get('container/schedules') + @ApiOperation({ summary: 'List container train schedules' }) + getContainerTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + + @Get('container/schedules/:id') + @ApiOperation({ summary: 'Get container train schedule detail' }) + getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post('container/schedules/:id/cancel') + @ApiOperation({ summary: 'Cancel container train schedule' }) + cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); + } +} 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 new file mode 100644 index 000000000..dbf79e403 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { LocomotivesModule } from '../locomotives/locomotives.module'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { TrainSchedulingController } from './train-scheduling.controller'; +import { TrainSchedulingService } from './train-scheduling.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Booking, + BookingContainer, + Locomotive, + WagonType, + TrainSet, + TrainSetWagon, + TrainSchedule, + TrainScheduleBooking, + WagonBookingAllocation, + Yard, + ]), + BookingsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + ], + controllers: [TrainSchedulingController], + providers: [TrainSchedulingService], + exports: [TrainSchedulingService], +}) +export class TrainSchedulingModule {} 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 new file mode 100644 index 000000000..659a4d6eb --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -0,0 +1,328 @@ +import { ConflictException } from '@nestjs/common'; + +import { TrainSchedulingService } from './train-scheduling.service'; + +const nw5 = { + id: 'wagon-type-1', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, +}; + +const locomotive = { + id: 'loc-1', + code: 'LOC-001', + maxPullWeightTons: 3500, + status: 'AVAILABLE', +}; + +const makeBooking = ( + id: string, + reference: string, + weight: number, + quantity: number, + containerCode: string, + scheduledDate = '2026-06-20T08:00:00.000Z', + originYardId = 'yard-origin', + destinationYardId = 'yard-destination', +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: weight, + scheduledDate: new Date(scheduledDate), + originYardId, + destinationYardId, + status: 'APPROVED', + customer: { companyName: 'Demo Customer' }, + originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, + destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, + bookingContainers: [ + { + quantity, + containerType: { code: containerCode, label: containerCode }, + }, + ], +}); + +describe('TrainSchedulingService', () => { + let service: TrainSchedulingService; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + }; + let locomotivesRepository: { + findById: jest.Mock; + }; + let wagonTypesRepository: { + findAll: jest.Mock; + }; + + beforeEach(() => { + dataSource = { + getRepository: jest.fn(), + transaction: jest.fn(), + }; + locomotivesRepository = { + findById: jest.fn(), + }; + wagonTypesRepository = { + findAll: jest.fn(), + }; + + service = new TrainSchedulingService( + dataSource as never, + locomotivesRepository as never, + wagonTypesRepository as never, + ); + }); + + it('computes the expected valid preview for Group A', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), + makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(2), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((booking) => booking.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.violations).toEqual([]); + expect(result.summary).toEqual({ + totalBookings: 3, + totalWeightTons: 1250, + wagonType: 'NW5', + wagonsNeeded: 18, + totalLengthMeters: 252, + }); + expect(result.wagonPlan).toHaveLength(18); + expect(result.wagonPlan[0]?.allocations[0]).toEqual({ + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }); + }); + + it('flags the overweight booking as invalid', async () => { + const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(1), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b6'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.summary.totalWeightTons).toBe(3600); + expect(result.violations).toContain( + 'Total booking weight 3600T exceeds max train weight 3500T', + ); + }); + + it('creates a schedule transactionally when validation passes', async () => { + const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')]; + const validation = { + valid: true, + violations: [], + bookings, + wagonType: nw5, + summary: { + totalBookings: 1, + totalWeightTons: 140, + wagonType: 'NW5', + wagonsNeeded: 2, + totalLengthMeters: 28, + }, + wagonPlan: [ + { + sequenceNo: 1, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 70, + allocations: [ + { + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }, + ], + }, + { + sequenceNo: 2, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 70, + allocations: [ + { + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }, + ], + }, + ], + }; + + const lockedLocomotiveRepo = { + findOne: jest.fn().mockResolvedValue(locomotive), + update: jest.fn().mockResolvedValue(undefined), + }; + const trainScheduleRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + }; + const trainScheduleBookingRepo = { + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + }; + const trainSetWagonRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + find: jest.fn().mockResolvedValue([ + { id: 'wagon-1', sequenceNo: 1 }, + { id: 'wagon-2', sequenceNo: 2 }, + ]), + }; + const wagonAllocRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + }; + const trainSetRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), + }; + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + switch (entity?.name) { + case 'Locomotive': + return lockedLocomotiveRepo; + case 'TrainSchedule': + return trainScheduleRepo; + case 'TrainScheduleBooking': + return trainScheduleBookingRepo; + case 'TrainSetWagon': + return trainSetWagonRepo; + case 'WagonBookingAllocation': + return wagonAllocRepo; + case 'TrainSet': + return trainSetRepo; + default: + throw new Error(`Unexpected transaction repository ${entity?.name}`); + } + }), + }; + + jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + const result = await service.createContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + locomotiveId: 'loc-1', + }); + + expect(trainSetRepo.save).toHaveBeenCalled(); + expect(trainScheduleRepo.save).toHaveBeenCalled(); + expect(trainSetWagonRepo.save).toHaveBeenCalled(); + expect(wagonAllocRepo.save).toHaveBeenCalled(); + expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); + expect(result).toEqual({ id: 'schedule-1' }); + }); + + it('rejects create when the locked locomotive is no longer available', async () => { + const validation = { + valid: true, + violations: [], + bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')], + wagonType: nw5, + summary: { + totalBookings: 1, + totalWeightTons: 70, + wagonType: 'NW5', + wagonsNeeded: 1, + totalLengthMeters: 14, + }, + wagonPlan: [ + { + sequenceNo: 1, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 70, + allocations: [], + }, + ], + }; + const manager = { + getRepository: jest.fn(() => ({ + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + })), + }; + + jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.createContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + locomotiveId: 'loc-1', + }), + ).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 new file mode 100644 index 000000000..0fd0546b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -0,0 +1,678 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager, In } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity'; +import { LocomotivesRepository } from '../locomotives/locomotives.repository'; +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'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; + +const DEFAULT_WAGON_TYPE_CODE = 'NW5'; +const MAX_TRAIN_WEIGHT_TONS = 3500; +const MAX_TRAIN_LENGTH_METERS = 760; + +type EligibleBookingItem = { + id: string; + reference: string; + customer: string; + containerType: string; + quantity: number; + weightTons: number; + origin: string; + destination: string; + preferredDepartureDate: string; + status: string; +}; + +type WagonAllocationRecord = { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; +}; + +type WagonPlanRecord = { + sequenceNo: number; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonAllocationRecord[]; +}; + +type ValidationResult = { + valid: boolean; + violations: string[]; + bookings: Booking[]; + wagonType: WagonType; + summary: { + totalBookings: number; + totalWeightTons: number; + wagonType: string; + wagonsNeeded: number; + totalLengthMeters: number; + }; + wagonPlan: WagonPlanRecord[]; +}; + +@Injectable() +export class TrainSchedulingService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly locomotivesRepository: LocomotivesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, + ) {} + + async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { + const bookingRepository = this.dataSource.getRepository(Booking); + const queryBuilder = bookingRepository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.customer', 'customer') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id') + .where('booking.freightType = :freightType', { freightType: 'CONTAINER' }) + .andWhere('scheduleBooking.id IS NULL'); + + if (query.originStationId) { + queryBuilder.andWhere('booking.originYardId = :originStationId', { + originStationId: query.originStationId, + }); + } + + if (query.destinationStationId) { + queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', { + destinationStationId: query.destinationStationId, + }); + } + + if (query.scheduleDate) { + queryBuilder.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`, + { scheduleDate: this.toUtcDateKey(query.scheduleDate) }, + ); + } + + if (query.status) { + queryBuilder.andWhere('booking.status = :status', { status: query.status }); + } + + const bookings = await queryBuilder + .orderBy('booking.scheduled_date', 'ASC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + + const items: EligibleBookingItem[] = bookings.map((booking) => ({ + id: booking.id, + reference: booking.reference, + customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer', + containerType: booking.bookingContainers + ?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container') + .join(', ') ?? 'Container', + quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0, + weightTons: this.roundTons(booking.cargoTotalWeightVgm), + origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', + destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + preferredDepartureDate: booking.scheduledDate.toISOString(), + status: booking.status, + })); + + return { + count: items.length, + items, + }; + } + + async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { + const validation = await this.validateContainerBookingsForScheduling(dto); + + return { + valid: validation.valid, + violations: validation.violations, + summary: validation.summary, + bookingIds: validation.bookings.map((booking) => booking.id), + wagonPlan: validation.wagonPlan, + }; + } + + async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { + const validation = await this.validateContainerBookingsForScheduling(dto); + + if (!validation.valid) { + throw new BadRequestException({ + message: 'train_schedule_invalid', + violations: validation.violations, + }); + } + + const locomotive = await this.selectOrValidateLocomotive( + dto.locomotiveId, + validation.summary.totalWeightTons, + ); + + const createdSchedule = await this.dataSource.transaction(async (manager) => { + const locomotiveRepository = manager.getRepository(Locomotive); + const lockedLocomotive = await locomotiveRepository.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`); + } + + if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) { + throw new BadRequestException( + `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, + ); + } + + const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({ + where: { bookingId: In(validation.bookings.map((booking) => booking.id)) }, + }); + + if (existingScheduleCount > 0) { + throw new BadRequestException('One or more bookings are already scheduled'); + } + + const trainSet = await this.buildTrainSet( + manager, + lockedLocomotive, + validation.wagonType, + validation.summary.totalWeightTons, + validation.summary.totalLengthMeters, + validation.wagonPlan, + ); + + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: 'SCHEDULED', + }); + + const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule); + + const scheduleBookings = validation.bookings.map((booking) => + manager.getRepository(TrainScheduleBooking).create({ + trainScheduleId: savedSchedule.id, + bookingId: booking.id, + }), + ); + await manager.getRepository(TrainScheduleBooking).save(scheduleBookings); + + const savedWagons = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: trainSet.id }, + order: { sequenceNo: 'ASC' }, + }); + + const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon])); + const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { + const wagon = wagonBySequence.get(wagonPlan.sequenceNo); + + if (!wagon) { + throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`); + } + + return wagonPlan.allocations.map((allocation) => + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: wagon.id, + bookingId: allocation.bookingId, + allocatedWeightTons: allocation.allocatedWeightTons, + }), + ); + }); + + await manager.getRepository(WagonBookingAllocation).save(allocationRows); + + await locomotiveRepository.update(lockedLocomotive.id, { + status: 'ASSIGNED', + }); + + return savedSchedule.id; + }); + + return this.getContainerTrainScheduleById(createdSchedule); + } + + async validateContainerBookingsForScheduling( + dto: PreviewContainerTrainScheduleDto, + ): Promise { + const bookingIds = [...new Set(dto.bookingIds)]; + + if (!bookingIds.length) { + throw new BadRequestException('At least one booking is required'); + } + + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true }, + }); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`); + } + + const bookings = await this.loadBookingsForScheduling(bookingIds); + const violations: string[] = []; + + if (bookings.length !== bookingIds.length) { + const foundIds = new Set(bookings.map((booking) => booking.id)); + const missing = bookingIds.filter((id) => !foundIds.has(id)); + violations.push(`Bookings not found: ${missing.join(', ')}`); + } + + const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { bookingId: In(bookingIds) }, + select: { bookingId: true }, + }); + + if (scheduledLinks.length > 0) { + violations.push('One or more selected bookings are already assigned to a train schedule'); + } + + const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER'); + if (nonContainerBookings.length > 0) { + violations.push('Only CONTAINER bookings are supported for train scheduling'); + } + + const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); + const routeMismatch = bookings.some( + (booking) => + booking.originYardId !== dto.originStationId || + booking.destinationYardId !== dto.destinationStationId, + ); + if (routeMismatch) { + violations.push('Selected bookings must share the same origin and destination as the schedule'); + } + + const dateMismatch = bookings.some( + (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, + ); + if (dateMismatch) { + violations.push('Selected bookings must share the same schedule date'); + } + + const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size; + if (uniqueOriginCount > 1) { + violations.push('Selected bookings must share the same origin station'); + } + + const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size; + if (uniqueDestinationCount > 1) { + violations.push('Selected bookings must share the same destination station'); + } + + const uniqueDateCount = new Set( + bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), + ).size; + if (uniqueDateCount > 1) { + violations.push('Selected bookings must share the same preferred departure date'); + } + + const totalWeightTons = this.roundTons( + bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0), + ); + + const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType)); + const totalLengthMeters = this.roundTons( + wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), + ); + + if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { + violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`); + } + + if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { + violations.push( + `Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`, + ); + } + + if ( + wagonType.maxWagonsPerTrain != null && + wagonPlan.length > Number(wagonType.maxWagonsPerTrain) + ) { + violations.push( + `Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`, + ); + } + + const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({ + where: { status: 'AVAILABLE' as LocomotiveStatus }, + }); + + if (availableLocomotiveCount === 0) { + violations.push('No available locomotive exists for scheduling'); + } else { + const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({ + where: { status: 'AVAILABLE' }, + }); + const canPull = capableLocomotives.some( + (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, + ); + if (!canPull) { + violations.push('No available locomotive can pull the total weight'); + } + } + + return { + valid: violations.length === 0, + violations, + bookings, + wagonType, + summary: { + totalBookings: bookings.length, + totalWeightTons, + wagonType: wagonType.code, + wagonsNeeded: wagonPlan.length, + totalLengthMeters, + }, + wagonPlan, + }; + } + + calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] { + const wagonCapacityTons = Number(wagonType.capacityTons); + const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); + let remainingWeight = this.roundTons(totalBookingWeightTons); + + return Array.from({ length: wagonsNeeded }, (_, index) => { + const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight)); + remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons)); + + return { + sequenceNo: index + 1, + capacityTons: wagonCapacityTons, + lengthMeters: this.roundTons(Number(wagonType.lengthMeters)), + assignedWeightTons, + allocations: [], + }; + }); + } + + async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) { + const locomotive = await this.locomotivesRepository.findById(locomotiveId); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + } + + if (locomotive.status !== 'AVAILABLE') { + throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); + } + + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + ); + } + + return locomotive; + } + + async buildTrainSet( + manager: EntityManager, + locomotive: Locomotive, + wagonType: WagonType, + totalWeightTons: number, + totalLengthMeters: number, + wagonPlan: WagonPlanRecord[], + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters, + wagonCount: wagonPlan.length, + status: 'ASSIGNED', + }); + const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); + + const wagons = wagonPlan.map((wagon) => + manager.getRepository(TrainSetWagon).create({ + trainSetId: savedTrainSet.id, + wagonTypeId: wagonType.id, + sequenceNo: wagon.sequenceNo, + capacityTons: wagon.capacityTons, + lengthMeters: wagon.lengthMeters, + assignedWeightTons: wagon.assignedWeightTons, + }), + ); + + await manager.getRepository(TrainSetWagon).save(wagons); + + return savedTrainSet; + } + + allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] { + const remaining = bookings.map((booking) => ({ + bookingId: booking.id, + bookingReference: booking.reference, + remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + })); + let bookingIndex = 0; + + return baseWagonPlan.map((wagon) => { + let wagonRemaining = this.roundTons(wagon.capacityTons); + const allocations: WagonAllocationRecord[] = []; + let assignedWeightTons = 0; + + while (wagonRemaining > 0 && bookingIndex < remaining.length) { + const booking = remaining[bookingIndex]; + const allocatedWeightTons = this.roundTons( + Math.min(wagonRemaining, booking.remainingWeightTons), + ); + + if (allocatedWeightTons <= 0) { + bookingIndex += 1; + continue; + } + + allocations.push({ + bookingId: booking.bookingId, + bookingReference: booking.bookingReference, + allocatedWeightTons, + }); + booking.remainingWeightTons = this.roundTons( + booking.remainingWeightTons - allocatedWeightTons, + ); + wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); + assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons); + + if (booking.remainingWeightTons <= 0) { + bookingIndex += 1; + } + } + + return { + ...wagon, + assignedWeightTons, + allocations, + }; + }); + } + + async getContainerTrainSchedules() { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + trainSet: { locomotive: true }, + originStation: true, + destinationStation: true, + scheduleBookings: true, + }, + order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + }); + + return schedules.map((schedule) => ({ + id: schedule.id, + scheduleDate: schedule.scheduledDepartureDate, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + locomotive: schedule.trainSet?.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } + : null, + wagonCount: schedule.trainSet?.wagonCount ?? 0, + totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), + totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + bookingsCount: schedule.scheduleBookings?.length ?? 0, + status: schedule.status, + })); + } + + async getContainerTrainScheduleById(id: string) { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id }, + relations: { + trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } }, + originStation: true, + destinationStation: true, + scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } }, + }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + return { + id: schedule.id, + status: schedule.status, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + trainSet: schedule.trainSet + ? { + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)), + totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: this.roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + } + : null, + wagons: + [...(schedule.trainSet.wagons ?? [])] + .sort((left, right) => left.sequenceNo - right.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: this.roundTons(Number(wagon.capacityTons)), + lengthMeters: this.roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)), + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)), + })) ?? [], + })), + } + : null, + bookings: + schedule.scheduleBookings?.map((scheduleBooking) => ({ + id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, + reference: scheduleBooking.booking?.reference ?? null, + customer: + scheduleBooking.booking?.customer?.companyName ?? + scheduleBooking.booking?.customer?.email ?? + null, + weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)), + status: scheduleBooking.booking?.status ?? null, + })) ?? [], + }; + } + + async cancelTrainSchedule(id: string) { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id }, + relations: { trainSet: { locomotive: true } }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(TrainSchedule).update(schedule.id, { + status: 'CANCELLED', + }); + + 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', + }); + } + }); + + return this.getContainerTrainScheduleById(id); + } + + private async loadBookingsForScheduling(bookingIds: string[]) { + return this.dataSource.getRepository(Booking).find({ + where: { id: In(bookingIds) }, + relations: { + customer: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + }, + order: { createdAt: 'ASC' }, + }); + } + + private toUtcDateKey(value: Date | string) { + const date = value instanceof Date ? value : new Date(value); + return date.toISOString().slice(0, 10); + } + + private roundTons(value: number) { + return Number(value.toFixed(3)); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts new file mode 100644 index 000000000..780bc1977 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSet } from './train-set.entity'; + +@Entity({ schema: 'freight', name: 'train_set_wagons' }) +@Index(['trainSetId', 'sequenceNo'], { unique: true }) +export class TrainSetWagon extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid' }) + trainSetId!: string; + + @ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + assignedWeightTons!: number; + + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) + allocations?: WagonBookingAllocation[]; +} 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 new file mode 100644 index 000000000..9099824d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from './train-set-wagon.entity'; + +export const TRAIN_SET_STATUSES = [ + 'DRAFT', + 'ASSIGNED', + 'DISPATCHED', + 'COMPLETED', + 'CANCELLED', +] as const; + +export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_sets' }) +@Index(['locomotiveId']) +@Index(['status']) +export class TrainSet extends BaseEntity { + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + totalWeightTons!: number; + + @Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 }) + totalLengthMeters!: number; + + @Column({ name: 'wagon_count', type: 'int' }) + wagonCount!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainSetStatus; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet) + wagons?: TrainSetWagon[]; + + @OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet) + trainSchedule?: TrainSchedule; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts new file mode 100644 index 000000000..5296b04a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSetWagon } from './entities/train-set-wagon.entity'; + +@Injectable() +export class TrainSetWagonsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSetWagon) + repository: Repository, + ) { + super(repository); + } +} 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 new file mode 100644 index 000000000..f11052727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainSet } from './entities/train-set.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])], + providers: [TrainSetsRepository, TrainSetWagonsRepository], + exports: [TrainSetsRepository, TrainSetWagonsRepository], +}) +export class TrainSetsModule {} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts new file mode 100644 index 000000000..a6cadbf2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSet } from './entities/train-set.entity'; + +@Injectable() +export class TrainSetsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSet) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts new file mode 100644 index 000000000..f1bfeedea --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_types' }) +@Index(['code']) +@Index(['isActive']) +export class WagonType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) + maxWagonsPerTrain?: number | null; + + @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) + supportedLoadTypes!: string[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType) + trainSetWagons?: TrainSetWagon[]; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts new file mode 100644 index 000000000..3cb23cc3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesRepository } from './wagon-types.repository'; +import { WagonTypesService } from './wagon-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([WagonType])], + providers: [WagonTypesRepository, WagonTypesService], + exports: [WagonTypesRepository, WagonTypesService], +}) +export class WagonTypesModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts new file mode 100644 index 000000000..001ff0212 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; + +@Injectable() +export class WagonTypesRepository extends BaseRepository { + constructor( + @InjectRepository(WagonType) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts new file mode 100644 index 000000000..4e15937e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -0,0 +1,19 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesRepository } from './wagon-types.repository'; + +@Injectable() +export class WagonTypesService { + constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} + + async findByCode(code: string): Promise { + const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${code} not found`); + } + + return wagonType; + } +} diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts new file mode 100644 index 000000000..356c0b6cb --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -0,0 +1,265 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Customer } from '../modules/customers/entities/customer.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; + +const SEED_FLAG = 'SEED_DEMO_BOOKINGS'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER'; +const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, + { code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +const DEMO_BOOKINGS = [ + { + reference: 'BKG-CONT-001', + containerCode: '40FT', + quantity: 20, + totalWeightTons: 500, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-06-20T08:00:00.000Z', + }, + { + reference: 'BKG-CONT-002', + containerCode: '20FT', + quantity: 10, + totalWeightTons: 300, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-06-20T08:00:00.000Z', + }, + { + reference: 'BKG-CONT-003', + containerCode: '40FT', + quantity: 15, + totalWeightTons: 450, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-06-20T08:00:00.000Z', + }, + { + reference: 'BKG-CONT-004', + containerCode: '40FT', + quantity: 12, + totalWeightTons: 360, + originCode: 'ADDIS_ABABA', + destinationCode: 'DIRE_DAWA', + scheduledDate: '2026-06-20T08:00:00.000Z', + }, + { + reference: 'BKG-CONT-005', + containerCode: '20FT', + quantity: 8, + totalWeightTons: 160, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-06-21T08:00:00.000Z', + }, + { + reference: 'BKG-CONT-006', + containerCode: '40FT', + quantity: 80, + totalWeightTons: 3600, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-06-20T08:00:00.000Z', + }, +]; + +@Injectable() +export class DemoBookingsSeeder { + private readonly logger = new Logger(DemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true'; + if (!shouldSeed) { + this.logger.log(`Skipping demo booking seed because ${SEED_FLAG} is not enabled`); + return; + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WagonType).upsert( + { + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Locomotive).upsert( + [ + { + code: 'LOC-001', + name: 'Demo Locomotive 1', + maxPullWeightTons: 3500, + status: 'AVAILABLE', + }, + { + code: 'LOC-002', + name: 'Demo Locomotive 2', + maxPullWeightTons: 2500, + status: 'AVAILABLE', + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container Service', + description: 'Temporary service type for train scheduling demos', + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Customer).upsert( + { + userId: '00000000-0000-0000-0000-000000000111', + firstName: 'Train', + lastName: 'Scheduling', + email: CUSTOMER_EMAIL, + phone: '251900000001', + companyName: 'Train Scheduling Demo Customer', + companyEmail: CUSTOMER_EMAIL, + companyPhone: '251900000001', + companyLocation: 'Addis Ababa', + companyAddress: 'Demo Address', + customerType: 'DEMO', + status: 'ACTIVE', + contactPersonName: 'Train Scheduling', + contactPersonPhone: '251900000001', + tinNumber: '1234567890', + vatNumber: '1234567890', + fanNumber: '1234567890123456', + generalManagerName: 'Demo Manager', + generalManagerEmail: CUSTOMER_EMAIL, + generalManagerPhone: '251900000001', + }, + { conflictPaths: { email: true } }, + ); + + const [serviceType, customer, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`demo_booking_seed_dependency_missing:${demoBooking.reference}`); + } + + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + customerId: customer.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + totalAmount: 0, + paymentStatus: 'PENDING', + contractType: 'NEW', + serviceTypeId: serviceType.id, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: null, + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + paymentCurrency: 'USD', + allowConsolidation: false, + priorityScore: 0, + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70), + weightLimitRuleId: null, + isOverweight: demoBooking.totalWeightTons > 70, + overweightExcessTons: + demoBooking.totalWeightTons > 70 ? demoBooking.totalWeightTons - 70 : null, + }); + } + }); + + this.logger.log('Seeded demo train scheduling data'); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 884fde00b..775e17060 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -7,6 +7,7 @@ import { Paperclip, Settings, SlidersHorizontal, + TrainTrack, } from "lucide-react"; import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; @@ -29,6 +30,7 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +import TrainsPage from "./pages/trains/TrainsPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ @@ -46,6 +48,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Train scheduling", + href: "/dashboard/operations/train-scheduling", + icon: , + }, ...demoItems, ], }, @@ -199,6 +206,7 @@ const App = () => { path="booking-requests/:id/contract" element={} /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 02fe21ffc..aa1b446d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -1,5 +1,6 @@ import type { BookingListFilter } from "@/services/bookings.service"; import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; +import type { TrainScheduleFilters } from "@/types/trainScheduling"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export const QUERY_KEYS = { @@ -35,6 +36,16 @@ export const QUERY_KEYS = { byId: (id: string) => ["bookings", "detail", id] as const, }, + TRAIN_SCHEDULING: { + ROOT: ["train-scheduling"] as const, + eligible: (filters?: TrainScheduleFilters) => + ["train-scheduling", "eligible-bookings", filters ?? {}] as const, + locomotives: () => ["train-scheduling", "locomotives"] as const, + stations: () => ["train-scheduling", "stations"] as const, + schedules: () => ["train-scheduling", "schedules"] as const, + scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const, + }, + RULE_ENGINE: { ROOT: ["rule-engine"] as const, list: (resource: RuleEngineResourceSlug | string, params?: RuleEngineListParams) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 174416857..e77768e63 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -77,6 +77,7 @@ export const URL_CONSTANTS = { BOOKINGS: { BASE: "/bookings", + REFERENCE_DATA: "/bookings/reference-data", BY_ID: (id: string) => `/bookings/${id}`, QUEUE: (queue: string) => `/bookings/queues/${queue}`, STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`, @@ -111,6 +112,19 @@ export const URL_CONSTANTS = { VERIFY: "/api/otp/verify", }, + LOCOMOTIVES: { + BASE: "/locomotives", + }, + + TRAIN_SCHEDULING: { + ELIGIBLE_BOOKINGS: "/train-scheduling/container/eligible-bookings", + PREVIEW: "/train-scheduling/container/preview", + SCHEDULES: "/train-scheduling/container/schedules", + SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, + CANCEL_SCHEDULE: (id: string) => + `/train-scheduling/container/schedules/${id}/cancel`, + }, + RULE_ENGINE: { CARGO_TYPES: "/cargo-types", CARGO_TYPE_BY_ID: (id: string) => `/cargo-types/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index a8f2536b9..85dff0cc2 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -76,7 +76,11 @@ export const useContainerTypeOptions = ( enabled = true, ) => useQuery({ - queryKey: api.ruleEngine.list.queryKey(), + queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', { + page: 1, + pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE, + includeNone, + }), queryFn: () => api.ruleEngine.list.call({ resource: "container-types", diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx index a9d12eb6a..12c9a47ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx @@ -1,11 +1,760 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import toast from 'react-hot-toast'; +import { Calendar, RefreshCw, TrainTrack } from 'lucide-react'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@edr/ui-common'; + +import Breadcrumbs from '@/components/ui/Breadcrumbs'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { trainSchedulingService } from '@/services/trainScheduling.service'; +import type { + EligibleContainerBooking, + TrainScheduleFilters, + TrainSchedulePreviewResponse, + YardOption, +} from '@/types/trainScheduling'; + +const inputClassName = + 'w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950'; + +const formatDate = (value?: string | null) => { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '-'; + return new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}; + +const formatDayInput = (value?: string | null) => { + if (!value) return ''; + return value.slice(0, 10); +}; + +const parseError = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(', '); + if (typeof message === 'string') return message; + const violations = error.response?.data?.violations; + if (Array.isArray(violations)) return violations.join(', '); + } + return fallback; +}; + +const deriveFromBooking = ( + booking: EligibleContainerBooking | undefined, + stations: YardOption[], +) => { + if (!booking) { + return { originStationId: '', destinationStationId: '', scheduleDate: '' }; + } + + const originStationId = stations.find((station) => station.name === booking.origin)?.id ?? ''; + const destinationStationId = + stations.find((station) => station.name === booking.destination)?.id ?? ''; + + return { + originStationId, + destinationStationId, + scheduleDate: formatDayInput(booking.preferredDepartureDate), + }; +}; const TrainsPage = () => { + const qc = useQueryClient(); + const [filters, setFilters] = useState({}); + const [selectedBookingIds, setSelectedBookingIds] = useState([]); + const [preview, setPreview] = useState(null); + const [selectedLocomotiveId, setSelectedLocomotiveId] = useState(''); + const [detailId, setDetailId] = useState(null); + const [scheduleSearch, setScheduleSearch] = useState(''); + const [scheduleStatusFilter, setScheduleStatusFilter] = useState('ALL'); + + const stationsQuery = useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.stations(), + queryFn: () => trainSchedulingService.getStations(), + }); + + const eligibleQuery = useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.eligible(filters), + queryFn: () => trainSchedulingService.getEligibleBookings(filters), + }); + + const locomotivesQuery = useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives(), + queryFn: () => trainSchedulingService.getAvailableLocomotives(), + }); + + const schedulesQuery = useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules(), + queryFn: () => trainSchedulingService.listSchedules(), + }); + + const detailQuery = useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(detailId ?? ''), + queryFn: () => trainSchedulingService.getScheduleById(detailId!), + enabled: Boolean(detailId), + }); + + const eligibleItems = eligibleQuery.data?.items ?? []; + const filteredSchedules = useMemo(() => { + const query = scheduleSearch.trim().toLowerCase(); + + return (schedulesQuery.data ?? []).filter((schedule) => { + const matchesStatus = + scheduleStatusFilter === 'ALL' || schedule.status === scheduleStatusFilter; + + if (!matchesStatus) { + return false; + } + + if (!query) { + return true; + } + + const haystack = [ + schedule.id, + schedule.origin ?? '', + schedule.destination ?? '', + schedule.locomotive?.code ?? '', + schedule.status, + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(query); + }); + }, [scheduleSearch, scheduleStatusFilter, schedulesQuery.data]); + const selectedBookings = useMemo( + () => eligibleItems.filter((booking) => selectedBookingIds.includes(booking.id)), + [eligibleItems, selectedBookingIds], + ); + + const summary = useMemo(() => { + const totalWeightTons = selectedBookings.reduce((sum, booking) => sum + booking.weightTons, 0); + const wagonsNeeded = Math.ceil(totalWeightTons / 70); + const totalLengthMeters = wagonsNeeded * 14; + const routeSet = new Set(selectedBookings.map((booking) => `${booking.origin} -> ${booking.destination}`)); + const dateSet = new Set(selectedBookings.map((booking) => formatDayInput(booking.preferredDepartureDate))); + + return { + count: selectedBookings.length, + totalWeightTons, + wagonsNeeded: Number.isFinite(wagonsNeeded) ? wagonsNeeded : 0, + totalLengthMeters: Number.isFinite(totalLengthMeters) ? totalLengthMeters : 0, + route: routeSet.size === 1 ? [...routeSet][0] : selectedBookings.length ? 'Mixed route' : '-', + scheduleDate: dateSet.size === 1 ? [...dateSet][0] : selectedBookings.length ? 'Mixed date' : '-', + }; + }, [selectedBookings]); + + const previewMutation = useMutation({ + mutationFn: () => { + if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) { + throw new Error('Please select origin, destination, and schedule date'); + } + return trainSchedulingService.preview({ + bookingIds: selectedBookingIds, + scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(), + originStationId: filters.originStationId, + destinationStationId: filters.destinationStationId, + }); + }, + onSuccess: (data) => { + setPreview(data); + toast.success(data.valid ? 'Preview generated' : 'Preview has validation issues'); + }, + onError: (error) => { + toast.error(parseError(error, 'Failed to preview train schedule')); + }, + }); + + const createMutation = useMutation({ + mutationFn: () => { + if (!selectedLocomotiveId) { + throw new Error('Please select a locomotive'); + } + if (!filters.originStationId || !filters.destinationStationId || !filters.scheduleDate) { + throw new Error('Please select origin, destination, and schedule date'); + } + return trainSchedulingService.createSchedule({ + bookingIds: selectedBookingIds, + scheduleDate: new Date(`${filters.scheduleDate}T08:00:00.000Z`).toISOString(), + originStationId: filters.originStationId, + destinationStationId: filters.destinationStationId, + locomotiveId: selectedLocomotiveId, + }); + }, + onSuccess: (data) => { + toast.success('Train schedule created'); + setSelectedBookingIds([]); + setSelectedLocomotiveId(''); + setPreview(null); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); + setDetailId(data.id); + }, + onError: (error) => { + toast.error(parseError(error, 'Failed to create train schedule')); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (id: string) => trainSchedulingService.cancelSchedule(id), + onSuccess: (data) => { + toast.success('Train schedule cancelled'); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.schedules() }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.locomotives() }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.ROOT }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(data.id) }); + setDetailId(data.id); + }, + onError: (error) => { + toast.error(parseError(error, 'Failed to cancel train schedule')); + }, + }); + + const toggleBooking = (booking: EligibleContainerBooking, checked: boolean) => { + setSelectedBookingIds((current) => { + if (checked) { + const next = [...new Set([...current, booking.id])]; + if (next.length === 1) { + const defaults = deriveFromBooking(booking, stationsQuery.data ?? []); + setFilters((prev) => ({ + ...prev, + originStationId: prev.originStationId || defaults.originStationId, + destinationStationId: prev.destinationStationId || defaults.destinationStationId, + scheduleDate: prev.scheduleDate || defaults.scheduleDate, + })); + } + return next; + } + return current.filter((id) => id !== booking.id); + }); + setPreview(null); + }; + + const detail = detailQuery.data; + const isBusy = previewMutation.isPending || createMutation.isPending; + return ( - +
+ + +
+
+
+
+ +
+
+

Train Scheduling

+

+ Build container train schedules from compatible bookings, preview wagon plans, and assign locomotives. +

+
+
+ +
+ +
+
+
+
+ +

+ Filters +

+
+
+
+ + +
+
+ + +
+
+ + + setFilters((current) => ({ + ...current, + scheduleDate: event.target.value || undefined, + })) + } + /> +
+
+ + + setFilters((current) => ({ + ...current, + status: event.target.value || undefined, + })) + } + /> +
+
+
+ +
+
+
+

Eligible container bookings

+

+ Only container bookings not already assigned to a schedule appear here. +

+
+ + {eligibleQuery.data?.count ?? 0} bookings + +
+ +
+ + + + + + + + + + + + + + + + + {eligibleItems.map((booking) => ( + + + + + + + + + + + + + ))} + {!eligibleQuery.isLoading && eligibleItems.length === 0 ? ( + + + + ) : null} + +
SelectBookingCustomerContainerQtyWeightOriginDestinationDepartureStatus
+ toggleBooking(booking, event.target.checked)} + /> + {booking.reference}{booking.customer}{booking.containerType}{booking.quantity}{booking.weightTons.toLocaleString()} T{booking.origin}{booking.destination}{formatDate(booking.preferredDepartureDate)}{booking.status}
+ No eligible container bookings matched the current filters. +
+
+
+
+ +
+
+

Schedule builder

+
+
+

Selected bookings

+

{summary.count}

+
+
+

Total weight

+

{summary.totalWeightTons.toLocaleString()} T

+
+
+

Route

+

{summary.route}

+
+
+

Schedule date

+

{summary.scheduleDate}

+
+
+

Estimated wagon type

+

NW5

+
+
+

Estimated wagons / length

+

+ {summary.wagonsNeeded} wagons / {summary.totalLengthMeters} m +

+
+
+ +
+ + +
+ + +
+ + +
+ + {preview ? ( +
+
+

Preview result

+ + {preview.valid ? 'Valid' : 'Invalid'} + +
+ +
+
+

Wagons

+

{preview.summary.wagonsNeeded}

+
+
+

Weight

+

{preview.summary.totalWeightTons} T

+
+
+

Length

+

{preview.summary.totalLengthMeters} m

+
+
+ + {preview.violations.length > 0 ? ( +
+
    + {preview.violations.map((violation) => ( +
  • {violation}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+ +
+
+
+

Created schedules

+

Open a schedule to inspect wagons and allocations.

+
+ + {filteredSchedules.length} schedules + +
+ +
+ setScheduleSearch(event.target.value)} + /> + +
+ +
+ + + + + + + + + + + + + + + + + {filteredSchedules.map((schedule) => ( + + + + + + + + + + + + + ))} + {!schedulesQuery.isLoading && filteredSchedules.length === 0 ? ( + + + + ) : null} + +
ScheduleDepartureRouteLocomotiveBookingsWagonsWeightLengthStatusActions
{schedule.id}{formatDate(schedule.scheduleDate)} + {schedule.origin} to {schedule.destination} + {schedule.locomotive?.code ?? '-'}{schedule.bookingsCount}{schedule.wagonCount}{schedule.totalWeightTons} T{schedule.totalLengthMeters} m{schedule.status} +
+ + {schedule.status !== 'CANCELLED' ? ( + + ) : null} +
+
+ No train schedules matched the current filters. +
+
+
+
+
+
+ + (!open ? setDetailId(null) : null)}> + + + Train schedule detail + + Inspect the selected schedule, locomotive, wagons, and booking allocations. + + + + {detail ? ( +
+
+
+

Schedule

+

{detail.id}

+
+
+

Departure

+

{formatDate(detail.scheduledDepartureDate)}

+
+
+

Route

+

+ {detail.originStation?.label ?? detail.originStation?.code ?? '-'} to{' '} + {detail.destinationStation?.label ?? detail.destinationStation?.code ?? '-'} +

+
+
+

Status

+

{detail.status}

+
+
+ +
+

Locomotive

+

+ {detail.trainSet?.locomotive + ? `${detail.trainSet.locomotive.code} (${detail.trainSet.locomotive.maxPullWeightTons}T pull capacity)` + : 'No locomotive attached'} +

+
+ +
+

Wagons and allocations

+
+ {(detail.trainSet?.wagons ?? []).map((wagon) => ( +
+
+
+

+ Wagon {wagon.sequenceNo} - {wagon.wagonType?.code ?? 'NW5'} +

+

+ {wagon.assignedWeightTons}T assigned / {wagon.capacityTons}T capacity / {wagon.lengthMeters}m +

+
+
+
+ + + + + + + + + {wagon.allocations.map((allocation) => ( + + + + + ))} + +
BookingAllocated weight
{allocation.bookingReference ?? allocation.bookingId}{allocation.allocatedWeightTons} T
+
+
+ ))} +
+
+ +
+

Bookings in schedule

+
+ + + + + + + + + + + {detail.bookings.map((booking) => ( + + + + + + + ))} + +
ReferenceCustomerWeightStatus
{booking.reference ?? booking.id}{booking.customer ?? '-'}{booking.weightTons} T{booking.status ?? '-'}
+
+
+
+ ) : ( +

Loading schedule detail...

+ )} +
+
+
); }; diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts new file mode 100644 index 000000000..e696b7e8b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -0,0 +1,87 @@ +import { api as client } from '../auth/http'; +import { unwrap } from '@/utils/endpoint'; +import { URL_CONSTANTS } from '@/constants/URLS'; +import type { + CreateTrainSchedulePayload, + EligibleContainerBookingsResponse, + LocomotiveRecord, + TrainScheduleDetail, + TrainScheduleFilters, + TrainScheduleListItem, + TrainSchedulePreviewPayload, + TrainSchedulePreviewResponse, + YardOption, +} from '@/types/trainScheduling'; + +interface BookingReferenceDataResponse { + yard?: YardOption[]; +} + +export const trainSchedulingService = { + getEligibleBookings: async ( + filters?: TrainScheduleFilters, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.ELIGIBLE_BOOKINGS, + { params: filters }, + ); + return unwrap(response.data); + }, + + preview: async ( + payload: TrainSchedulePreviewPayload, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.PREVIEW, + payload, + ); + return unwrap(response.data); + }, + + createSchedule: async ( + payload: CreateTrainSchedulePayload, + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES, + payload, + ); + return unwrap(response.data); + }, + + listSchedules: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULES, + ); + return unwrap(response.data); + }, + + getScheduleById: async (id: string): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_BY_ID(id), + ); + return unwrap(response.data); + }, + + cancelSchedule: async (id: string): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_SCHEDULE(id), + {}, + ); + return unwrap(response.data); + }, + + getAvailableLocomotives: async (): Promise => { + const response = await client.get(URL_CONSTANTS.LOCOMOTIVES.BASE, { + params: { status: 'AVAILABLE' }, + }); + return unwrap(response.data); + }, + + getStations: async (): Promise => { + const response = await client.get( + URL_CONSTANTS.BOOKINGS.REFERENCE_DATA, + ); + const data = unwrap(response.data); + return data.yard ?? []; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts new file mode 100644 index 000000000..171500a93 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -0,0 +1,154 @@ +export interface YardOption { + id: string; + name: string; + code: string; + country?: string; +} + +export interface EligibleContainerBooking { + id: string; + reference: string; + customer: string; + containerType: string; + quantity: number; + weightTons: number; + origin: string; + destination: string; + preferredDepartureDate: string; + status: string; +} + +export interface EligibleContainerBookingsResponse { + count: number; + items: EligibleContainerBooking[]; +} + +export interface WagonPlanAllocation { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; +} + +export interface WagonPlanRow { + sequenceNo: number; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonPlanAllocation[]; +} + +export interface TrainSchedulePreviewResponse { + valid: boolean; + violations: string[]; + summary: { + totalBookings: number; + totalWeightTons: number; + wagonType: string; + wagonsNeeded: number; + totalLengthMeters: number; + }; + bookingIds: string[]; + wagonPlan: WagonPlanRow[]; +} + +export interface LocomotiveRecord { + id: string; + code: string; + name?: string | null; + maxPullWeightTons: number; + status: 'AVAILABLE' | 'ASSIGNED' | 'MAINTENANCE' | 'INACTIVE'; + availableFrom?: string | null; +} + +export interface TrainScheduleListItem { + id: string; + scheduleDate: string; + origin: string | null; + destination: string | null; + locomotive: + | { + id: string; + code: string; + name?: string | null; + } + | null; + wagonCount: number; + totalWeightTons: number; + totalLengthMeters: number; + bookingsCount: number; + status: string; +} + +export interface TrainScheduleDetail { + id: string; + status: string; + scheduledDepartureDate: string; + scheduledArrivalDate?: string | null; + originStation?: { + id: string; + label?: string; + code?: string; + } | null; + destinationStation?: { + id: string; + label?: string; + code?: string; + } | null; + trainSet?: { + id: string; + status: string; + wagonCount: number; + totalWeightTons: number; + totalLengthMeters: number; + locomotive?: { + id: string; + code: string; + name?: string | null; + status: string; + maxPullWeightTons: number; + } | null; + wagons: Array<{ + id: string; + sequenceNo: number; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + wagonType?: { + id: string; + code: string; + name: string; + } | null; + allocations: Array<{ + id: string; + bookingId: string; + bookingReference: string | null; + allocatedWeightTons: number; + }>; + }>; + } | null; + bookings: Array<{ + id: string; + reference: string | null; + customer: string | null; + weightTons: number; + status: string | null; + }>; +} + +export interface TrainScheduleFilters { + originStationId?: string; + destinationStationId?: string; + scheduleDate?: string; + status?: string; +} + +export interface TrainSchedulePreviewPayload { + bookingIds: string[]; + scheduleDate: string; + originStationId: string; + destinationStationId: string; +} + +export interface CreateTrainSchedulePayload extends TrainSchedulePreviewPayload { + locomotiveId: string; +} diff --git a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts index 31748c77d..4a4af69d6 100644 --- a/apps/edr-freight-web/backoffice/src/utils/endpoint.ts +++ b/apps/edr-freight-web/backoffice/src/utils/endpoint.ts @@ -50,9 +50,6 @@ export function endpoint( if (queryKeyBuilder && input !== undefined) { return queryKeyBuilder(input as TInput); } - if (queryKeyBuilder && input === undefined) { - return queryKeyBuilder(undefined as TInput); - } return input === undefined ? [service, action] : [service, action, input]; @@ -124,4 +121,4 @@ export function unwrap(response: { data: T } | T): T { } return response as T; -} \ No newline at end of file +} From ac0076e84e2afbb47d38233b42347c7dfebd0ae4 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 4 Jun 2026 17:08:05 +0300 Subject: [PATCH 08/14] fix(freight:api): migration fix on companies FAN number --- .../src/migrations/1749300000000-AddFanNumberToCompanies.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts index 647e4fb5b..b4bea396e 100644 --- a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts @@ -6,7 +6,7 @@ export class AddFanNumberToCompanies1749300000000 implements MigrationInterface public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` ALTER TABLE freight.companies - ADD COLUMN fan_number varchar(16) NULL; + ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL; `); } From cd907eb2632636dbc209fb27f72d518b7b3893cb Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 4 Jun 2026 17:08:41 +0300 Subject: [PATCH 09/14] feat(freight:api): added additional booking entry --- apps/edr-freight-api/src/seed/demo-bookings.seeder.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 356c0b6cb..507040eb0 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -55,6 +55,15 @@ const DEMO_BOOKINGS = [ destinationCode: 'ADDIS_ABABA', scheduledDate: '2026-06-20T08:00:00.000Z', }, + { + reference: 'BKG-CONT-007', + containerCode: '20FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-06-20T08:00:00.000Z', + }, { reference: 'BKG-CONT-004', containerCode: '40FT', From 993d96d1b27141e8eb6337c401ea7713befaf070 Mon Sep 17 00:00:00 2001 From: Michael Abebe Date: Thu, 4 Jun 2026 17:14:55 +0300 Subject: [PATCH 10/14] fix(freight:api): ton number conversion --- .../train-scheduling/train-scheduling.service.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 0fd0546b1..2a337afef 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 @@ -672,7 +672,13 @@ export class TrainSchedulingService { return date.toISOString().slice(0, 10); } - private roundTons(value: number) { - return Number(value.toFixed(3)); + private roundTons(value: number | string | null | undefined) { + const numericValue = typeof value === 'number' ? value : Number(value ?? 0); + + if (!Number.isFinite(numericValue)) { + return 0; + } + + return Number(numericValue.toFixed(3)); } } From c7617b1b6efd6c38da05568ec17c225c1b4468f7 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 09:46:00 +0300 Subject: [PATCH 11/14] chore: deps --- apps/edr-freight-web/portal/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 9afc896f7..3cd617e0a 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -25,6 +25,7 @@ "react": "19.2.6", "react-dom": "19.2.6", "react-hook-form": "^7.76.0", + "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 834c00e55..7270938b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -307,6 +307,9 @@ importers: react-hook-form: specifier: ^7.76.0 version: 7.76.0(react@19.2.6) + react-hot-toast: + specifier: ^2.6.0 + version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^6.27.0 version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) From c5859dba6cdd0361e462fcde76c34a0ccc55a828 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 09:47:46 +0300 Subject: [PATCH 12/14] feat(bookings): Implement document upload API for DRAFT bookings --- .../src/modules/bookings/bookings.controller.ts | 12 ++++++++++++ .../src/modules/bookings/bookings.service.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+) 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 03c5cfde4..367aab41f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -147,6 +147,18 @@ export class BookingsController { return this.bookingsService.remove(id); } + @Post(':id/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + async uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const booking = await this.bookingsService.uploadDocuments(id, files ?? []); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/generate-price') @ApiOperation({ summary: 'Generate price preview (DRAFT only)' }) @ApiOkResponse({ type: GeneratePriceResponseDto }) 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 89085f144..b18a2600b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -452,6 +452,21 @@ export class BookingsService { return this.findById(booking.id); } + /** Upload documents for a DRAFT booking. */ + async uploadDocuments( + id: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } + await this.filesService.uploadMany(id, 'bookings', files); + return this.findById(id); + } + async remove(id: string): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { From 0184db904053b67d6782d0ef012392e4e28ea26e Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 09:50:09 +0300 Subject: [PATCH 13/14] feat(bookings): Introduce DRAFT booking workflow with comprehensive features for pricing, document management, submission, and cancellation. --- .../src/pages/bookings/BookingDetailPage.tsx | 531 +++++++++++++++++- .../src/pages/bookings/NewBookingPage.tsx | 66 +-- .../pages/bookings/new-booking-form/schema.ts | 106 +--- .../new-booking-form/step5-cargo-details.tsx | 117 ++-- .../portal/src/services/api.ts | 31 +- .../portal/src/services/bookings.service.ts | 51 ++ .../services/files/file_upload_settings.ts | 3 - 7 files changed, 649 insertions(+), 256 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index f8ea6c496..73977fc7c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -1,6 +1,8 @@ +import { useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + AlertCircle, Calendar, MapPin, Package, @@ -24,21 +26,29 @@ import { FileSignature, PackageCheck, LoaderCircle, + DollarSign, + Upload, + XCircle, + Building2, + FileUp, } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { Freight } from "@edr/types"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, +import type { GeneratePriceResponse } from "@/services/bookings.service"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, Badge, + Button, Separator, } from "@edr/ui-common"; import { cn } from "@/lib/utils"; +import useAuth from "@/hooks/useAuth"; const PROGRESS_STAGES = [ { label: "Request", icon: FileText, statuses: ["DRAFT"] }, @@ -55,9 +65,17 @@ const STATUS_MAP: Record(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { data: booking, isLoading, isError, error } = useQuery( api.bookings.get.queryOptions({ @@ -66,6 +84,10 @@ export default function BookingDetailPage() { }), ); + const refetchBooking = () => { + queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) }); + }; + if (isLoading) { return (
@@ -110,17 +132,474 @@ export default function BookingDetailPage() { ); } + if (booking.status === "DRAFT") { + return ; + } + + return ; +} + +function DraftBookingView({ + booking, + onBookingUpdated, +}: { + booking: Freight.IBooking; + onBookingUpdated: () => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { customer } = useAuth(); + const fileInputRefs = useRef>({}); + + const [pricingData, setPricingData] = useState(null); + const [selectedFiles, setSelectedFiles] = useState>({}); + const [cancelReason, setCancelReason] = useState(""); + + const anyFileSelected = Object.values(selectedFiles).some(Boolean); + const allDocsProvided = !anyFileSelected; + + const priceMutation = useMutation({ + mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }), + onSuccess: (data) => { + setPricingData(data); + queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) }); + }, + }); + + const uploadMutation = useMutation({ + mutationFn: (files: Record) => + api.bookings.uploadDocuments.call({ id: booking.id, files }), + onSuccess: () => { + setSelectedFiles({}); + onBookingUpdated(); + }, + }); + + const submitMutation = useMutation({ + mutationFn: () => api.bookings.submit.call({ id: booking.id }), + onSuccess: () => { + onBookingUpdated(); + queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (reason: string) => + api.bookings.cancel.call({ id: booking.id, reason }), + onSuccess: () => { + onBookingUpdated(); + }, + }); + + function handleFileSelect(key: string, file: File | null) { + setSelectedFiles((prev) => ({ ...prev, [key]: file })); + } + + function handleUploadAll() { + const filesToUpload: Record = {}; + for (const doc of REQUIRED_DOC_FIELDS) { + if (selectedFiles[doc.key]) { + filesToUpload[doc.key] = selectedFiles[doc.key]!; + } + } + if (Object.keys(filesToUpload).length === 0) return; + uploadMutation.mutate(filesToUpload); + } + + function handleCancel() { + const reason = cancelReason.trim() || "Cancelled by customer"; + cancelMutation.mutate(reason); + } + + const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending; + + const companyName = (customer as any)?.company?.name ?? "—"; + const companyTin = (customer as any)?.company?.tin ?? "—"; + const contactName = (customer as any)?.profile + ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—" + : "—"; + const contactEmail = (customer as any)?.profile?.email ?? "—"; + + return ( +
+
+ + + + +
+
+ +
+
+
+

+ {booking.reference} +

+ +
+

+ Complete the steps below to submit your booking request. +

+
+
+
+
+ + {priceMutation.isError && ( +
+ +
+

Pricing failed

+

+ {priceMutation.error instanceof Error + ? priceMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + {uploadMutation.isError && ( +
+ +
+

Document upload failed

+

+ {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + {submitMutation.isError && ( +
+ +
+

Submission failed

+

+ {submitMutation.error instanceof Error + ? submitMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + {cancelMutation.isError && ( +
+ +
+

Cancel failed

+

+ {cancelMutation.error instanceof Error + ? cancelMutation.error.message + : "An unexpected error occurred."} +

+
+
+ )} + + + + + + Pricing Estimation + + + Generate a price estimate based on your booking details. + + + + {pricingData ? ( +
+
+ + + + + + + + + {pricingData.lineItems.map((item, i) => ( + + + + + ))} + + + + + +
DescriptionAmount
{item.description} + {item.amount.toLocaleString()} {item.currency} +
Total Estimated Cost + {pricingData.totalAmount.toLocaleString()} {pricingData.currency} +
+
+ + {pricingData.warnings.length > 0 && ( +
+ {pricingData.warnings.map((w, i) => ( +

+ + {w} +

+ ))} +
+ )} + +
+ +
+
+ ) : ( +
+
+ +
+
+

No pricing yet

+

+ Generate a price estimate to review before submitting. +

+
+ +
+ )} +
+
+ + + + + + Required Documents + + + Provide the necessary documents for this booking. Some information is + pre-filled from your company profile. + + + +
+

+ + Company Information (from profile) +

+
+ + + + +
+

+ To update your company information, go to{" "} + + . +

+
+ + + +
+

+ + Upload Booking Documents +

+
+ {REQUIRED_DOC_FIELDS.map((doc) => ( +
+ +
+ { + fileInputRefs.current[doc.key] = el; + }} + type="file" + accept=".pdf,.jpg,.jpeg,.png" + className="hidden" + onChange={(e) => { + handleFileSelect(doc.key, e.target.files?.[0] ?? null); + }} + /> + + {selectedFiles[doc.key] && ( + + )} +
+
+ ))} +
+ +
+ + {uploadMutation.isSuccess && ( +

+ + Documents uploaded successfully +

+ )} +
+
+
+
+ + + + + + Cancel Booking + + + If you no longer need this booking, you can cancel it. + + + + setCancelReason(e.target.value)} + /> + + + + +
+
+ {!canConfirm && ( +

+ {!pricingData + ? "Request pricing estimation before confirming." + : "Upload documents before confirming."} +

+ )} + + +
+
+
+
+ ); +} + +function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { + const navigate = useNavigate(); + const normalizedStatus = booking.status as keyof typeof STATUS_MAP; const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT; const currentStageIndex = statusConfig.stage; - const containerCount = booking.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; - const containerType = booking.containers?.[0]?.type ?? null; - return (
- +
-
= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }} />
@@ -193,13 +672,13 @@ export default function BookingDetailPage() { {PROGRESS_STAGES.map((stage, idx) => { const isCompleted = idx < currentStageIndex; const isActive = idx === currentStageIndex; - + return (
{isCompleted ? : } @@ -435,14 +914,14 @@ function RouteEndpoint({ ); } -function InfoItem({ - icon, - label, - value -}: { - icon?: React.ReactNode; - label: string; - value?: string | number | null +function InfoItem({ + icon, + label, + value +}: { + icon?: React.ReactNode; + label: string; + value?: string | number | null }) { return (
@@ -465,8 +944,8 @@ function StatusBadge({ status }: { status: string }) { }; return ( - {status.replace(/_/g, ' ')} 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 d69258e0d..9b5a4cf73 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -6,14 +6,12 @@ import { useNavigate } from "react-router-dom"; import { AlertCircle, Check, - CheckCircle2, ChevronLeft, ChevronRight, LoaderCircle, } from "lucide-react"; import { Button } from "@edr/ui-common"; import { api } from "@/services/api"; -import { Freight } from "@edr/types"; import type { CreateBookingPayload } from "@/services/bookings.service"; import { BookingFormInputValues, @@ -32,13 +30,11 @@ import { Step5CargoDetails, Step8Review, } from "./new-booking-form/steps"; -import useAuth from "@/hooks/useAuth"; export default function NewBookingPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [step, setStep] = useState(1); - const { customer } = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), ); @@ -48,7 +44,7 @@ export default function NewBookingPage() { api.bookings.create.call(payload), onSuccess: (booking) => { queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }); - setTimeout(() => navigate(`/bookings/${booking.id}`), 2500); + navigate(`/bookings/${booking.id}`); }, }); @@ -132,23 +128,27 @@ export default function NewBookingPage() { return ""; }; + const selectedChild = + data.cargoType !== "container" && data.bulkCommoditytype + ? cargoTree + .find((g) => g.code.toLowerCase() === data.freightType) + ?.children?.find((c) => c.name === data.bulkCommoditytype) + : undefined; + const cargoTypeId = data.cargoType === "container" ? findContainerCargoTypeId() - : (findCargoTypeId( - data.freightType === "bulk" - ? data.bulkCommodity - : data.breakBulkType, - ) ?? ""); + : (findCargoTypeId(data.bulkCommoditytype) ?? + cargoTree.find((g) => g.code.toLowerCase() === data.freightType) + ?.id ?? + ""); const cargoFreeText = data.cargoType === "container" ? undefined - : data.freightType === "bulk" && data.bulkCommodity === "Others" - ? data.bulkCommodityOther - : data.freightType === "break_bulk" && data.breakBulkType === "Others" - ? data.breakBulkTypeOther - : undefined; + : selectedChild?.show_free_text_box + ? data.bulkCommoditytype + : undefined; // ── Build API payload ─────────────────────────────────────────────── const apiPayload: CreateBookingPayload = { @@ -168,15 +168,16 @@ export default function NewBookingPage() { : direction === "domestic" ? "DOMESTIC" : "IMPORT", - freightType: - data.cargoType === "container" - ? Freight.FreightType.Container - : Freight.FreightType.Bulk, cargoTypeId: data.cargoType === "container" ? undefined : cargoTypeId, cargoTotalWeightVgm: totalWeight, isHazardous: data.isHazardous, paymentCurrency: "USD", allowConsolidation: data.consolidationEnabled, + // @ts-ignore + freightType: + data.cargoType === "container" + ? ("CONTAINER" as const) + : ("BULK" as const), containers: data.cargoType === "container" ? data.containers.map((c) => ({ @@ -185,7 +186,6 @@ export default function NewBookingPage() { vgmPerUnitTons: Number(c.vgm || 0), })) : [], - ...(customer?.company?.id ? { companyId: customer.company.id } : {}), ...(data.previousContractRef ? { previousContractId: data.previousContractRef } : {}), @@ -207,26 +207,6 @@ export default function NewBookingPage() { createMutation.mutate(apiPayload); }); - if (createMutation.isSuccess) { - return ( -
-
-
- -
-

Contract Submitted

-

- Your request is queued for review by EDR Line Staff. You will be - notified once approved. -

-

- {createMutation.data?.reference} -

-
-
- ); - } - return (
-

Submission failed

+

Failed to save draft

{createMutation.error instanceof Error ? createMutation.error.message @@ -306,9 +286,7 @@ export default function NewBookingPage() { ) : ( )} - {createMutation.isPending - ? "Submitting..." - : "Submit Contract Request"} + {createMutation.isPending ? "Saving Draft..." : "Save as Draft"} )}

diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts index d994f93f7..be443d081 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/schema.ts @@ -1,19 +1,6 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; -export const STATIONS = [ - "Addis Ababa", - "Adama", - "Mojo", - "Awash", - "Mieso", - "Dire Dawa", - "Aysha", - "Ali Sabieh", - "Holhol", - "Djibouti City", -] as const; - export const ETHIOPIA_STATIONS = new Set([ "Addis Ababa", "Adama", @@ -23,19 +10,6 @@ export const ETHIOPIA_STATIONS = new Set([ "Dire Dawa", ]); -export const BULK_COMMODITIES = [ - "Coffee", - "Beans", - "Fertilizer", - "Sugar", - "Oil", - "Livestock", - "Steel", - "Others", -] as const; - -export const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"] as const; - export const MOCK_VALID_CONTRACTS = [ "EDR-2024-10001", "EDR-2024-10002", @@ -43,31 +17,6 @@ export const MOCK_VALID_CONTRACTS = [ "EDR-2022-55442", ]; -export const CONTAINER_TYPES = [ - "Dry Container", - "High Cubic", - "Reefer Container", - "Open Top", - "Flat Rack", - "Tank Container", - "Open Side", -] as const; - -export const SHIPPING_LINES = [ - "MSC", - "CMA CGM", - "Evergreen", - "COSCO", - "Hapag-Lloyd", - "ONE", - "Yang Ming", - "ZIM", - "Messina Line", - "Safmarine", - "Wan Hai", - "Ethiopian Shipping Lines (ESLSE)", -] as const; - export const STEPS = [ { id: 1, label: "Contract Type", short: "Contract" }, { id: 2, label: "Service Type & Mile", short: "Service" }, @@ -108,11 +57,8 @@ export const bookingFormSchema = z shippingLine: z.string(), cargoType: z.enum(["container", "bulk"], "Select a cargo type."), cargoWeight: z.string(), - freightType: z.enum(["bulk", "break_bulk"]).optional(), - bulkCommodity: z.string(), - bulkCommodityOther: z.string(), - breakBulkType: z.string(), - breakBulkTypeOther: z.string(), + freightType: z.string(), // parent group + bulkCommoditytype: z.string(), isHazardous: z.boolean(), isRefrigerated: z.boolean(), containers: z.array( @@ -171,42 +117,10 @@ export const bookingFormSchema = z (data) => !( data.cargoType === "bulk" && - data.freightType === "bulk" && - !data.bulkCommodity + data.freightType && + !data.bulkCommoditytype ), - { message: "Select a commodity.", path: ["bulkCommodity"] }, - ) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType === "bulk" && - data.bulkCommodity === "Others" && - !data.bulkCommodityOther.trim() - ), - { message: "Specify the commodity.", path: ["bulkCommodityOther"] }, - ) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType === "break_bulk" && - !data.breakBulkType - ), - { message: "Select a break-bulk type.", path: ["breakBulkType"] }, - ) - .refine( - (data) => - !( - data.cargoType === "bulk" && - data.freightType === "break_bulk" && - data.breakBulkType === "Others" && - !data.breakBulkTypeOther.trim() - ), - { - message: "Specify the break-bulk type.", - path: ["breakBulkTypeOther"], - }, + { message: "Select a commodity.", path: ["bulkCommoditytype"] }, ) .refine( (data) => { @@ -268,10 +182,7 @@ export const initialBookingFormValues: DeepPartial = { destinationYard: "", shippingLine: "", cargoWeight: "", - bulkCommodity: "", - bulkCommodityOther: "", - breakBulkType: "", - breakBulkTypeOther: "", + bulkCommoditytype: "", isHazardous: false, isRefrigerated: false, containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }], @@ -300,10 +211,7 @@ export const stepFields: Record>> = { "cargoType", "cargoWeight", "freightType", - "bulkCommodity", - "bulkCommodityOther", - "breakBulkType", - "breakBulkTypeOther", + "bulkCommoditytype", "containers", "consolidationEnabled", ], diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index ee54196e8..cbd70f5ac 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -44,8 +44,7 @@ export function Step5CargoDetails({ }) { const cargoType = form.watch("cargoType"); const freightType = form.watch("freightType"); - const bulkCommodity = form.watch("bulkCommodity"); - const breakBulkType = form.watch("breakBulkType"); + const bulkCommoditytype = form.watch("bulkCommoditytype"); const containers = form.watch("containers"); const { fields, append, remove } = useFieldArray({ @@ -60,13 +59,21 @@ export function Step5CargoDetails({ ); }, [referenceData]); - const bulkCommodityOptions = useMemo(() => { + const freightTypeGroups = useMemo(() => { if (!referenceData?.cargo_type) return []; - return referenceData.cargo_type.flatMap( - (group) => group.children?.map((c) => c.name) ?? [], + return referenceData.cargo_type.filter( + (g) => g.code !== "CONTAINER", ); }, [referenceData]); + const commodityOptions = useMemo(() => { + if (!referenceData?.cargo_type || !freightType) return []; + const group = referenceData.cargo_type.find( + (g) => g.code.toLowerCase() === freightType, + ); + return group?.children?.map((c) => c.name) ?? []; + }, [referenceData, freightType]); + function getOverweightAlert( type: "20ft" | "40ft", vgm: number, @@ -122,7 +129,7 @@ export function Step5CargoDetails({ selected={cargoType === "container"} onClick={() => { field.onChange("container"); - form.setValue("freightType", undefined, { + form.setValue("freightType", "", { shouldDirty: true, }); }} @@ -194,82 +201,42 @@ export function Step5CargoDetails({ render={({ field, fieldState }) => (
- field.onChange("bulk")} - > -

Bulk

-

- Coffee, fertilizer, grain, ore, etc. -

-
- field.onChange("break_bulk")} - > -

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
+ {freightTypeGroups.map((group) => { + const val = group.code.toLowerCase(); + return ( + { + field.onChange(val); + form.setValue("bulkCommoditytype", "", { + shouldDirty: true, + }); + }} + > +

{group.name}

+
+ ); + })}
)} /> - {freightType === "bulk" && ( + {freightType && commodityOptions.length > 0 && (
( - {bulkCommodityOptions.map((option) => ( - - {option} - - ))} - - )} - /> - {bulkCommodity === "Others" && ( - ( - - - - - )} - /> - )} -
- )} - - {freightType === "break_bulk" && ( -
- ( - - {bulkCommodityOptions.map((option) => ( + {commodityOptions.map((option) => ( {option} @@ -277,22 +244,6 @@ export function Step5CargoDetails({ )} /> - {breakBulkType === "Others" && ( - ( - - - - - )} - /> - )}
)}
diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index baca5cd34..672b1f562 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -8,7 +8,11 @@ import type { UpdateFileUploadFieldDto, UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; -import { bookingsService, CreateBookingPayload } from "./bookings.service"; +import { + bookingsService, + CreateBookingPayload, + GeneratePriceResponse, +} from "./bookings.service"; import { consignmentsService } from "./consignments.service"; import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; @@ -144,6 +148,31 @@ export const api = { remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) => bookingsService.remove(id), ), + + cancel: endpoint<{ id: string; reason: string }, Freight.IBooking>( + "bookings", + "cancel", + ({ id, reason }) => bookingsService.cancel(id, reason), + ), + + generatePrice: endpoint<{ id: string }, GeneratePriceResponse>( + "bookings", + "generatePrice", + ({ id }) => bookingsService.generatePrice(id), + ), + + submit: endpoint<{ id: string }, Freight.IBooking>( + "bookings", + "submit", + ({ id }) => bookingsService.submit(id), + ), + + uploadDocuments: endpoint< + { id: string; files: Record }, + Freight.IBooking + >("bookings", "uploadDocuments", ({ id, files }) => + bookingsService.uploadDocuments(id, files), + ), }, consignments: { 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 2dc1ba235..c3ea58ad9 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -25,6 +25,21 @@ export interface ContractView { }>; } +export interface PriceLineItem { + code: string; + description: string; + amount: number; + currency: string; +} + +export interface GeneratePriceResponse { + bookingId: string; + totalAmount: number; + currency: string; + lineItems: PriceLineItem[]; + warnings: string[]; +} + export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; @@ -53,6 +68,42 @@ export const bookingsService = { await client.delete(`/api/bookings/${id}`); }, + cancel: async (id: string, reason: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason }); + return data.data; + }, + + generatePrice: async (id: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/generate-price`); + return data.data; + }, + + submit: async (id: string): Promise => { + const { data } = await client.post(`/api/bookings/${id}/submit`); + return data.data; + }, + + uploadDocuments: async ( + id: string, + files: Record, + ): Promise => { + const formData = new FormData(); + for (const [key, fileOrFiles] of Object.entries(files)) { + if (!fileOrFiles) continue; + if (Array.isArray(fileOrFiles)) { + for (const f of fileOrFiles) formData.append(key, f); + } else { + formData.append(key, fileOrFiles); + } + } + const { data } = await client.post( + `/api/bookings/${id}/documents`, + formData, + { headers: { "Content-Type": "multipart/form-data" } }, + ); + return data.data; + }, + getContractView: async (id: string): Promise => { const { data } = await client.get(B.CONTRACT_VIEW(id)); return data.data ?? data; diff --git a/apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts b/apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts deleted file mode 100644 index c0ee8b1dc..000000000 --- a/apps/edr-freight-web/portal/src/services/files/file_upload_settings.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { api } from "../crud"; - -import { URL_CONSTANTS } from "../../constants/URLS" \ No newline at end of file From a991139cc26f1dd2a5cb2f057901fb73c4dea731 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 5 Jun 2026 10:23:08 +0300 Subject: [PATCH 14/14] feat(bookings): Integrate auto-pricing into DRAFT booking UI, and refactor customer-company relationships across services. --- apps/edr-freight-api/src/app.module.ts | 5 +- .../train-scheduling.service.ts | 592 ++++++++----- .../src/seed/demo-bookings.seeder.ts | 232 ++--- .../src/seed/pricing-data.seeder.ts | 800 ++++++++++++++++++ .../src/pages/bookings/BookingDetailPage.tsx | 564 ++++++++---- .../portal/src/services/api.ts | 6 - tasks.md | 5 - 7 files changed, 1683 insertions(+), 521 deletions(-) create mode 100644 apps/edr-freight-api/src/seed/pricing-data.seeder.ts delete mode 100644 tasks.md diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index fff9ac3b5..42b5b4dae 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -36,6 +36,7 @@ import { import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; +import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; @Module({ @@ -83,7 +84,7 @@ import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; BackofficeModule, DemoPermissionsModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, FileUploadSettingsSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( @@ -91,6 +92,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly demoBookingsSeeder: DemoBookingsSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, ) { } @@ -99,6 +101,7 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.demoBookingsSeeder.run(); + await this.pricingDataSeeder.run(); await this.fileUploadSettingsSeeder.run(); } } 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 2a337afef..34c4a7843 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 @@ -3,25 +3,28 @@ import { ConflictException, Injectable, NotFoundException, -} from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Locomotive, type LocomotiveStatus } from '../locomotives/entities/locomotive.entity'; -import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -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'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; -import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; -import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; -import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { Booking } from "../bookings/entities/booking.entity"; +import { + Locomotive, + type LocomotiveStatus, +} from "../locomotives/entities/locomotive.entity"; +import { LocomotivesRepository } from "../locomotives/locomotives.repository"; +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"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; +import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; +import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; +import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; -const DEFAULT_WAGON_TYPE_CODE = 'NW5'; +const DEFAULT_WAGON_TYPE_CODE = "NW5"; const MAX_TRAIN_WEIGHT_TONS = 3500; const MAX_TRAIN_LENGTH_METERS = 760; @@ -74,31 +77,38 @@ export class TrainSchedulingService { private readonly dataSource: DataSource, private readonly locomotivesRepository: LocomotivesRepository, private readonly wagonTypesRepository: WagonTypesRepository, - ) {} + ) { } async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { const bookingRepository = this.dataSource.getRepository(Booking); const queryBuilder = bookingRepository - .createQueryBuilder('booking') - .leftJoinAndSelect('booking.customer', 'customer') - .leftJoinAndSelect('booking.originYard', 'originYard') - .leftJoinAndSelect('booking.destinationYard', 'destinationYard') - .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') - .leftJoinAndSelect('bookingContainer.containerType', 'containerType') - .leftJoin(TrainScheduleBooking, 'scheduleBooking', 'scheduleBooking.booking_id = booking.id') - .where('booking.freightType = :freightType', { freightType: 'CONTAINER' }) - .andWhere('scheduleBooking.id IS NULL'); + .createQueryBuilder("booking") + .leftJoinAndSelect("booking.customer", "customer") + .leftJoinAndSelect("booking.originYard", "originYard") + .leftJoinAndSelect("booking.destinationYard", "destinationYard") + .leftJoinAndSelect("booking.bookingContainers", "bookingContainer") + .leftJoinAndSelect("bookingContainer.containerType", "containerType") + .leftJoin( + TrainScheduleBooking, + "scheduleBooking", + "scheduleBooking.booking_id = booking.id", + ) + .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) + .andWhere("scheduleBooking.id IS NULL"); if (query.originStationId) { - queryBuilder.andWhere('booking.originYardId = :originStationId', { + queryBuilder.andWhere("booking.originYardId = :originStationId", { originStationId: query.originStationId, }); } if (query.destinationStationId) { - queryBuilder.andWhere('booking.destinationYardId = :destinationStationId', { - destinationStationId: query.destinationStationId, - }); + queryBuilder.andWhere( + "booking.destinationYardId = :destinationStationId", + { + destinationStationId: query.destinationStationId, + }, + ); } if (query.scheduleDate) { @@ -109,25 +119,44 @@ export class TrainSchedulingService { } if (query.status) { - queryBuilder.andWhere('booking.status = :status', { status: query.status }); + queryBuilder.andWhere("booking.status = :status", { + status: query.status, + }); } const bookings = await queryBuilder - .orderBy('booking.scheduled_date', 'ASC') - .addOrderBy('booking.created_at', 'ASC') + .orderBy("booking.scheduled_date", "ASC") + .addOrderBy("booking.created_at", "ASC") .getMany(); const items: EligibleBookingItem[] = bookings.map((booking) => ({ id: booking.id, reference: booking.reference, - customer: booking.customer?.companyName ?? booking.customer?.email ?? 'Unknown customer', - containerType: booking.bookingContainers - ?.map((container) => container.containerType?.label ?? container.containerType?.code ?? 'Container') - .join(', ') ?? 'Container', - quantity: booking.bookingContainers?.reduce((sum, container) => sum + Number(container.quantity ?? 0), 0) ?? 0, + customer: + booking.company?.name ?? booking.company?.email ?? "Unknown customer", + containerType: + booking.bookingContainers + ?.map( + (container) => + container.containerType?.label ?? + container.containerType?.code ?? + "Container", + ) + .join(", ") ?? "Container", + quantity: + booking.bookingContainers?.reduce( + (sum, container) => sum + Number(container.quantity ?? 0), + 0, + ) ?? 0, weightTons: this.roundTons(booking.cargoTotalWeightVgm), - origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', - destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', + origin: + booking.originYard?.label ?? + booking.originYard?.code ?? + "Unknown origin", + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "Unknown destination", preferredDepartureDate: booking.scheduledDate.toISOString(), status: booking.status, })); @@ -155,7 +184,7 @@ export class TrainSchedulingService { if (!validation.valid) { throw new BadRequestException({ - message: 'train_schedule_invalid', + message: "train_schedule_invalid", violations: validation.violations, }); } @@ -165,92 +194,115 @@ export class TrainSchedulingService { validation.summary.totalWeightTons, ); - const createdSchedule = await this.dataSource.transaction(async (manager) => { - const locomotiveRepository = manager.getRepository(Locomotive); - const lockedLocomotive = await locomotiveRepository.findOne({ - where: { id: locomotive.id }, - lock: { mode: 'pessimistic_write' }, - }); + const createdSchedule = await this.dataSource.transaction( + async (manager) => { + const locomotiveRepository = manager.getRepository(Locomotive); + const lockedLocomotive = await locomotiveRepository.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`); - } - - if (Number(lockedLocomotive.maxPullWeightTons) < validation.summary.totalWeightTons) { - throw new BadRequestException( - `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, - ); - } - - const existingScheduleCount = await manager.getRepository(TrainScheduleBooking).count({ - where: { bookingId: In(validation.bookings.map((booking) => booking.id)) }, - }); - - if (existingScheduleCount > 0) { - throw new BadRequestException('One or more bookings are already scheduled'); - } - - const trainSet = await this.buildTrainSet( - manager, - lockedLocomotive, - validation.wagonType, - validation.summary.totalWeightTons, - validation.summary.totalLengthMeters, - validation.wagonPlan, - ); - - const schedule = manager.getRepository(TrainSchedule).create({ - trainSetId: trainSet.id, - originStationId: dto.originStationId, - destinationStationId: dto.destinationStationId, - scheduledDepartureDate: new Date(dto.scheduleDate), - status: 'SCHEDULED', - }); - - const savedSchedule = await manager.getRepository(TrainSchedule).save(schedule); - - const scheduleBookings = validation.bookings.map((booking) => - manager.getRepository(TrainScheduleBooking).create({ - trainScheduleId: savedSchedule.id, - bookingId: booking.id, - }), - ); - await manager.getRepository(TrainScheduleBooking).save(scheduleBookings); - - const savedWagons = await manager.getRepository(TrainSetWagon).find({ - where: { trainSetId: trainSet.id }, - order: { sequenceNo: 'ASC' }, - }); - - const wagonBySequence = new Map(savedWagons.map((wagon) => [wagon.sequenceNo, wagon])); - const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { - const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - - if (!wagon) { - throw new BadRequestException(`Missing wagon sequence ${wagonPlan.sequenceNo}`); + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); } - return wagonPlan.allocations.map((allocation) => - manager.getRepository(WagonBookingAllocation).create({ - trainSetWagonId: wagon.id, - bookingId: allocation.bookingId, - allocatedWeightTons: allocation.allocatedWeightTons, + if (lockedLocomotive.status !== "AVAILABLE") { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is not available`, + ); + } + + if ( + Number(lockedLocomotive.maxPullWeightTons) < + validation.summary.totalWeightTons + ) { + throw new BadRequestException( + `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, + ); + } + + const existingScheduleCount = await manager + .getRepository(TrainScheduleBooking) + .count({ + where: { + bookingId: In(validation.bookings.map((booking) => booking.id)), + }, + }); + + if (existingScheduleCount > 0) { + throw new BadRequestException( + "One or more bookings are already scheduled", + ); + } + + const trainSet = await this.buildTrainSet( + manager, + lockedLocomotive, + validation.wagonType, + validation.summary.totalWeightTons, + validation.summary.totalLengthMeters, + validation.wagonPlan, + ); + + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: "SCHEDULED", + }); + + const savedSchedule = await manager + .getRepository(TrainSchedule) + .save(schedule); + + const scheduleBookings = validation.bookings.map((booking) => + manager.getRepository(TrainScheduleBooking).create({ + trainScheduleId: savedSchedule.id, + bookingId: booking.id, }), ); - }); + await manager + .getRepository(TrainScheduleBooking) + .save(scheduleBookings); - await manager.getRepository(WagonBookingAllocation).save(allocationRows); + const savedWagons = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: trainSet.id }, + order: { sequenceNo: "ASC" }, + }); - await locomotiveRepository.update(lockedLocomotive.id, { - status: 'ASSIGNED', - }); + const wagonBySequence = new Map( + savedWagons.map((wagon) => [wagon.sequenceNo, wagon]), + ); + const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { + const wagon = wagonBySequence.get(wagonPlan.sequenceNo); - return savedSchedule.id; - }); + if (!wagon) { + throw new BadRequestException( + `Missing wagon sequence ${wagonPlan.sequenceNo}`, + ); + } + + return wagonPlan.allocations.map((allocation) => + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: wagon.id, + bookingId: allocation.bookingId, + allocatedWeightTons: allocation.allocatedWeightTons, + }), + ); + }); + + await manager + .getRepository(WagonBookingAllocation) + .save(allocationRows); + + await locomotiveRepository.update(lockedLocomotive.id, { + status: "ASSIGNED", + }); + + return savedSchedule.id; + }, + ); return this.getContainerTrainScheduleById(createdSchedule); } @@ -261,7 +313,7 @@ export class TrainSchedulingService { const bookingIds = [...new Set(dto.bookingIds)]; if (!bookingIds.length) { - throw new BadRequestException('At least one booking is required'); + throw new BadRequestException("At least one booking is required"); } const [wagonType] = await this.wagonTypesRepository.findAll({ @@ -269,7 +321,9 @@ export class TrainSchedulingService { }); if (!wagonType) { - throw new NotFoundException(`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`); + throw new NotFoundException( + `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`, + ); } const bookings = await this.loadBookingsForScheduling(bookingIds); @@ -278,21 +332,29 @@ export class TrainSchedulingService { if (bookings.length !== bookingIds.length) { const foundIds = new Set(bookings.map((booking) => booking.id)); const missing = bookingIds.filter((id) => !foundIds.has(id)); - violations.push(`Bookings not found: ${missing.join(', ')}`); + violations.push(`Bookings not found: ${missing.join(", ")}`); } - const scheduledLinks = await this.dataSource.getRepository(TrainScheduleBooking).find({ - where: { bookingId: In(bookingIds) }, - select: { bookingId: true }, - }); + const scheduledLinks = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ + where: { bookingId: In(bookingIds) }, + select: { bookingId: true }, + }); if (scheduledLinks.length > 0) { - violations.push('One or more selected bookings are already assigned to a train schedule'); + violations.push( + "One or more selected bookings are already assigned to a train schedule", + ); } - const nonContainerBookings = bookings.filter((booking) => booking.freightType !== 'CONTAINER'); + const nonContainerBookings = bookings.filter( + (booking) => booking.freightType !== "CONTAINER", + ); if (nonContainerBookings.length > 0) { - violations.push('Only CONTAINER bookings are supported for train scheduling'); + violations.push( + "Only CONTAINER bookings are supported for train scheduling", + ); } const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); @@ -302,44 +364,62 @@ export class TrainSchedulingService { booking.destinationYardId !== dto.destinationStationId, ); if (routeMismatch) { - violations.push('Selected bookings must share the same origin and destination as the schedule'); + violations.push( + "Selected bookings must share the same origin and destination as the schedule", + ); } const dateMismatch = bookings.some( (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, ); if (dateMismatch) { - violations.push('Selected bookings must share the same schedule date'); + violations.push("Selected bookings must share the same schedule date"); } - const uniqueOriginCount = new Set(bookings.map((booking) => booking.originYardId)).size; + const uniqueOriginCount = new Set( + bookings.map((booking) => booking.originYardId), + ).size; if (uniqueOriginCount > 1) { - violations.push('Selected bookings must share the same origin station'); + violations.push("Selected bookings must share the same origin station"); } - const uniqueDestinationCount = new Set(bookings.map((booking) => booking.destinationYardId)).size; + const uniqueDestinationCount = new Set( + bookings.map((booking) => booking.destinationYardId), + ).size; if (uniqueDestinationCount > 1) { - violations.push('Selected bookings must share the same destination station'); + violations.push( + "Selected bookings must share the same destination station", + ); } const uniqueDateCount = new Set( bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), ).size; if (uniqueDateCount > 1) { - violations.push('Selected bookings must share the same preferred departure date'); + violations.push( + "Selected bookings must share the same preferred departure date", + ); } const totalWeightTons = this.roundTons( - bookings.reduce((sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), 0), + bookings.reduce( + (sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), + 0, + ), ); - const wagonPlan = this.allocateBookingsToWagons(bookings, this.calculateNW5WagonPlan(totalWeightTons, wagonType)); + const wagonPlan = this.allocateBookingsToWagons( + bookings, + this.calculateNW5WagonPlan(totalWeightTons, wagonType), + ); const totalLengthMeters = this.roundTons( wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), ); if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { - violations.push(`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`); + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`, + ); } if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { @@ -357,21 +437,25 @@ export class TrainSchedulingService { ); } - const availableLocomotiveCount = await this.dataSource.getRepository(Locomotive).count({ - where: { status: 'AVAILABLE' as LocomotiveStatus }, - }); + const availableLocomotiveCount = await this.dataSource + .getRepository(Locomotive) + .count({ + where: { status: "AVAILABLE" as LocomotiveStatus }, + }); if (availableLocomotiveCount === 0) { - violations.push('No available locomotive exists for scheduling'); + violations.push("No available locomotive exists for scheduling"); } else { - const capableLocomotives = await this.dataSource.getRepository(Locomotive).find({ - where: { status: 'AVAILABLE' }, - }); + const capableLocomotives = await this.dataSource + .getRepository(Locomotive) + .find({ + where: { status: "AVAILABLE" }, + }); const canPull = capableLocomotives.some( (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, ); if (!canPull) { - violations.push('No available locomotive can pull the total weight'); + violations.push("No available locomotive can pull the total weight"); } } @@ -391,14 +475,21 @@ export class TrainSchedulingService { }; } - calculateNW5WagonPlan(totalBookingWeightTons: number, wagonType: WagonType): WagonPlanRecord[] { + calculateNW5WagonPlan( + totalBookingWeightTons: number, + wagonType: WagonType, + ): WagonPlanRecord[] { const wagonCapacityTons = Number(wagonType.capacityTons); const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); let remainingWeight = this.roundTons(totalBookingWeightTons); return Array.from({ length: wagonsNeeded }, (_, index) => { - const assignedWeightTons = this.roundTons(Math.min(wagonCapacityTons, remainingWeight)); - remainingWeight = this.roundTons(Math.max(0, remainingWeight - assignedWeightTons)); + const assignedWeightTons = this.roundTons( + Math.min(wagonCapacityTons, remainingWeight), + ); + remainingWeight = this.roundTons( + Math.max(0, remainingWeight - assignedWeightTons), + ); return { sequenceNo: index + 1, @@ -410,15 +501,20 @@ export class TrainSchedulingService { }); } - async selectOrValidateLocomotive(locomotiveId: string, totalWeightTons: number) { + async selectOrValidateLocomotive( + locomotiveId: string, + totalWeightTons: number, + ) { const locomotive = await this.locomotivesRepository.findById(locomotiveId); if (!locomotive) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } - if (locomotive.status !== 'AVAILABLE') { - throw new BadRequestException(`Locomotive ${locomotive.code} is not available`); + if (locomotive.status !== "AVAILABLE") { + throw new BadRequestException( + `Locomotive ${locomotive.code} is not available`, + ); } if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { @@ -443,7 +539,7 @@ export class TrainSchedulingService { totalWeightTons, totalLengthMeters, wagonCount: wagonPlan.length, - status: 'ASSIGNED', + status: "ASSIGNED", }); const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); @@ -463,11 +559,16 @@ export class TrainSchedulingService { return savedTrainSet; } - allocateBookingsToWagons(bookings: Booking[], baseWagonPlan: WagonPlanRecord[]): WagonPlanRecord[] { + allocateBookingsToWagons( + bookings: Booking[], + baseWagonPlan: WagonPlanRecord[], + ): WagonPlanRecord[] { const remaining = bookings.map((booking) => ({ bookingId: booking.id, bookingReference: booking.reference, - remainingWeightTons: this.roundTons(Number(booking.cargoTotalWeightVgm ?? 0)), + remainingWeightTons: this.roundTons( + Number(booking.cargoTotalWeightVgm ?? 0), + ), })); let bookingIndex = 0; @@ -496,7 +597,9 @@ export class TrainSchedulingService { booking.remainingWeightTons - allocatedWeightTons, ); wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); - assignedWeightTons = this.roundTons(assignedWeightTons + allocatedWeightTons); + assignedWeightTons = this.roundTons( + assignedWeightTons + allocatedWeightTons, + ); if (booking.remainingWeightTons <= 0) { bookingIndex += 1; @@ -519,40 +622,54 @@ export class TrainSchedulingService { destinationStation: true, scheduleBookings: true, }, - order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' }, + order: { scheduledDepartureDate: "DESC", createdAt: "DESC" }, }); return schedules.map((schedule) => ({ id: schedule.id, scheduleDate: schedule.scheduledDepartureDate, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + origin: + schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + null, locomotive: schedule.trainSet?.locomotive ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name ?? null, - } + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } : null, wagonCount: schedule.trainSet?.wagonCount ?? 0, - totalWeightTons: this.roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), - totalLengthMeters: this.roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), + totalWeightTons: this.roundTons( + Number(schedule.trainSet?.totalWeightTons ?? 0), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet?.totalLengthMeters ?? 0), + ), bookingsCount: schedule.scheduleBookings?.length ?? 0, status: schedule.status, })); } async getContainerTrainScheduleById(id: string) { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id }, - relations: { - trainSet: { locomotive: true, wagons: { wagonType: true, allocations: { booking: true } } }, - originStation: true, - destinationStation: true, - scheduleBookings: { booking: { customer: true, originYard: true, destinationYard: true } }, - }, - }); + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { + trainSet: { + locomotive: true, + wagons: { wagonType: true, allocations: { booking: true } }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { company: true, originYard: true, destinationYard: true }, + }, + }, + }); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -567,67 +684,78 @@ export class TrainSchedulingService { destinationStation: schedule.destinationStation, trainSet: schedule.trainSet ? { - id: schedule.trainSet.id, - status: schedule.trainSet.status, - wagonCount: schedule.trainSet.wagonCount, - totalWeightTons: this.roundTons(Number(schedule.trainSet.totalWeightTons)), - totalLengthMeters: this.roundTons(Number(schedule.trainSet.totalLengthMeters)), - locomotive: schedule.trainSet.locomotive - ? { - id: schedule.trainSet.locomotive.id, - code: schedule.trainSet.locomotive.code, - name: schedule.trainSet.locomotive.name, - status: schedule.trainSet.locomotive.status, - maxPullWeightTons: this.roundTons( - Number(schedule.trainSet.locomotive.maxPullWeightTons), - ), + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: this.roundTons( + Number(schedule.trainSet.totalWeightTons), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet.totalLengthMeters), + ), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: this.roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((left, right) => left.sequenceNo - right.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: this.roundTons(Number(wagon.capacityTons)), + lengthMeters: this.roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: this.roundTons( + Number(wagon.assignedWeightTons), + ), + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, } - : null, - wagons: - [...(schedule.trainSet.wagons ?? [])] - .sort((left, right) => left.sequenceNo - right.sequenceNo) - .map((wagon) => ({ - id: wagon.id, - sequenceNo: wagon.sequenceNo, - capacityTons: this.roundTons(Number(wagon.capacityTons)), - lengthMeters: this.roundTons(Number(wagon.lengthMeters)), - assignedWeightTons: this.roundTons(Number(wagon.assignedWeightTons)), - wagonType: wagon.wagonType - ? { - id: wagon.wagonType.id, - code: wagon.wagonType.code, - name: wagon.wagonType.name, - } - : null, - allocations: - wagon.allocations?.map((allocation) => ({ - id: allocation.id, - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - allocatedWeightTons: this.roundTons(Number(allocation.allocatedWeightTons)), - })) ?? [], - })), - } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: this.roundTons( + Number(allocation.allocatedWeightTons), + ), + })) ?? [], + })), + } : null, bookings: schedule.scheduleBookings?.map((scheduleBooking) => ({ id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, reference: scheduleBooking.booking?.reference ?? null, customer: - scheduleBooking.booking?.customer?.companyName ?? - scheduleBooking.booking?.customer?.email ?? + scheduleBooking.booking?.company?.name ?? + scheduleBooking.booking?.company?.email ?? null, - weightTons: this.roundTons(Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0)), + weightTons: this.roundTons( + Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0), + ), status: scheduleBooking.booking?.status ?? null, })) ?? [], }; } async cancelTrainSchedule(id: string) { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id }, - relations: { trainSet: { locomotive: true } }, - }); + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { trainSet: { locomotive: true } }, + }); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -635,19 +763,21 @@ export class TrainSchedulingService { await this.dataSource.transaction(async (manager) => { await manager.getRepository(TrainSchedule).update(schedule.id, { - status: 'CANCELLED', + status: "CANCELLED", }); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { - status: 'CANCELLED', + status: "CANCELLED", }); } if (schedule.trainSet?.locomotiveId) { - await manager.getRepository(Locomotive).update(schedule.trainSet.locomotiveId, { - status: 'AVAILABLE', - }); + await manager + .getRepository(Locomotive) + .update(schedule.trainSet.locomotiveId, { + status: "AVAILABLE", + }); } }); @@ -658,12 +788,12 @@ export class TrainSchedulingService { return this.dataSource.getRepository(Booking).find({ where: { id: In(bookingIds) }, relations: { - customer: true, + company: true, originYard: true, destinationYard: true, bookingContainers: { containerType: true }, }, - order: { createdAt: 'ASC' }, + order: { createdAt: "ASC" }, }); } @@ -673,7 +803,7 @@ export class TrainSchedulingService { } private roundTons(value: number | string | null | undefined) { - const numericValue = typeof value === 'number' ? value : Number(value ?? 0); + const numericValue = typeof value === "number" ? value : Number(value ?? 0); if (!Number.isFinite(numericValue)) { return 0; diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts index 507040eb0..e215dbd9b 100644 --- a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -1,95 +1,105 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { randomUUID } from 'crypto'; -import { DataSource } from 'typeorm'; +import { Injectable, Logger } from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { DataSource } from "typeorm"; -import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; -import { Booking } from '../modules/bookings/entities/booking.entity'; -import { Customer } from '../modules/customers/entities/customer.entity'; -import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; -import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; -import { Yard } from '../modules/rule-engine/entities/yard.entity'; -import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; -import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; +import { Booking } from "../modules/bookings/entities/booking.entity"; +import { Customer } from "../modules/customers/entities/customer.entity"; +import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; -const SEED_FLAG = 'SEED_DEMO_BOOKINGS'; +const SEED_FLAG = "SEED_DEMO_BOOKINGS"; -const SERVICE_TYPE_CODE = 'RAIL_CONTAINER'; -const CUSTOMER_EMAIL = 'train-scheduling-demo@edr.local'; +const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; +const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local"; const YARDS = [ - { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, - { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, - { code: 'DIRE_DAWA', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 3 }, + { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + }, ]; const CONTAINER_TYPES = [ - { code: '20FT', label: '20FT', sizeFt: 20 }, - { code: '40FT', label: '40FT', sizeFt: 40 }, + { code: "20FT", label: "20FT", sizeFt: 20 }, + { code: "40FT", label: "40FT", sizeFt: 40 }, ]; const DEMO_BOOKINGS = [ { - reference: 'BKG-CONT-001', - containerCode: '40FT', + reference: "BKG-CONT-001", + containerCode: "40FT", quantity: 20, totalWeightTons: 500, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-002', - containerCode: '20FT', + reference: "BKG-CONT-002", + containerCode: "20FT", quantity: 10, totalWeightTons: 300, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-003', - containerCode: '40FT', + reference: "BKG-CONT-003", + containerCode: "40FT", quantity: 15, totalWeightTons: 450, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-007', - containerCode: '20FT', + reference: "BKG-CONT-007", + containerCode: "20FT", quantity: 6, totalWeightTons: 180, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-004', - containerCode: '40FT', + reference: "BKG-CONT-004", + containerCode: "40FT", quantity: 12, totalWeightTons: 360, - originCode: 'ADDIS_ABABA', - destinationCode: 'DIRE_DAWA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, { - reference: 'BKG-CONT-005', - containerCode: '20FT', + reference: "BKG-CONT-005", + containerCode: "20FT", quantity: 8, totalWeightTons: 160, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-21T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-21T08:00:00.000Z", }, { - reference: 'BKG-CONT-006', - containerCode: '40FT', + reference: "BKG-CONT-006", + containerCode: "40FT", quantity: 80, totalWeightTons: 3600, - originCode: 'DJIBOUTI', - destinationCode: 'ADDIS_ABABA', - scheduledDate: '2026-06-20T08:00:00.000Z', + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", }, ]; @@ -97,24 +107,26 @@ const DEMO_BOOKINGS = [ export class DemoBookingsSeeder { private readonly logger = new Logger(DemoBookingsSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === 'true'; + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; if (!shouldSeed) { - this.logger.log(`Skipping demo booking seed because ${SEED_FLAG} is not enabled`); + this.logger.log( + `Skipping demo booking seed because ${SEED_FLAG} is not enabled`, + ); return; } await this.dataSource.transaction(async (manager) => { await manager.getRepository(WagonType).upsert( { - code: 'NW5', - name: 'Flat Wagon', + code: "NW5", + name: "Flat Wagon", capacityTons: 70, lengthMeters: 14, maxWagonsPerTrain: 53, - supportedLoadTypes: ['CONTAINER'], + supportedLoadTypes: ["CONTAINER"], isActive: true, }, { conflictPaths: { code: true } }, @@ -123,16 +135,16 @@ export class DemoBookingsSeeder { await manager.getRepository(Locomotive).upsert( [ { - code: 'LOC-001', - name: 'Demo Locomotive 1', + code: "LOC-001", + name: "Demo Locomotive 1", maxPullWeightTons: 3500, - status: 'AVAILABLE', + status: "AVAILABLE", }, { - code: 'LOC-002', - name: 'Demo Locomotive 2', + code: "LOC-002", + name: "Demo Locomotive 2", maxPullWeightTons: 2500, - status: 'AVAILABLE', + status: "AVAILABLE", }, ], { conflictPaths: { code: true } }, @@ -146,8 +158,8 @@ export class DemoBookingsSeeder { await manager.getRepository(ServiceType).upsert( { code: SERVICE_TYPE_CODE, - serviceName: 'Rail Container Service', - description: 'Temporary service type for train scheduling demos', + serviceName: "Rail Container Service", + description: "Temporary service type for train scheduling demos", canBeBookedAlone: true, includesFirstMile: false, includesLastMile: false, @@ -173,74 +185,86 @@ export class DemoBookingsSeeder { await manager.getRepository(Customer).upsert( { - userId: '00000000-0000-0000-0000-000000000111', - firstName: 'Train', - lastName: 'Scheduling', + userId: "00000000-0000-0000-0000-000000000111", + firstName: "Train", + lastName: "Scheduling", email: CUSTOMER_EMAIL, - phone: '251900000001', - companyName: 'Train Scheduling Demo Customer', + phone: "251900000001", + companyName: "Train Scheduling Demo Customer", companyEmail: CUSTOMER_EMAIL, - companyPhone: '251900000001', - companyLocation: 'Addis Ababa', - companyAddress: 'Demo Address', - customerType: 'DEMO', - status: 'ACTIVE', - contactPersonName: 'Train Scheduling', - contactPersonPhone: '251900000001', - tinNumber: '1234567890', - vatNumber: '1234567890', - fanNumber: '1234567890123456', - generalManagerName: 'Demo Manager', + companyPhone: "251900000001", + companyLocation: "Addis Ababa", + companyAddress: "Demo Address", + customerType: "DEMO", + status: "ACTIVE", + contactPersonName: "Train Scheduling", + contactPersonPhone: "251900000001", + tinNumber: "1234567890", + vatNumber: "1234567890", + fanNumber: "1234567890123456", + generalManagerName: "Demo Manager", generalManagerEmail: CUSTOMER_EMAIL, - generalManagerPhone: '251900000001', + generalManagerPhone: "251900000001", }, { conflictPaths: { email: true } }, ); const [serviceType, customer, yards, containerTypes] = await Promise.all([ - manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), - manager.getRepository(Customer).findOneByOrFail({ email: CUSTOMER_EMAIL }), + manager + .getRepository(ServiceType) + .findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager + .getRepository(Customer) + .findOneByOrFail({ email: CUSTOMER_EMAIL }), manager.getRepository(Yard).find(), manager.getRepository(ContainerType).find(), ]); const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); const containerTypeByCode = new Map( - containerTypes.map((containerType) => [containerType.code, containerType]), + containerTypes.map((containerType) => [ + containerType.code, + containerType, + ]), ); for (const demoBooking of DEMO_BOOKINGS) { const origin = yardByCode.get(demoBooking.originCode); const destination = yardByCode.get(demoBooking.destinationCode); - const containerType = containerTypeByCode.get(demoBooking.containerCode); + const containerType = containerTypeByCode.get( + demoBooking.containerCode, + ); if (!origin || !destination || !containerType) { - throw new Error(`demo_booking_seed_dependency_missing:${demoBooking.reference}`); + throw new Error( + `demo_booking_seed_dependency_missing:${demoBooking.reference}`, + ); } - const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + const vgmPerUnitTons = + demoBooking.totalWeightTons / demoBooking.quantity; await manager.getRepository(Booking).upsert( { reference: demoBooking.reference, - customerId: customer.id, - status: 'APPROVED', + companyId: customer.id, + status: "APPROVED", scheduledDate: new Date(demoBooking.scheduledDate), totalAmount: 0, - paymentStatus: 'PENDING', - contractType: 'NEW', + paymentStatus: "PENDING", + contractType: "NEW", serviceTypeId: serviceType.id, - equipmentReturn: 'WITHOUT_RETURN', + equipmentReturn: "WITHOUT_RETURN", originYardId: origin.id, destinationYardId: destination.id, - tradeDirection: 'IMPORT', - freightType: 'CONTAINER', + tradeDirection: "IMPORT", + freightType: "CONTAINER", cargoTypeId: null, cargoFreeText: null, shippingLineId: null, cargoTotalWeightVgm: demoBooking.totalWeightTons, isHazardous: false, - paymentCurrency: 'USD', + paymentCurrency: "USD", allowConsolidation: false, priorityScore: 0, versionNumber: 1, @@ -252,7 +276,9 @@ export class DemoBookingsSeeder { reference: demoBooking.reference, }); - await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager + .getRepository(BookingContainer) + .delete({ bookingId: booking.id }); await manager.getRepository(BookingContainer).insert({ id: randomUUID(), bookingId: booking.id, @@ -264,11 +290,13 @@ export class DemoBookingsSeeder { weightLimitRuleId: null, isOverweight: demoBooking.totalWeightTons > 70, overweightExcessTons: - demoBooking.totalWeightTons > 70 ? demoBooking.totalWeightTons - 70 : null, + demoBooking.totalWeightTons > 70 + ? demoBooking.totalWeightTons - 70 + : null, }); } }); - this.logger.log('Seeded demo train scheduling data'); + this.logger.log("Seeded demo train scheduling data"); } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts new file mode 100644 index 000000000..671436a73 --- /dev/null +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -0,0 +1,800 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity"; +import { Rate } from "../modules/rule-engine/entities/rate.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity"; +import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity"; +import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; + +const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; +const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; + +@Injectable() +export class PricingDataSeeder { + private readonly logger = new Logger(PricingDataSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run(): Promise { + await this.dataSource.transaction(async (manager) => { + const ctRepo = manager.getRepository(ContainerType); + const stRepo = manager.getRepository(ServiceType); + const yRepo = manager.getRepository(Yard); + const slRepo = manager.getRepository(ShippingLine); + const wlRepo = manager.getRepository(WeightLimitRule); + const prRepo = manager.getRepository(PriorityRule); + const rRepo = manager.getRepository(Rate); + + await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.seedWeightLimits(wlRepo, ctRepo); + await this.seedPriorityRules(prRepo); + const containerTypes = await ctRepo.find(); + const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct])); + + const rates = await this.seedRates(rRepo, ctByCode); + const ratesByType = new Map(); + for (const r of rates) { + const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; + if (!ratesByType.has(key)) ratesByType.set(key, []); + ratesByType.get(key)!.push(r); + } + + await this.seedSurchargeTypes(manager, ratesByType); + + const yards = await yRepo.find(); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const serviceTypes = await stRepo.find(); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const shippingLines = await slRepo.find(); + const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl])); + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + await this.seedDraftBookings( + ctByCode, + yardByCode, + stByCode, + slByCode, + cargoByCode, + ); + }); + + this.logger.log("Seeded pricing data"); + } + + private async upsertReferenceData( + manager: any, + ctRepo: any, + stRepo: any, + yRepo: any, + slRepo: any, + ): Promise { + await yRepo.upsert( + [ + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti", + displayOrder: 1, + isActive: true, + }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + isActive: true, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + isActive: true, + }, + { + code: "MODJO", + label: "Modjo", + country: "Ethiopia", + displayOrder: 4, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await ctRepo.upsert( + [ + { + code: "20FT", + label: "20FT Standard", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: "40FT", + label: "40FT Standard", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + { + code: "20FT_REEFER", + label: "20FT Reefer", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 3, + }, + { + code: "40FT_REEFER", + label: "40FT Reefer", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 4, + }, + ], + { 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: 100, + 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: 50, + isActive: true, + displayOrder: 3, + }, + ], + { conflictPaths: { code: true } }, + ); + + await slRepo.upsert( + [ + { + code: "MAERSK", + label: "Maersk Line", + mappedToCode: "MAERSK", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "MSC", + label: "MSC", + mappedToCode: "MSC", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "CMA_CGM", + label: "CMA CGM", + mappedToCode: "CMA_CGM", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "COSCO", + label: "COSCO Shipping", + mappedToCode: "COSCO", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "OTHER", + label: "Other Line", + mappedToCode: null, + showExtraFeeNotice: false, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: "GRAIN", + cargoTypeName: "Grain / Cereals", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: "FERTILIZER", + cargoTypeName: "Fertilizer", + requiresDirectorApproval: false, + 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, + }, + ], + { conflictPaths: { code: true } }, + ); + } + + private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { + await wlRepo.createQueryBuilder().delete().execute(); + const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); + const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); + const base = new Date("2026-01-01"); + await wlRepo.insert([ + { + containerTypeId: twenty.id, + tradeDirection: "IMPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: twenty.id, + tradeDirection: "EXPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "IMPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "EXPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + ]); + this.logger.log("Seeded weight limit rules"); + } + + private async seedPriorityRules(prRepo: any): Promise { + const existing = await prRepo.find({ + where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }], + }); + for (const r of existing) { + await prRepo.remove(r); + } + await prRepo.save([ + prRepo.create({ + code: "USD_PRIORITY", + label: "USD Payment Priority", + score: 200, + conditionCurrency: "USD", + isActive: true, + }), + prRepo.create({ + code: "STANDARD_PRIORITY", + label: "Standard Priority", + score: 50, + conditionCurrency: null, + isActive: true, + }), + ]); + this.logger.log("Seeded priority rules"); + } + + private async seedRates( + rRepo: any, + ctByCode: Map, + ): Promise { + const effectiveFrom = new Date("2026-01-01"); + const now = new Date(); + const rateData = [ + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 800, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 1200, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 45000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 67000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 600, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 900, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 34000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 50000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 20000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 30000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 1000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 56000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 750, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 42000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: null, + currency: "ETB", + rateValue: 25000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 40, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2200, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "USD", + rateValue: 25, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "ETB", + rateValue: 1400, + rateUnit: "PER_TON", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 150, + rateUnit: "FLAT", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 8500, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 200, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 11000, + rateUnit: "FLAT", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "USD", + rateValue: 100, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "ETB", + rateValue: 5500, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_CONTAINER", + }, + ]; + + const entities = rateData.map((d) => + rRepo.create({ + ...d, + status: "LIVE", + proposedByStaffId: STAFF_USER_ID, + approvedByCeoId: CEO_USER_ID, + approvedAt: now, + effectiveFrom, + }), + ); + return rRepo.save(entities); + } + + private async seedSurchargeTypes( + manager: any, + ratesByType: Map, + ): Promise { + const surRepo = manager.getRepository(SurchargeType); + const bcmRepo = manager.getRepository(BookingCargoModifier); + await bcmRepo.createQueryBuilder().delete().execute(); + const findRate = (rateType: string, currency: string) => { + const key = `${rateType}|${currency}|`; + const rates = ratesByType.get(key); + return rates?.[0]; + }; + + const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); + const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB"); + const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); + const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB"); + const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); + const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB"); + const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); + const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB"); + const consolidRateUsd = findRate("LASHING", "USD"); + const consolidRateEtb = findRate("LASHING", "ETB"); + + await surRepo.createQueryBuilder().delete().execute(); + await surRepo.save([ + surRepo.create({ + code: "HAZARDOUS_CARGO", + label: "Hazardous Cargo", + triggerCondition: "CARGO_FLAG_HAZARDOUS", + rateId: hazardRateUsd?.id ?? hazardRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "REEFER_CARGO", + label: "Reefer Cargo", + triggerCondition: "CARGO_FLAG_REEFER", + rateId: reeferRateUsd?.id ?? reeferRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "OVERWEIGHT_CARGO", + label: "Overweight Cargo", + triggerCondition: "VGM_EXCEEDS_LIMIT", + rateId: overweightRateUsd?.id ?? overweightRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "SHIPPING_LINE_FEE", + label: "Shipping Line Fee", + triggerCondition: "SHIPPING_LINE_MAPPED", + rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "CONSOLIDATION_FEE", + label: "Consolidation Fee", + triggerCondition: "CONSOLIDATION_ENABLED", + rateId: consolidRateUsd?.id ?? consolidRateEtb?.id, + isActive: true, + }), + ]); + this.logger.log("Seeded surcharge types"); + } + + private async seedDraftBookings( + ctByCode: Map, + yardByCode: Map, + stByCode: Map, + slByCode: Map, + cargoByCode: Map, + ): Promise { + const djibouti = yardByCode.get("DJIBOUTI")!; + const addis = yardByCode.get("ADDIS_ABABA")!; + const railContainer = stByCode.get("RAIL_CONTAINER")!; + const railBulk = stByCode.get("RAIL_BULK")!; + const maersk = slByCode.get("MAERSK")!; + const grain = cargoByCode.get("GRAIN")!; + const twenty = ctByCode.get("20FT")!; + const forty = ctByCode.get("40FT")!; + const twentyReefer = ctByCode.get("20FT_REEFER")!; + + const drafts = [ + { + reference: "BKG-PRICE-001", + description: "Standard 20FT container import — base rail only", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 250, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-002", + description: "40FT container import + hazardous surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: true, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 135, + containers: [ + { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["HAZARDOUS_CARGO"], + }, + { + reference: "BKG-PRICE-003", + description: "20FT container import + shipping line (ETB)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "ETB", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: maersk.id, + cargoTypeId: null, + cargoTotalWeightVgm: 480, + containers: [ + { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, + ], + expectedBaseRate: 45000, + expectedSurcharges: ["SHIPPING_LINE_FEE"], + }, + { + reference: "BKG-PRICE-004", + description: "40FT container import + consolidation (USD)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: true, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 224, + containers: [ + { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["CONSOLIDATION_FEE"], + }, + { + reference: "BKG-PRICE-005", + description: "Bulk import — grain", + freightType: "BULK" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railBulk.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: grain.id, + cargoTotalWeightVgm: 500, + containers: [], + expectedBaseRate: 50, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-006", + description: "20FT reefer container import + reefer surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 75, + containers: [ + { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["REEFER_CARGO"], + }, + { + reference: "BKG-PRICE-007", + description: "20FT container import + overweight (30t > 26t limit)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 300, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["OVERWEIGHT_CARGO"], + }, + ]; + + this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`); + } +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index 73977fc7c..07243cdec 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -16,7 +16,6 @@ import { ShieldCheck, AlertTriangle, Info, - Clock, Layers, CheckCircle2, History, @@ -36,7 +35,6 @@ import { import Breadcrumbs from "@/components/Breadcrumbs"; import { api } from "@/services/api"; import type { Freight } from "@edr/types"; -import type { GeneratePriceResponse } from "@/services/bookings.service"; import { Card, CardHeader, @@ -46,6 +44,14 @@ import { Badge, Button, Separator, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, } from "@edr/ui-common"; import { cn } from "@/lib/utils"; import useAuth from "@/hooks/useAuth"; @@ -57,12 +63,40 @@ const PROGRESS_STAGES = [ { label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] }, ]; -const STATUS_MAP: Record = { - DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 }, - CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 }, - IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 }, - DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 }, - CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 }, +const STATUS_MAP: Record< + string, + { title: string; description: string; color: string; stage: number } +> = { + DRAFT: { + title: "Drafting Request", + description: "Booking is being prepared and has not been submitted.", + color: "text-slate-500", + stage: 0, + }, + CONFIRMED: { + title: "Booking Confirmed", + description: "Booking has been confirmed and approved.", + color: "text-emerald-600", + stage: 1, + }, + IN_TRANSIT: { + title: "Cargo Moving", + description: "Shipment is currently moving through the rail network.", + color: "text-sky-600", + stage: 2, + }, + DELIVERED: { + title: "Service Complete", + description: "Cargo delivered and service successfully terminated.", + color: "text-emerald-600", + stage: 3, + }, + CANCELLED: { + title: "Cancelled", + description: "This booking process has been terminated.", + color: "text-red-600", + stage: -1, + }, }; const REQUIRED_DOC_FIELDS = [ @@ -74,10 +108,14 @@ const REQUIRED_DOC_FIELDS = [ export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); const queryClient = useQueryClient(); - const { data: booking, isLoading, isError, error } = useQuery( + const { + data: booking, + isLoading, + isError, + error, + } = useQuery( api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id, @@ -85,7 +123,9 @@ export default function BookingDetailPage() { ); const refetchBooking = () => { - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: id! }) }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: id! }), + }); }; if (isLoading) { @@ -93,7 +133,9 @@ export default function BookingDetailPage() {
-

Loading booking details…

+

+ Loading booking details… +

); @@ -110,7 +152,9 @@ export default function BookingDetailPage() { Failed to load booking

- {error instanceof Error ? error.message : "An unexpected error occurred."} + {error instanceof Error + ? error.message + : "An unexpected error occurred."}

@@ -133,7 +177,9 @@ export default function BookingDetailPage() { } if (booking.status === "DRAFT") { - return ; + return ( + + ); } return ; @@ -151,20 +197,20 @@ function DraftBookingView({ const { customer } = useAuth(); const fileInputRefs = useRef>({}); - const [pricingData, setPricingData] = useState(null); - const [selectedFiles, setSelectedFiles] = useState>({}); + const [selectedFiles, setSelectedFiles] = useState< + Record + >({}); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); const anyFileSelected = Object.values(selectedFiles).some(Boolean); - const allDocsProvided = !anyFileSelected; - const priceMutation = useMutation({ - mutationFn: () => api.bookings.generatePrice.call({ id: booking.id }), - onSuccess: (data) => { - setPricingData(data); - queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: booking.id }) }); - }, - }); + const pricingQuery = useQuery( + api.bookings.generatePrice.queryOptions({ + input: { id: booking.id }, + enabled: !!booking.id, + }), + ); const uploadMutation = useMutation({ mutationFn: (files: Record) => @@ -187,6 +233,7 @@ function DraftBookingView({ mutationFn: (reason: string) => api.bookings.cancel.call({ id: booking.id, reason }), onSuccess: () => { + setCancelDialogOpen(false); onBookingUpdated(); }, }); @@ -211,17 +258,19 @@ function DraftBookingView({ cancelMutation.mutate(reason); } - const canConfirm = !!pricingData && !uploadMutation.isPending && !submitMutation.isPending; + const canConfirm = + pricingQuery.isSuccess && !uploadMutation.isPending && !submitMutation.isPending; const companyName = (customer as any)?.company?.name ?? "—"; const companyTin = (customer as any)?.company?.tin ?? "—"; const contactName = (customer as any)?.profile - ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || "—" + ? `${(customer as any).profile.firstName ?? ""} ${(customer as any).profile.lastName ?? ""}`.trim() || + "—" : "—"; const contactEmail = (customer as any)?.profile?.email ?? "—"; return ( -
+
- {priceMutation.isError && ( + {pricingQuery.isError && (

Pricing failed

- {priceMutation.error instanceof Error - ? priceMutation.error.message + {pricingQuery.error instanceof Error + ? pricingQuery.error.message : "An unexpected error occurred."}

@@ -307,50 +356,85 @@ function DraftBookingView({
)} - + Pricing Estimation - Generate a price estimate based on your booking details. + Price estimate based on your booking details. - {pricingData ? ( + {pricingQuery.isLoading ? ( +
+ +

+ Calculating price… +

+
+ ) : pricingQuery.isError ? ( +
+

+ Could not calculate price. +

+ +
+ ) : pricingQuery.data ? (
- + - {pricingData.lineItems.map((item, i) => ( + {pricingQuery.data.lineItems.map((item, i) => ( - + ))} - +
DescriptionAmount + Amount +
{item.description} + {item.description} + {item.amount.toLocaleString()} {item.currency}
Total Estimated Cost + Total Estimated Cost + - {pricingData.totalAmount.toLocaleString()} {pricingData.currency} + {pricingQuery.data.totalAmount.toLocaleString()}{" "} + {pricingQuery.data.currency}
- {pricingData.warnings.length > 0 && ( + {pricingQuery.data.warnings.length > 0 && (
- {pricingData.warnings.map((w, i) => ( -

+ {pricingQuery.data.warnings.map((w, i) => ( +

{w}

@@ -362,40 +446,13 @@ function DraftBookingView({
- ) : ( -
-
- -
-
-

No pricing yet

-

- Generate a price estimate to review before submitting. -

-
- -
- )} + ) : null}
@@ -406,8 +463,8 @@ function DraftBookingView({ Required Documents - Provide the necessary documents for this booking. Some information is - pre-filled from your company profile. + Provide the necessary documents for this booking. Some information + is pre-filled from your company profile. @@ -460,7 +517,10 @@ function DraftBookingView({ accept=".pdf,.jpg,.jpeg,.png" className="hidden" onChange={(e) => { - handleFileSelect(doc.key, e.target.files?.[0] ?? null); + handleFileSelect( + doc.key, + e.target.files?.[0] ?? null, + ); }} /> {selectedFiles[doc.key] && ( + +

+ Cancelling will terminate this booking request and cannot be + undone. +

+ + + + + + + Cancel Booking + + Are you sure you want to cancel this booking? This action + cannot be undone. + + +
+ + setCancelReason(e.target.value)} + autoFocus + /> +
+ + + + + + +
+
@@ -556,9 +652,11 @@ function DraftBookingView({
{!canConfirm && (

- {!pricingData - ? "Request pricing estimation before confirming." - : "Upload documents before confirming."} + {pricingQuery.isLoading + ? "Calculating price…" + : pricingQuery.isError + ? "Price calculation failed." + : "Upload documents before confirming."}

)} - - - )} + {(booking.status === "CONFIRMED" || + booking.status === "IN_TRANSIT") && ( + + +
+

Contract ready

+

+ Review the agreement and apply your digital signature. +

+
+ +
+
+ )} @@ -658,14 +756,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { Booking Status Lifecycle - Track the journey from request to completion + + Track the journey from request to completion +
= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }} + style={{ + width: + currentStageIndex >= 0 + ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` + : "0%", + }} />
@@ -674,19 +779,32 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { const isActive = idx === currentStageIndex; return ( -
-
- {isCompleted ? : } +
+
+ {isCompleted ? ( + + ) : ( + + )}
- + {stage.label}
@@ -695,26 +813,40 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
-
- {normalizedStatus === "CANCELLED" ? : } -
-
-

- {statusConfig.title} -

-

- {statusConfig.description} -

-
- {normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && ( -
+
+ {normalizedStatus === "CANCELLED" ? ( + + ) : ( + + )} +
+
+

+ {statusConfig.title} +

+

+ {statusConfig.description} +

+
+ {normalizedStatus !== "CANCELLED" && + normalizedStatus !== "DELIVERED" && ( +
-

Est. Waiting

-

1-2 Working Days

+

+ Est. Waiting +

+

+ 1-2 Working Days +

-
- )} +
+ )}
@@ -740,7 +872,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- + Rail
@@ -752,9 +887,31 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- } label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} /> - } label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} /> - } label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} /> + } + label="Service" + value={ + booking.serviceType === "RAIL_AND_FORWARDING" + ? "Rail & Forwarding" + : "Rail Only" + } + /> + } + label="Return" + value={ + booking.equipmentReturn === "WITH_RETURN" + ? "With Return" + : "Without Return" + } + /> + } + label="Trade" + value={ + booking.tradeDirection === "IMPORT" ? "Import" : "Export" + } + />
@@ -771,14 +928,23 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {

First Mile

- +

Last Mile

- {booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"} + {booking.lastMileEnabled && booking.lastMileDeliveryAddress + ? booking.lastMileDeliveryAddress + : "Not requested"}

@@ -793,31 +959,57 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) {
- } label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} /> - } label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} /> - } label="Currency" value={booking.paymentCurrency} /> + } + label="Freight Type" + value={ + booking.freightType === "BULK" ? "Bulk" : "Break Bulk" + } + /> + } + label="Weight (VGM)" + value={`${booking.cargoTotalWeightVgm} Tons`} + /> + } + label="Currency" + value={booking.paymentCurrency} + />
{booking.containers && booking.containers.length > 0 && ( <>
-

Load Details

+

+ Load Details +

- - + + {booking.containers.map((c, i) => ( - - - + + + ))} @@ -839,7 +1031,10 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { - +
@@ -860,15 +1055,21 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { {booking.freightSubtype && (
-

Cargo Description

-

"{booking.freightSubtype}"

+

+ Cargo Description +

+

+ "{booking.freightSubtype}" +

)} {booking.financialTerms && ( <>
-

Financial Terms

+

+ Financial Terms +

@@ -879,7 +1080,9 @@ function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { )} {!booking.freightSubtype && !booking.financialTerms && ( -

No additional information provided.

+

+ No additional information provided. +

)} @@ -917,17 +1120,23 @@ function RouteEndpoint({ function InfoItem({ icon, label, - value + value, }: { icon?: React.ReactNode; label: string; - value?: string | number | null + value?: string | number | null; }) { return (
- {icon &&
{icon}
} + {icon && ( +
+ {icon} +
+ )}
-

{label}

+

+ {label} +

{value ?? "—"}

@@ -946,9 +1155,12 @@ function StatusBadge({ status }: { status: string }) { return ( - {status.replace(/_/g, ' ')} + {status.replace(/_/g, " ")} ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 672b1f562..847fac2bd 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -18,7 +18,6 @@ import { trackingService } from "./tracking.service"; import { fileUploadSettingsService } from "./fileUploadSettings.service"; import { dropdownSettingsService } from "./dropdownSettings.service"; import { authService } from "./auth.service"; -import { customersService } from "./customers.service"; import { companiesService } from "./companies.service"; import { CreateDropdownOptionDto, @@ -28,11 +27,6 @@ import { UpdateDropdownOptionDto, UpdateDropdownSettingDto, } from "@/types/dropdownSettings"; -import { - CreateCustomerDto, - Customer, - UpdateCustomerDto, -} from "@/types/customers"; import type { CompanyInfoResponse, CreateCompanyPayload, diff --git a/tasks.md b/tasks.md deleted file mode 100644 index b829182c4..000000000 --- a/tasks.md +++ /dev/null @@ -1,5 +0,0 @@ -- In ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step1-contract-type.tsx the input field for the previous contract ref should be a select field with the list of contracts from the API -- the "Equipment Return" field in ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step3-first-last-mile.tsx should be a toggle like Last Mile - Delivery and should appear only if the last mile is toggled. and also add "( Door to Port)" to first mile description and vice versa to the last mile -- add Type of container- Dry container, high cubic containers , reefer containers, open top containers, flat rack, tank container, open side containers to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx -- merge the "Unpaired 20ft Container" from ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step6-wagon-allocation.tsx to step 4 and fully remove the step 5 -- add Type of Shipping lines - MSC, CMA CGM, Evergreen, COSCO, Hapag-Lloyd, ONE, Yang Ming, ZIM, Messina Line, Safmarine, Wan Hai, Ethiopian Shipping Lines (ESLSE) to ./apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step4-route.tsx
TypeQuantityVGM (Tons) + Quantity + + VGM (Tons) +
{c.type}{c.qty} Units{c.vgm}t + {c.type} + + {c.qty} Units + + {c.vgm}t +