From f39aad1d7f54a7033687cfea0d49eb284455cb5b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 21 Jul 2026 11:57:03 +0300 Subject: [PATCH 01/71] fix: ( bookings ) make BookingSeat.scheduleId optional to fix P2032 on booking read --- apps/edr-passenger-api/prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index b58356365..db46df025 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -580,7 +580,7 @@ model BookingSeat { bookingId String seatId String leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2 - scheduleId String // which schedule this seat belongs to + scheduleId String? // which schedule this seat belongs to passengerName String dateOfBirth DateTime? passengerCategory PassengerCategory @default(ADULT) From d76d198a180e37c8b6a0a680331be6f752e1376d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 09:08:53 +0000 Subject: [PATCH 02/71] fix(companies): default customer list to review-queue ordering Marketing asked for approval requests to surface in order instead of the alphabetical default. New sortBy=review tiers the list by what needs action - submitted applications awaiting first approval, then approved customers with a pending change request, then everyone else (drafts included) - newest first within each tier. Exposed as the backoffice "Needs review first" sort option and made the default on both ends. EDRFREIGHT-232 --- .../modules/companies/companies.repository.ts | 28 +++++++++++++++++-- .../companies/dto/list-companies-query.dto.ts | 16 +++++++---- .../src/pages/customers/CustomersPage.tsx | 8 ++++-- .../backoffice/src/types/customer.ts | 3 +- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 3ac12b11a..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository { AND ccr.deleted_at IS NULL )`; + /** + * The `sortBy = 'review'` queue ordering: whatever marketing must act on + * floats to the top. Tier 0 — submitted applications awaiting first approval + * (drafts excluded: nothing to review yet). Tier 1 — approved customers with + * a pending change request. Tier 2 — everyone else, drafts included. + */ + private static readonly REVIEW_TIER_SQL = `(CASE + WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0 + WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1 + ELSE 2 + END)`; + constructor( @InjectRepository(Company) repo: Repository, @@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository { status, onboardingCompleted, hasPendingChangeRequest, - sortBy = 'name', - sortOrder = 'ASC', + sortBy = 'review', + sortOrder = 'DESC', } = query; const qb = this.repository @@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository { } // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. + if (sortBy === 'review') { + // Queue ordering: actionable tiers first, newest first within each. The + // tier is selected under an alias because skip/take pagination with + // joins re-derives the ORDER BY in a subquery — a raw expression there + // breaks, a selected alias survives. + qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier') + .orderBy('review_tier', 'ASC') + .addOrderBy('company.createdAt', 'DESC'); + } else { + qb.orderBy(`company.${sortBy}`, sortOrder); + } const [items, total] = await qb - .orderBy(`company.${sortBy}`, sortOrder) // Names are not unique and createdAt can tie on bulk imports; the id // tiebreaker keeps paging stable instead of dropping/repeating rows. .addOrderBy('company.id', 'ASC') diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8d4910ded..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -60,15 +60,19 @@ export class ListCompaniesQueryDto { hasPendingChangeRequest?: boolean; @ApiPropertyOptional({ - enum: ["name", "createdAt", "updatedAt"], - default: "name", - description: "Column to order by. Defaults to name for backwards compatibility.", + enum: ["review", "name", "createdAt", "updatedAt"], + default: "review", + description: + "Column to order by. The default `review` is a review-queue ordering: " + + "companies awaiting first approval, then those with a pending change " + + "request, then everyone else — newest first within each group. The " + + "other values are plain column sorts.", }) @IsOptional() - @IsIn(["name", "createdAt", "updatedAt"]) - sortBy?: "name" | "createdAt" | "updatedAt"; + @IsIn(["review", "name", "createdAt", "updatedAt"]) + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["ASC", "DESC"]) diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index d70e6d040..4c63dc09e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -81,6 +81,10 @@ const VIEW_FILTERS: Record< }; const SORT_OPTIONS = [ + // Queue ordering: awaiting first approval → pending profile changes → the + // rest, newest first within each group. The default, so whatever marketing + // must act on is always on top of the list. + { value: "review:DESC", label: "Needs review first" }, { value: "createdAt:DESC", label: "Newest first" }, { value: "createdAt:ASC", label: "Oldest first" }, { value: "name:ASC", label: "Name (A–Z)" }, @@ -93,11 +97,11 @@ export default function CustomersPage() { const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); const [view, setView] = useState("all"); - const [sort, setSort] = useState("createdAt:DESC"); + const [sort, setSort] = useState("review:DESC"); const filter = useMemo(() => { const [sortBy, sortOrder] = sort.split(":") as [ - "name" | "createdAt" | "updatedAt", + "review" | "name" | "createdAt" | "updatedAt", "ASC" | "DESC", ]; return { diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 419e93d25..53d3daed8 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -207,7 +207,8 @@ export interface CompanyListFilter { * already `active`, so `status` alone can never surface them. */ hasPendingChangeRequest?: boolean; - sortBy?: "name" | "createdAt" | "updatedAt"; + /** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */ + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; sortOrder?: "ASC" | "DESC"; } From 4f6a559ae331bd566881a3aabc5da5020f9f9eff Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 09:09:04 +0000 Subject: [PATCH 03/71] feat(portal): reviewer note and resubmit for rejected roles in settings A customer whose role was rejected saw only a bare "Rejected" chip in settings - the reviewer's note and the resubmit action existed solely inside the contract wizard's block modal, so fixing and reapplying from the profile page was impossible. The settings role card now shows the reviewer note (for suspended roles too) and offers "Resubmit for approval", which flips the role back to Pending and notifies the backoffice through the existing roleReapplied inbox event. RoleCard's locked variant now renders as a plain box instead of a button so it can host the action button (buttons cannot nest) and the new detail line. EDRFREIGHT-233 --- .../src/pages/settings/CompanyRolesCard.tsx | 41 ++++++- .../portal/src/pages/settings/RoleCard.tsx | 108 +++++++++++------- 2 files changed, 106 insertions(+), 43 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index fc0407d5e..974af456e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; +import { Building2, CheckCircle2, RefreshCw, Save, XCircle } from "lucide-react"; import { Button, Card, @@ -87,6 +87,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { }, }); + // Resubmit a rejected role for review. Flips it back to Pending server-side + // and pings the backoffice, so the fix-and-resubmit loop can happen entirely + // from settings instead of only from the contract page's rejection banner. + const reapplyMutation = useMutation({ + mutationFn: (profileId: string) => + api.companies.reapplyProfile.call({ profileId }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + const handleSave = () => { if (selected.size === 0) return; mutation.mutate(Array.from(selected)); @@ -113,6 +129,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { {options.map((opt) => { const existing = profileByType.get(opt.type); const view = existing ? roleStatusView(existing) : undefined; + const rejected = existing?.status === "rejected"; return ( } + loading={ + reapplyMutation.isPending && + reapplyMutation.variables === existing.id + } + onClick={() => reapplyMutation.mutate(existing.id)} + > + Resubmit for approval + + ) : undefined + } onClick={() => toggle(opt.type)} /> ); diff --git a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx index 0a6b98dc4..0061d3318 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx @@ -18,6 +18,14 @@ export interface RoleCardProps { lockedNote?: string; /** Mantine color for {@link lockedNote}; matches the role's status. */ lockedNoteColor?: string; + /** Extra muted line under {@link lockedNote}, e.g. the reviewer's note. */ + detail?: string; + /** + * Interactive content (e.g. a resubmit button) rendered inside the card. + * Only honoured on a locked card — the interactive variant is itself a + * button, and buttons cannot nest. + */ + action?: React.ReactNode; onClick?: () => void; } @@ -35,54 +43,70 @@ export default function RoleCard({ approved = false, lockedNote, lockedNoteColor = "edr-green", + detail, + action, onClick, }: RoleCardProps) { const highlighted = selected || approved; - return ( - - - - {icon} - - - - {label} + const className = `group block rounded-lg border! p-5! text-left transition-all duration-200 ${ + highlighted + ? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!" + : "border-edr-border! bg-edr-card!" + } ${ + locked + ? "cursor-default" + : "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft" + }`; + + const content = ( + + + {icon} + + + + {label} + + + {description} + + {lockedNote && ( + + {lockedNote} - - {description} - - {lockedNote && ( - - {lockedNote} - - )} - - {highlighted && ( - )} - + {locked && detail && ( + + {detail} + + )} + {locked && action && {action}} + + {highlighted && ( + + )} + + ); + + // A locked card is display-only, so it renders as a plain box — which also + // lets `action` hold real buttons without nesting them inside a button. + if (locked) { + return {content}; + } + + return ( + + {content} ); } From c8f932f5d5603728a0a1008bfdd44d6a37c0daaf Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 09:09:15 +0000 Subject: [PATCH 04/71] feat(companies): require and deliver a staff message on suspend/reactivate Staff could suspend or reactivate a customer role with one silent click: no reason captured, nothing stored, and the customer was never told. The API now rejects a suspend or reactivate without a non-empty note, keeps the note in reviewNote while suspended, and sends the customer an SMS/email/in-app notification quoting the staff message. In the backoffice the reject-note modal is generalised into a decision modal shared by reject, suspend and reactivate, so all three force a message. EDRFREIGHT-188 --- .../modules/companies/companies.service.ts | 44 ++++++- .../companies/company-notifier.service.ts | 37 ++++++ .../src/components/customers/badges.tsx | 110 +++++++++++++----- 3 files changed, 159 insertions(+), 32 deletions(-) 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 df0e14998..73e2fdc00 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1090,6 +1090,23 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1176,9 +1193,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -1192,6 +1213,23 @@ export class CompaniesService { if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); + // Suspension and reactivation lock/unlock a role the customer relies on — + // tell them, and carry the staff message so they know why. + const reactivated = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if (status === ProfileStatus.Suspended || reactivated) { + const company = await this.companiesRepo.findById(updated.companyId); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + status === ProfileStatus.Suspended ? "suspended" : "reactivated", + note ?? "", + ); + } + } + // Approving any profile promotes a pending company to active, so the // customer can start working as soon as their first profile is cleared. if (status === ProfileStatus.Active) { diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index f71a67976..d9aac8e45 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -89,6 +89,43 @@ export class CompanyNotifierService { }); } + /** + * Tell the customer one of their operational roles was suspended or + * reactivated, quoting the staff message — the service layer requires one for + * both transitions, so the customer always learns why, not just what. + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence = + change === "suspended" + ? `You will not be able to operate under this role until it is reactivated; ` + + `your other roles are unaffected.` + : `You can operate under this role again.`; + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence} Message from EDR staff: ${staffMessage}`; + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link: "/settings", + data: { companyId: company.id, profileType, change, staffMessage }, + priority: NotificationPriority.HIGH, + }); + } + // ── Backoffice-facing: work has arrived back in the review queue ──────────── /** diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 6cb6759e7..04267cc7b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -298,34 +298,82 @@ export function ProfileApprovalActions({ const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); - const [rejectOpen, setRejectOpen] = useState(false); + const [decision, setDecision] = useState< + "reject" | "suspend" | "reactivate" | null + >(null); const [note, setNote] = useState(""); const act = (next: ProfileStatus) => mutate({ profileId, status: next }); - const confirmReject = () => { + // Decisions the customer must be given a reason for. Reject/suspend/reactivate + // all capture a required message through the same modal; the API refuses + // suspend/reactivate without one. + const DECISIONS = { + reject: { + title: "Reject profile", + intro: + "Tell the customer what needs fixing. They'll see this note and can " + + "amend and resubmit the role for approval.", + label: "Reason for rejection", + placeholder: "e.g. The uploaded business license is expired.", + confirmLabel: "Reject profile", + color: "red", + status: "rejected" as ProfileStatus, + }, + suspend: { + title: "Suspend role", + intro: + "Explain why this role is being suspended. The customer will see this " + + "message and cannot operate under the role until it is reactivated.", + label: "Reason for suspension", + placeholder: "e.g. Outstanding invoices unpaid for over 90 days.", + confirmLabel: "Suspend role", + color: "orange", + status: "suspended" as ProfileStatus, + }, + reactivate: { + title: "Reactivate role", + intro: + "Explain why this role is being reactivated. The customer will see " + + "this message and can operate under the role again.", + label: "Reactivation message", + placeholder: "e.g. Outstanding payments have been settled.", + confirmLabel: "Reactivate role", + color: "edr-green", + status: "active" as ProfileStatus, + }, + } as const; + + const openDecision = (kind: keyof typeof DECISIONS) => { + setNote(""); + setDecision(kind); + }; + + const active = decision ? DECISIONS[decision] : null; + + const confirmDecision = () => { + if (!active) return; mutate( - { profileId, status: "rejected", note: note.trim() }, - { onSuccess: () => setRejectOpen(false) }, + { profileId, status: active.status, note: note.trim() }, + { onSuccess: () => setDecision(null) }, ); }; - const rejectModal = ( + const decisionModal = active && ( setRejectOpen(false)} - title="Reject profile" + opened + onClose={() => setDecision(null)} + title={active.title} centered radius="lg" > - Tell the customer what needs fixing. They'll see this note and can - amend and resubmit the role for approval. + {active.intro} -

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

- -
- - - - -
-
-
-
- - -
-
- -
- - - - - -
-
- - - - - - - -
- -
- - - - - - - - -
JourneyDuplicate Bookings
-
-
- - - - - diff --git a/booking-extractor.html b/booking-extractor.html deleted file mode 100644 index 844c98b2c..000000000 --- a/booking-extractor.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - EDR Booking Extractor - - - - -

EDR Booking Extractor

- -
- -
- - Drop bookings.json here or click to browse -
-

Accepts a JSON array of bookings or an object with a bookings key.

-
- - - -
-
- -
-
-
- - - -
-
- - - - - - - - - - - - - - - - - - - - - -
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
-
-
- - - - - diff --git a/booking-proxy.mjs b/booking-proxy.mjs deleted file mode 100644 index 27f9248ee..000000000 --- a/booking-proxy.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import http from 'http'; -import https from 'https'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const PORT = 8080; -const __dir = path.dirname(fileURLToPath(import.meta.url)); - -const server = http.createServer((req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - - // Serve any .html file in the same directory - if (req.url === '/' || req.url.endsWith('.html')) { - const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); - const filepath = path.join(__dir, filename); - if (fs.existsSync(filepath)) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - fs.createReadStream(filepath).pipe(res); - } else { - res.writeHead(404); res.end('Not found'); - } - return; - } - - // Proxy /proxy?url= - if (req.url.startsWith('/proxy?url=')) { - const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); - const parsed = new URL(target); - const mod = parsed.protocol === 'https:' ? https : http; - const options = { - hostname: parsed.hostname, - port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), - path: parsed.pathname + parsed.search, - method: req.method, - headers: { ...req.headers, host: parsed.hostname }, - }; - const proxy = mod.request(options, (apiRes) => { - res.writeHead(apiRes.statusCode, apiRes.headers); - apiRes.pipe(res); - }); - proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); - req.pipe(proxy); - return; - } - - res.writeHead(404); res.end(); -}); - -server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/ticket-extractor.html b/ticket-extractor.html deleted file mode 100644 index 17be60576..000000000 --- a/ticket-extractor.html +++ /dev/null @@ -1,239 +0,0 @@ - - - - - - EDR Ticket Extractor - - - - -

EDR Ticket Extractor

- -
- -
- - Drop tickets.json here or click to browse -
-

Accepts a JSON array of tickets or an object with a tickets key.

-
- - - -
-
- -
-
-
- - - - - - - - - - - - - - - - - - -
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
-
-
- - - - - From c923c88983b609a76e44630d43428e262a23877f Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 08:02:23 +0300 Subject: [PATCH 35/71] Tables gagination updates --- .../backoffice/src/app/agents/page.tsx | 8 ++++- .../backoffice/src/app/coaches/page.tsx | 11 +++++-- .../backoffice/src/app/payments/page.tsx | 8 ++++- .../src/app/reports/passengers/page.tsx | 15 ++++++---- .../app/reports/payment-discrepancy/page.tsx | 13 ++++++-- .../backoffice/src/app/reports/seats/page.tsx | 19 ++++++++---- .../backoffice/src/app/schedules/page.tsx | 21 ++++++++----- .../backoffice/src/app/stations/page.tsx | 8 ++++- .../backoffice/src/app/tickets/page.tsx | 30 ++++++++++++------- .../backoffice/src/app/trains/page.tsx | 7 ++++- .../backoffice/src/lib/use-pagination.ts | 18 +++++++++++ 11 files changed, 120 insertions(+), 38 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/lib/use-pagination.ts diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index 9efd7ab19..b2967c58d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -9,6 +9,8 @@ import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { agentsApi, apiClient } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -90,6 +92,9 @@ export default function AgentsPage() { queryFn: () => agentsApi.getAll(filters), }); + const allAgents = data?.items || []; + const { paged: pagedAgents, page, totalPages, setPage } = usePagination(allAgents, 20); + const columns = [ { key: 'agentCode', @@ -182,12 +187,13 @@ export default function AgentsPage() { + = { passenger: 'edr-badge-info', sleeper: 'edr-badge-warning', @@ -551,11 +556,12 @@ export default function CoachesPage() { + )} @@ -574,11 +580,12 @@ export default function CoachesPage() { + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx index b9c81a183..9cbfdd4e3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payments/page.tsx @@ -9,6 +9,8 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { paymentsApi, apiClient } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; import { formatDateTime, formatCurrency } from '@/lib/utils'; import SupplementaryChargesModal from './SupplementaryChargesModal'; import { @@ -109,6 +111,9 @@ export default function PaymentsPage() { }), }); + const allPayments = (data as any)?.items || (Array.isArray(data) ? data : []); + const { paged: pagedPayments, page: paymentsPage, totalPages: paymentsTotalPages, setPage: setPaymentsPage } = usePagination(allPayments, 20); + const PAYMENT_COLS = [ { key: 'reference', label: 'Reference' }, { key: 'booking', label: 'Booking Reference' }, @@ -311,12 +316,13 @@ export default function PaymentsPage() { + {/* Payment Details Modal */} setSelectedPayment(null)} title="Payment Details" size="xl"> diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index d4d78ab01..35b6d6cc7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -6,6 +6,8 @@ import { Users, Armchair, BarChart3, Train, Download } from "lucide-react"; import { apiClient } from "@/lib/api-client"; import { formatDateTime } from "@/lib/utils"; import ActionButton from "@/components/ui/ActionButton"; +import Pagination from "@/components/ui/Pagination"; +import { usePagination } from "@/lib/use-pagination"; interface ScheduleOption { id: string; @@ -127,6 +129,8 @@ export default function PassengersReportPage() { }) .sort((a, b) => a.bookingRef.localeCompare(b.bookingRef)); + const { paged: pagedList, page: listPage, totalPages: listTotalPages, setPage: setListPage, reset: resetListPage } = usePagination(filteredList, 50); + const downloadCsv = (csv: string, filename: string) => { const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); @@ -457,12 +461,12 @@ export default function PassengersReportPage() { className="input max-w-sm flex-1" placeholder="Search by name or booking ref…" value={listSearch} - onChange={(e) => setListSearch(e.target.value)} + onChange={(e) => { setListSearch(e.target.value); resetListPage(); }} /> setFilterCoachNumber(e.target.value)} + onChange={(e) => { setFilterCoachNumber(e.target.value); resetListPage(); }} > {coachNumberOptions.map((c) => ( @@ -486,7 +490,7 @@ export default function PassengersReportPage() { setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")} + onChange={(e) => { setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID"); resetSeatsPage(); }} > @@ -280,7 +285,7 @@ export default function SeatStatusReportPage() { - {filtered.map((row, i) => { + {pagedSeats.map((row, i) => { const isPaid = row.bookingStatus === "CONFIRMED" || row.bookingStatus === "BOARDED"; return ( @@ -306,7 +311,7 @@ export default function SeatStatusReportPage() { ); })} - {filtered.length === 0 && ( + {pagedSeats.length === 0 && ( No seats found @@ -316,6 +321,7 @@ export default function SeatStatusReportPage() { + } {/* Blocked Seats Tab */} @@ -338,12 +344,12 @@ export default function SeatStatusReportPage() { - {data.blockedSeats.length === 0 && ( + {pagedBlocked.length === 0 && ( No blocked seats )} - {data.blockedSeats.map((b) => ( + {pagedBlocked.map((b) => ( {b.seatClassName ?? "—"} @@ -365,6 +371,7 @@ export default function SeatStatusReportPage() { + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 4ef9b97ed..b9b2765a7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -9,6 +9,8 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; import { routeCoachTemplatesApi } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; interface Schedule { @@ -368,6 +370,8 @@ export default function SchedulesPage() { ); }); + const { paged: pagedSchedules, page: schedulePage, totalPages: scheduleTotalPages, setPage: setSchedulePage } = usePagination(filteredSchedules as Schedule[], 20); + const statusMap: Record = { SCHEDULED: 'edr-badge-info', BOARDING: 'edr-badge-warning', @@ -621,13 +625,16 @@ export default function SchedulesPage() { No schedules found. {filters.search && 'Try adjusting your search.'} ) : ( - + <> + + + )} diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 063f4cab8..032a978a4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -9,6 +9,8 @@ import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { stationsApi } from '@/lib/api'; +import Pagination from '@/components/ui/Pagination'; +import { usePagination } from '@/lib/use-pagination'; export default function StationsPage() { const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); @@ -90,6 +92,9 @@ export default function StationsPage() { } }; + const stationItems = data?.items || []; + const { paged: pagedStations, page, totalPages, setPage } = usePagination(stationItems, 20); + const handleDelete = (station: any) => { setDeleteConfirm({ isOpen: true, station, error: undefined }); }; @@ -231,12 +236,13 @@ export default function StationsPage() { {/* Stations Table */} + {/* Delete Confirmation */} setTicketPage(1); + const PAGE_SIZE = 50; const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [ticketToDelete, setTicketToDelete] = useState(null); const [deleteError, setDeleteError] = useState(null); @@ -65,7 +69,7 @@ export default function TicketsPage() { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ - queryKey: ['tickets', filters], + queryKey: ['tickets', filters, ticketPage], queryFn: () => ticketsApi.getAll({ search: filters.search || undefined, status: filters.status || undefined, @@ -76,11 +80,14 @@ export default function TicketsPage() { dateFrom: filters.dateFrom || undefined, dateTo: filters.dateTo || undefined, coachId: filters.coachId || undefined, - skip: 0, - take: 50, + skip: (ticketPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, }), }); + const ticketMeta = (data as any)?.meta; + const ticketTotalPages = ticketMeta ? ticketMeta.totalPages : Math.max(1, Math.ceil(((data as any)?.total ?? (data?.items?.length ?? 0)) / PAGE_SIZE)); + const { data: stationsData } = useQuery({ queryKey: ['stations'], queryFn: () => stationsApi.getAll(), @@ -562,7 +569,7 @@ export default function TicketsPage() { placeholder="Search by ticket number..." className="input" value={filters.search} - onChange={(e) => setFilters({ ...filters, search: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, search: e.target.value }); }} />
@@ -570,7 +577,7 @@ export default function TicketsPage() { setFilters({ ...filters, destinationStationId: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, destinationStationId: e.target.value }); }} > {stations.map((station: any) => ( @@ -597,7 +604,7 @@ export default function TicketsPage() { type="date" className="input" value={filters.departureDate} - onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, departureDate: e.target.value }); }} />
@@ -605,7 +612,7 @@ export default function TicketsPage() { setFilters({ ...filters, status: e.target.value })} + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, status: e.target.value }); }} > @@ -638,12 +645,12 @@ export default function TicketsPage() {
setFilters({ ...filters, dateFrom: e.target.value })} /> + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, dateFrom: e.target.value }); }} />
setFilters({ ...filters, dateTo: e.target.value })} /> + onChange={(e) => { resetTicketPage(); setFilters({ ...filters, dateTo: e.target.value }); }} />
)} @@ -657,6 +664,7 @@ export default function TicketsPage() { loading={isLoading} emptyMessage="No tickets found" /> + {/* Board Confirmation Modal */} + {/* Delete Confirmation */} (items: T[], pageSize = 20) { + const [page, setPage] = useState(1); + + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + const safePage = Math.min(page, totalPages); + + const paged = useMemo( + () => items.slice((safePage - 1) * pageSize, safePage * pageSize), + [items, safePage, pageSize], + ); + + // Reset to page 1 whenever the source list changes length (e.g. after a filter) + const reset = () => setPage(1); + + return { paged, page: safePage, totalPages, setPage, reset }; +} From 0d8e122de77af383db0d0b7df2c9e1c7eccf7bf7 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 08:51:33 +0300 Subject: [PATCH 36/71] Ticket generation updates --- .../src/app/booking/confirmation/page.tsx | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index 1039b1465..e9ed308d8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -112,6 +112,15 @@ export default function ConfirmationPage() { // backgrounded tabs, so that can take a very long time. staleTime: 0, enabled: !!bookingId, + // Keep polling after CONFIRMED until tickets are issued — ticket generation runs + // async after the booking transaction commits (see finalizePaymentSuccess in + // payments.service.ts), so the first CONFIRMED fetch often returns an empty + // tickets array. + refetchInterval: (query) => { + const data = query.state.data; + if (!data || data.status !== "CONFIRMED") return false; + return (data.tickets?.length ?? 0) >= passengers.length ? false : FAST_POLL_INTERVAL_MS; + }, }); // Poll the payment intent while the booking is PENDING_PAYMENT — fast during the @@ -153,7 +162,7 @@ export default function ConfirmationPage() { }; const handleDownloadVoucher = async () => { - if (!pnr) { + if (!pnr || !bookingId) { alert("Booking data not available. Please try again."); return; } @@ -164,23 +173,28 @@ export default function ConfirmationPage() { setIsGeneratingVoucher(true); try { - const { generatePassengerVoucherPDF } = - await import("@/lib/generate-voucher"); + const { generatePassengerVoucherPDF } = await import("@/lib/generate-voucher"); + + // Always fetch fresh booking data so tickets are present even if the cached + // _booking raced ahead of ticket generation (tickets are written async after + // the booking is confirmed — see finalizePaymentSuccess in payments.service.ts). + const freshBooking: BookingWithTicket = await apiClient.get(`/bookings/${bookingId}`); + const bookingData = freshBooking ?? _booking; const activeSchedule = isRoundTrip ? outboundSchedule : selectedSchedule; // The server-confirmed settled amount/currency (what was actually charged) is // authoritative — prefer it over the ETB booking fare once available. Shown exactly // as returned by the API (no /100, no per-passenger split) on every passenger's // voucher — see fareIsMajorUnits below. - const settledAmountMinor = _booking?.payment?.amountMinor; - const settledCurrency = _booking?.payment?.currency; + const settledAmountMinor = bookingData?.payment?.amountMinor; + const settledCurrency = bookingData?.payment?.currency; const hasSettledAmount = settledAmountMinor != null && !!settledCurrency; // Derive display currency from nationality (same logic as review/payment pages) const nat = (searchCriteria?.nationality ?? '').toUpperCase(); const passengerDisplayCurrency = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD'; const voucherCurrency = hasSettledAmount ? settledCurrency! : passengerDisplayCurrency; - const createdAt = _booking?.createdAt || new Date().toISOString(); - const status = _booking?.status || "CONFIRMED"; + const createdAt = bookingData?.createdAt || new Date().toISOString(); + const status = bookingData?.status || "CONFIRMED"; // Compute per-passenger fares (in ETB) using the same logic as the review/payment // pages. reviewedPassengerFares is the authoritative source; rebuild from package @@ -206,7 +220,7 @@ export default function ConfirmationPage() { return isPkgChild ? pkgChildFare : pkgAdultFare; } const totalFare = - reviewedTotalMinor ?? paidAmountMinor ?? _booking?.totalMinor ?? 0; + reviewedTotalMinor ?? paidAmountMinor ?? bookingData?.totalMinor ?? 0; return Math.round(totalFare / passengers.length); }; @@ -254,11 +268,9 @@ export default function ConfirmationPage() { // synchronous user-activation window and risk iOS Safari silently blocking them. for (let i = 0; i < passengers.length; i++) { const p = passengers[i]; - // Same match-by-name-then-position as the on-screen ticket list above — no - // fabricated placeholder if there's no backend ticket data (see generate-voucher.ts). const matchedTicket = - _booking?.tickets?.find((t) => t.passengerName === p.name) ?? - _booking?.tickets?.[i] ?? + bookingData?.tickets?.find((t) => t.passengerName === p.name) ?? + bookingData?.tickets?.[i] ?? null; const ticketNumber = matchedTicket?.barcodePayload || "Not yet issued"; From 82cb5508ccd8ed59893873dbe485f55b0c03166d Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 06:43:05 +0000 Subject: [PATCH 37/71] chore: rm the active company profile --- ...opActiveProfileTypeFromExternalProfiles.ts | 45 ++++++ .../src/modules/bookings/bookings.service.ts | 27 +--- .../modules/companies/companies.controller.ts | 16 -- .../modules/companies/companies.service.ts | 152 ++++++------------ .../companies/company-notifier.service.ts | 106 ++++++++---- .../dto/company-info-response.dto.ts | 2 +- .../dto/response-external-profile.dto.ts | 17 +- .../companies/dto/set-active-mode.dto.ts | 7 - .../entities/external-profile.entity.ts | 16 -- .../modules/contracts/contracts.service.ts | 45 +++--- .../contracts/dto/create-contract.dto.ts | 10 ++ .../portal/src/components/AppLayout.tsx | 100 ++++++++++-- .../src/components/NewBookingButton.tsx | 15 +- .../portal/src/constants/URLS.ts | 1 - .../portal/src/hooks/useAuth.ts | 37 ++--- .../edr-freight-web/portal/src/lib/posthog.ts | 3 - .../portal/src/pages/SettingsPage.tsx | 73 ++++++++- .../src/pages/bookings/NewBookingPage.tsx | 45 +++--- .../new-booking-form/step-documents.tsx | 14 +- .../src/pages/contracts/NewContractPage.tsx | 28 +++- .../new-contract-form/step-documents.tsx | 11 +- .../portal/src/services/api.ts | 6 - .../portal/src/services/companies.service.ts | 15 -- 23 files changed, 440 insertions(+), 351 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts delete mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts diff --git a/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts new file mode 100644 index 000000000..d8119930f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drop the `active_profile_type` "active mode" column. A booking/contract now + * resolves its company_profile from the trade direction at creation time (with + * a forwarder passing an explicit companyProfileId), so no per-user active mode + * is stored. `onboarding_step` / `onboarding_completed` are unaffected. + */ +export class DropActiveProfileTypeFromExternalProfiles2450000000000 + implements MigrationInterface +{ + name = 'DropActiveProfileTypeFromExternalProfiles2450000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + // Rebuild the mode the same way the original column was backfilled: + // importer first, then exporter, then whichever profile the company has. + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } +} 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 25c5a461a..7f8718480 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -743,21 +742,13 @@ export class BookingsService { ); companyProfileId = profile.id; } else if (companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } + // No explicit profile pin: resolve from the booking's trade direction + // (import→importer, export→exporter; otherwise the first profile). A + // forwarder booking sends dto.companyProfileId and takes the branch above. companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, - fallbackType, ); // A customer booking under their own account may only do so once the @@ -1065,9 +1056,6 @@ export class BookingsService { await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, - existing.companyProfileId - ? undefined - : (existing.companyProfile?.type as ProfileType | undefined), ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); @@ -1389,15 +1377,6 @@ export class BookingsService { } } - /** - * Resolve the active company_profile id a customer's bookings should be - * scoped to (importer/exporter mode). Null when not onboarded — callers fall - * back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - return this.companiesService.resolveActiveCompanyProfileId(userId); - } - /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong 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 6111bd32a..43d70ce19 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; -import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -353,21 +352,6 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } - @Patch("active-mode") - @ApiOperation({ - summary: "Switch the current user's active operational mode (importer/exporter)", - }) - async setActiveMode( - @CurrentUser() user: CurrentIamUser, - @Body() dto: SetActiveModeDto, - ): Promise { - const { profile, company } = await this.companiesService.setActiveMode( - user.id, - dto.type, - ); - return new CompanyInfoResponseDto(profile, company); - } - @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) 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 fd352c959..3867bef3a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -201,18 +201,6 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); - // Default active mode from the chosen role(s): importer wins when both are - // picked, otherwise the first allowed type chosen. - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - const chosenTypes = (dto.companyProfiles ?? []) - .map((p) => p.type) - .filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; - const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -220,7 +208,6 @@ export class CompaniesService { lastName: identity.lastName, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, - activeProfileType, onboardingStep: "company", }); @@ -293,11 +280,6 @@ export class CompaniesService { const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; const company = await this.companiesRepo.create({ name: identity.firstName @@ -316,7 +298,6 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, isPrimaryContact: true, - activeProfileType, onboardingStep: "company", onboardingCompleted: false, }); @@ -1213,40 +1194,50 @@ export class CompaniesService { if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); - // Suspension and reactivation lock/unlock a role the customer relies on — - // tell them, and carry the staff message so they know why. - const reactivated = - status === ProfileStatus.Active && - existing.status === ProfileStatus.Suspended; - if (status === ProfileStatus.Suspended || reactivated) { + // Every reviewed transition that changes what the customer can do is told + // to them, carrying the staff message so they know why. Approval has no + // message (the note is cleared); the others require one. + const change = + status === ProfileStatus.Suspended + ? "suspended" + : status === ProfileStatus.Rejected + ? "rejected" + : status === ProfileStatus.Active + ? existing.status === ProfileStatus.Suspended + ? "reactivated" + : "approved" + : null; + if (change) { const company = await this.companiesRepo.findById(updated.companyId); if (company) { this.companyNotifier.profileStatusChanged( company, updated.type, - status === ProfileStatus.Suspended ? "suspended" : "reactivated", + change, note ?? "", ); - } - } - - // Approving any profile promotes a pending company to active, so the - // customer can start working as soon as their first profile is cleared. - if (status === ProfileStatus.Active) { - const company = await this.companiesRepo.findById(updated.companyId); - if (company && company.status === CompanyStatus.Pending) { - await this.companiesRepo.update(updated.companyId, { - status: CompanyStatus.Active, - }); + // The first approved role promotes a pending company to active — a + // bigger event (the account itself goes live), so tell them that too. + if ( + status === ProfileStatus.Active && + company.status === CompanyStatus.Pending + ) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + this.companyNotifier.companyApproved(company); + } } } return updated; } /** - * Customer reapplies for a rejected operational role (after fixing whatever the - * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and - * clear the rejection note so it re-enters the approval queue. + * Customer reapplies for a rejected or suspended operational role (after + * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it + * back to Pending and clear the review note so it re-enters the approval + * queue. Suspension is a staff lockout, so resubmitting is an appeal — the + * backoffice still has to approve before the role goes live again. */ async reapplyCompanyProfile( userId: string, @@ -1261,9 +1252,12 @@ export class CompaniesService { if (!target || target.companyId !== companyId) { throw new NotFoundException(`Company profile ${profileId} not found`); } - if (target.status !== ProfileStatus.Rejected) { + if ( + target.status !== ProfileStatus.Rejected && + target.status !== ProfileStatus.Suspended + ) { throw new BadRequestException( - "Only a rejected role can be resubmitted for approval", + "Only a rejected or suspended role can be resubmitted for approval", ); } @@ -1387,10 +1381,9 @@ export class CompaniesService { /** * Create a single operational profile for the current user's company. The new - * role starts Pending, so it deliberately does NOT become the active mode: - * switching onto an unapproved profile would strip the user of `canBook` and - * block them from creating contracts under the role they already had approved. - * Callers switch explicitly via {@link setActiveMode} once the role is Active. + * role starts Pending and carries no reference until a backoffice reviewer + * approves it; a booking/contract resolves its profile from the trade + * direction at creation time, so no "active mode" is stored. */ async createCompanyProfileForUser( userId: string, @@ -1425,40 +1418,6 @@ export class CompaniesService { return created; } - /** - * Switch the user's active operational mode. The target profile must already - * exist — clients create it first via createCompanyProfileForUser. - */ - async setActiveMode( - userId: string, - type: ProfileType, - ): Promise<{ profile: ExternalProfile; company: Company }> { - const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) - throw new NotFoundException(`Profile for user ${userId} not found`); - - const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - if (!allowedTypes.includes(type)) { - throw new BadRequestException( - `Profile type "${type}" is not allowed for company type "${company.type}"`, - ); - } - - const existing = await this.companyProfilesRepo.findByType(companyId, type); - if (!existing) { - throw new ConflictException( - `No ${type} profile exists yet — create it before switching`, - ); - } - - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - - return this.getCompanyInfoByUserId(userId); - } - async setOnboardingStep(userId: string, step: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -2329,15 +2288,14 @@ export class CompaniesService { /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → - * exporter profile; for DOMESTIC or a forwarder/single-profile company (or - * when the natural profile doesn't exist) it falls back to the user's active - * profile, then the company's first profile. Returns null when the company - * has no profiles at all. + * exporter profile; for DOMESTIC (or when the natural profile doesn't exist, + * e.g. a freight forwarder) it falls back to the company's first profile. + * Callers that need a specific role (a forwarder) pass an explicit + * companyProfileId instead. Returns null when the company has no profiles. */ async resolveCompanyProfileIdForBooking( companyId: string, tradeDirection: string, - fallbackType?: ProfileType | null, ): Promise { const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); if (profiles.length === 0) return null; @@ -2349,30 +2307,12 @@ export class CompaniesService { ? ProfileType.exporter : null; - const byType = (type?: ProfileType | null) => - type ? profiles.find((p) => p.type === type) : undefined; - - const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + const match = + (naturalType && profiles.find((p) => p.type === naturalType)) ?? + profiles[0]; return match?.id ?? null; } - /** - * Resolve the company_profile a customer's data should be scoped to, from - * their persisted active mode. Returns null when nothing can be resolved - * (not onboarded yet) so callers can fall back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - try { - const { profile, company } = await this.getCompanyInfoByUserId(userId); - const type = profile.activeProfileType; - if (!type) return null; - const match = company.companyProfiles?.find((p) => p.type === type); - return match?.id ?? null; - } catch { - return null; - } - } - async fetchETradeData(tin: string) { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index d9aac8e45..66f88e9f8 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -59,23 +59,13 @@ export class CompanyNotifierService { } } - /** - * Tell the customer their account was suspended or blacklisted. Called only on - * a real transition into one of those statuses; other status writes are silent. - */ - statusChanged(company: Company, previous: CompanyStatus): void { - const status = company.status; - if (status === previous) return; - if (!PUNITIVE_STATUSES.includes(status)) return; - - const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; - const title = `Account ${label}`; - const body = - `Your company account has been ${label}. ` + - `You will not be able to submit new contracts or bookings. ` + - `Please contact EDR support for assistance.`; - - this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + /** SMS + email + in-app account-status item to the company contact. */ + private notifyAccount( + company: Company, + title: string, + body: string, + link = "/settings", + ): void { void this.notifyContact(company, `${title}. ${body}`); void this.inbox.notify({ recipients: { companyId: company.id }, @@ -83,32 +73,88 @@ export class CompanyNotifierService { type: NotificationType.ACCOUNT_STATUS, title, body, - link: "/settings", - data: { companyId: company.id, status }, + link, + data: { companyId: company.id, status: company.status }, priority: NotificationPriority.HIGH, }); } /** - * Tell the customer one of their operational roles was suspended or - * reactivated, quoting the staff message — the service layer requires one for - * both transitions, so the customer always learns why, not just what. + * Tell the customer their account changed status. Fires on the transitions + * that change what they can do: suspended/blacklisted (locked out) and + * reactivated (back to Active from a lockout). Silent otherwise. + */ + statusChanged(company: Company, previous: CompanyStatus): void { + const status = company.status; + if (status === previous) return; + + if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) { + this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`); + this.notifyAccount( + company, + "Account reactivated", + "Your company account has been reactivated. " + + "You can submit new contracts and bookings again.", + ); + return; + } + + if (!PUNITIVE_STATUSES.includes(status)) return; + + const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; + this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + this.notifyAccount( + company, + `Account ${label}`, + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`, + ); + } + + /** + * Tell the customer their company account was approved and is now live — the + * first operational role clearing review promotes a pending company to Active. + */ + companyApproved(company: Company): void { + this.logger.log(`ACCOUNT_APPROVED — ${company.id}`); + this.notifyAccount( + company, + "Account approved", + "Your company account has been approved and is now active. " + + "You can start submitting bookings and contracts.", + "/dashboard", + ); + } + + /** + * Tell the customer one of their operational roles changed review status — + * approved, rejected, suspended, or reactivated — quoting the staff message + * when one was given (rejection/suspension/reactivation require one; approval + * carries none). */ profileStatusChanged( company: Company, profileType: string, - change: "suspended" | "reactivated", + change: "approved" | "rejected" | "suspended" | "reactivated", staffMessage: string, ): void { const title = `${profileType} role ${change}`; - const consequence = - change === "suspended" - ? `You will not be able to operate under this role until it is reactivated; ` + - `your other roles are unaffected.` - : `You can operate under this role again.`; + const consequence: Record = { + approved: "You can now operate under this role.", + rejected: + "You will not be able to operate under this role. Amend the required " + + "documents and resubmit it for approval from your settings page.", + suspended: + "You will not be able to operate under this role until it is " + + "reactivated; your other roles are unaffected.", + reactivated: "You can operate under this role again.", + }; + const message = staffMessage.trim(); const body = `Your company's ${profileType} role has been ${change}. ` + - `${consequence} Message from EDR staff: ${staffMessage}`; + `${consequence[change]}` + + (message ? ` Message from EDR staff: ${message}` : ""); this.logger.log( `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, @@ -121,7 +167,7 @@ export class CompanyNotifierService { title, body, link: "/settings", - data: { companyId: company.id, profileType, change, staffMessage }, + data: { companyId: company.id, profileType, change, staffMessage: message }, priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 9a4fb330a..2634e0943 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -24,7 +24,7 @@ export class CompanyInfoResponseDto { company: Company, changeRequest?: CompanyChangeRequest | null, ) { - this.profile = new ResponseExternalProfileDto(profile, company); + this.profile = new ResponseExternalProfileDto(profile); this.company = new ResponseCompanyDto(company); const open = diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 256641074..916bb940d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,8 +1,6 @@ -import { Company } from '../entities/company.entity'; import { ExternalProfile, } from '../entities/external-profile.entity'; -import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -13,20 +11,12 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; - /** The active operational mode (importer/exporter/forwarder). */ - activeProfileType?: ProfileType | null; - /** - * The id of the company_profile matching activeProfileType, resolved - * server-side so the client never re-derives it. Null until a company - * (with profiles) is loaded and a matching profile exists. - */ - activeCompanyProfileId?: string | null; onboardingStep?: string | null; onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile, company?: Company) { + constructor(profile: ExternalProfile) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -35,13 +25,8 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; - this.activeProfileType = profile.activeProfileType ?? null; this.onboardingStep = profile.onboardingStep ?? null; this.onboardingCompleted = profile.onboardingCompleted ?? false; - this.activeCompanyProfileId = - company?.companyProfiles?.find( - (p) => p.type === profile.activeProfileType, - )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts deleted file mode 100644 index ac8f57a93..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { ProfileType } from '../entities/company-profile.entity'; - -export class SetActiveModeDto { - @IsEnum(ProfileType) - type!: ProfileType; -} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 93e499b5e..84f644091 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,7 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; -import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; - /** - * The operational profile the user is currently "in" (importer vs exporter, - * or the single forwarder profile). Drives header switching and scopes the - * customer's bookings / dashboard to that company_profile. Nullable for - * users who haven't picked a role yet. - */ - @Column({ - name: 'active_profile_type', - type: 'varchar', - length: 32, - nullable: true, - enum: ProfileType, - }) - activeProfileType?: ProfileType | null; - /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ @Column({ name: 'onboarding_step', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index d5fcf5395..b51403991 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,7 +12,6 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; @@ -188,31 +187,31 @@ export class ContractsService { this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); - // Stamp the operational profile (importer/exporter) for portal scoping. + // Stamp the operational profile for portal scoping. A forwarder contract + // pins its profile explicitly (trade direction can't tell it apart from a + // direct import/export); everything else resolves from the trade direction. let companyProfileId: string | null = null; if (!isGovernment && companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } - companyProfileId = - await this.companiesService.resolveCompanyProfileIdForBooking( - companyId, - dto.tradeDirection, - fallbackType, - ); + if (dto.companyProfileId) { + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else { + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + dto.tradeDirection, + ); - const customerSelfBooking = !dto.companyId && !!userId; - if (customerSelfBooking && companyProfileId) { - await this.companiesService.assertCompanyProfileApprovedForBooking( - companyProfileId, - ); + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 688e0b4c7..fb4f40654 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -124,6 +124,16 @@ export class CreateContractDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile to stamp the contract to (a forwarder contract); ' + + 'commercial contracts otherwise auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @IsIn([...CONTRACT_KINDS]) contractKind!: string; diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 7870a56b6..411c00ce0 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -1,6 +1,8 @@ import { + Alert, AppShell, Avatar, + Badge, Box, Button, Divider, @@ -19,6 +21,7 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { + Ban, ChevronDown, FileSignature, LogOut, @@ -201,29 +204,49 @@ export function AppLayout({ CUSTOMER_SERVICES.includes(p.type as ServiceType), ) : []; + // Suspended services are also hidden by default (the profile exists) — surface + // them so the customer can appeal by resubmitting a fresh business license. + const suspendedServices = isCustomer + ? companyProfiles.filter( + (p) => + p.status === "suspended" && + p.id && + CUSTOMER_SERVICES.includes(p.type as ServiceType), + ) + : []; const canManageServices = - isCustomer && (addableServices.length > 0 || rejectedServices.length > 0); + isCustomer && + (addableServices.length > 0 || + rejectedServices.length > 0 || + suspendedServices.length > 0); const [switching, setSwitching] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [createTarget, setCreateTarget] = useState("importer"); - // Non-null while resubmitting a rejected service; null while creating a new one. + // Non-null while resubmitting a rejected/suspended service; null while creating a new one. const [reapplyId, setReapplyId] = useState(null); + // Status of the profile being resubmitted ("rejected" | "suspended") — drives + // the modal copy; null for a brand-new profile. + const [reapplyStatus, setReapplyStatus] = useState(null); + // Reason the profile was suspended/rejected, surfaced in the modal. + const [reapplyNote, setReapplyNote] = useState(null); const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); const openServiceModal = ( type: ServiceType, - profileId: string | null, + profile?: { id?: string; status?: string; reviewNote?: string | null }, ) => { setCreateTarget(type); - setReapplyId(profileId); + setReapplyId(profile?.id ?? null); + setReapplyStatus(profile?.status ?? null); + setReapplyNote(profile?.reviewNote ?? null); setLicenseFiles([]); setCreateError(null); setCreateOpen(true); }; - const handleAddService = (type: ServiceType) => openServiceModal(type, null); + const handleAddService = (type: ServiceType) => openServiceModal(type); const handleCreateConfirm = async () => { const isReapply = reapplyId !== null; @@ -250,6 +273,7 @@ export function AppLayout({ }; const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m; + const isSuspendedAppeal = reapplyStatus === "suspended"; const isItemActive = (item: SidebarItem) => activePath === item.href.toLowerCase() || @@ -372,7 +396,7 @@ export function AppLayout({ key={p.id} color="red" onClick={() => - openServiceModal(p.type as ServiceType, p.id!) + openServiceModal(p.type as ServiceType, p) } leftSection={} > @@ -381,6 +405,30 @@ export function AppLayout({ ))} )} + {suspendedServices.length > 0 && ( + <> + {(addableServices.length > 0 || + rejectedServices.length > 0) && } + Suspended — appeal + {suspendedServices.map((p) => ( + + openServiceModal(p.type as ServiceType, p) + } + leftSection={} + rightSection={ + + Suspended + + } + > + {serviceLabel(p.type as ServiceType)} + + ))} + + )} )} @@ -849,25 +897,39 @@ export function AppLayout({ opened={createOpen} onClose={() => (switching ? undefined : setCreateOpen(false))} title={ - reapplyId - ? `Resubmit your ${serviceLabel(createTarget)} service` - : `Set up your ${serviceLabel(createTarget)} profile` + isSuspendedAppeal + ? `Appeal suspension — ${serviceLabel(createTarget)}` + : reapplyId + ? `Resubmit your ${serviceLabel(createTarget)} service` + : `Set up your ${serviceLabel(createTarget)} profile` } centered radius="lg" > - {reapplyId + {isSuspendedAppeal ? `Your ${serviceLabel( createTarget, - ).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.` - : `You don't have a ${serviceLabel( - createTarget, - ).toLowerCase()} profile yet. Add your business license to create one and switch to ${serviceLabel( - createTarget, - ).toLowerCase()}.`} + ).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.` + : reapplyId + ? `Your ${serviceLabel( + createTarget, + ).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.` + : `You don't have a ${serviceLabel( + createTarget, + ).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`} + {isSuspendedAppeal && reapplyNote && ( + } + title="Reason for suspension" + > + {reapplyNote} + + )} - {reapplyId ? "Resubmit" : "Create & switch"} + {isSuspendedAppeal + ? "Submit appeal" + : reapplyId + ? "Resubmit" + : "Create"} diff --git a/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx b/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx index cb0e2e726..79109ce2c 100644 --- a/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx +++ b/apps/edr-freight-web/portal/src/components/NewBookingButton.tsx @@ -11,22 +11,21 @@ interface NewBookingButtonProps { /** * New-booking entry point that respects approval status: a customer can only - * create bookings under a profile once the backoffice has approved it. While the - * active profile is pending the button is disabled with an explanation, so the - * gate is communicated rather than silently failing at submit time. + * create bookings once the backoffice has approved at least one operational + * role. While every role is still pending the button is disabled with an + * explanation, so the gate is communicated rather than failing at submit time. */ export function NewBookingButton({ label = "New booking", size, mt, }: NewBookingButtonProps) { - const { canBook, activeProfileStatus } = useAuth(); + const { canBook, hasPendingProfile } = useAuth(); if (!canBook) { - const message = - activeProfileStatus === "pending" - ? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved." - : "Bookings aren't available for this profile yet."; + const message = hasPendingProfile + ? "Your role is awaiting approval. You'll be able to create bookings as soon as it's approved." + : "Bookings aren't available until one of your roles is approved."; return ( diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 090502a3a..451b58390 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -99,7 +99,6 @@ export const URL_CONSTANTS = { PROFILE: "/api/companies/profile", COMPANY_PROFILES: "/api/companies/company-profiles", COMPANY_PROFILE: "/api/companies/company-profile", - ACTIVE_MODE: "/api/companies/active-mode", ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index daeb55600..3573750a9 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -158,11 +158,7 @@ const useAuth = () => { } }; - // Active-mode (importer/exporter) state, sourced from the persisted profile. const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null; - const activeProfileType = companyInfo?.profile?.activeProfileType ?? null; - const activeCompanyProfileId = - companyInfo?.profile?.activeCompanyProfileId ?? null; const companyType = companyInfo?.company?.type ?? null; const companyStatus = companyInfo?.company?.status ?? null; // A company can create bookings only once an admin has approved it (active). @@ -171,14 +167,13 @@ const useAuth = () => { companyInfo?.profile?.onboardingCompleted ?? false; const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; - // Booking is gated on backoffice approval of the active operational profile: - // a customer can only book under a profile once its status is "active". - const activeProfile = - companyInfo?.company?.companyProfiles?.find( - (p) => p.id === activeCompanyProfileId, - ) ?? null; - const activeProfileStatus = activeProfile?.status ?? null; - const canBook = activeProfileStatus === "active"; + // A booking/contract stamps its operational profile from the trade direction + // at creation time, so there's no "active mode": the customer can create work + // as long as they have at least one backoffice-approved operational role. + const companyProfiles = companyInfo?.company?.companyProfiles ?? []; + const hasActiveProfile = companyProfiles.some((p) => p.status === "active"); + const hasPendingProfile = companyProfiles.some((p) => p.status === "pending"); + const canBook = hasActiveProfile; // Profile-edit review: while a change request is pending the customer is // locked out of editing and of creating new contracts/bookings; a rejected @@ -188,7 +183,7 @@ const useAuth = () => { const reviewNote = review?.note ?? null; const isUnderReview = reviewStatus === "pending"; - /** Refetch everything scoped to the active operational profile. */ + /** Refetch company info, dashboard, and bookings after a profile change. */ const invalidateScopedData = async () => { await Promise.all([ queryClient.invalidateQueries({ @@ -201,16 +196,6 @@ const useAuth = () => { ]); }; - const switchMode = async (type: ProfileTypeValue): Promise> => { - try { - await api.companies.setActiveMode.call({ type }); - await invalidateScopedData(); - return { success: true, data: undefined }; - } catch (err) { - return { success: false, error: extractApiError(err) }; - } - }; - /** * Add an operational role. The new role starts pending review, so the active * mode is left untouched — the user keeps working under their approved role. @@ -276,10 +261,9 @@ const useAuth = () => { user: isAuthenticated ? (authQuery.data ?? null) : null, company: isAuthenticated ? (companyQuery.data ?? null) : null, customer: isAuthenticated ? (companyQuery.data ?? null) : null, - activeProfileType, - activeCompanyProfileId, - activeProfileStatus, canBook, + hasActiveProfile, + hasPendingProfile, companyType, companyStatus, isCompanyApproved, @@ -288,7 +272,6 @@ const useAuth = () => { isUnderReview, onboardingCompleted, onboardingStep, - switchMode, createProfile, reapplyProfile, login, diff --git a/apps/edr-freight-web/portal/src/lib/posthog.ts b/apps/edr-freight-web/portal/src/lib/posthog.ts index 59599fbb6..3f1fde723 100644 --- a/apps/edr-freight-web/portal/src/lib/posthog.ts +++ b/apps/edr-freight-web/portal/src/lib/posthog.ts @@ -106,7 +106,6 @@ export function captureApiError(error: unknown): void { /** Company context, as returned by `useAuth().company`. */ interface IdentifyCompany { company?: { id?: string; type?: string | null; status?: string | null } | null; - profile?: { activeProfileType?: string | null } | null; } /** @@ -136,7 +135,6 @@ export function useIdentify( company_id: company?.company?.id, company_type: company?.company?.type, company_status: company?.company?.status, - active_profile_type: company?.profile?.activeProfileType, }); }, [ user?.id, @@ -146,6 +144,5 @@ export function useIdentify( company?.company?.id, company?.company?.type, company?.company?.status, - company?.profile?.activeProfileType, ]); } diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 4018d131b..755691607 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -2,6 +2,7 @@ import { api } from "@/services/api"; import type { ProfileResponse } from "@/types/profile"; import { Alert, + Anchor, Badge, Box, Button, @@ -27,6 +28,7 @@ import { Building2, Clock, FileCheck, + FileText, Globe, Layers, RefreshCw, @@ -37,6 +39,8 @@ import { UserCog, } from "lucide-react"; import { companiesService } from "@/services/companies.service"; +import { fetchViewableFile } from "@/services/files.service"; +import { useFileViewer } from "@edr/ui-common"; import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; @@ -300,8 +304,6 @@ export default function SettingsPage() { )} - - value && setTab(value as SettingsTab)} @@ -354,6 +356,7 @@ export default function SettingsPage() {
+
@@ -404,6 +407,7 @@ const ROLE_STATUS: Record = { */ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) { const queryClient = useQueryClient(); + const { view, viewer } = useFileViewer(); const roles = profile.companyProfiles; const refresh = () => @@ -431,7 +435,8 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) { if (roles.length === 0) return null; return ( - + <> + Operational Services @@ -465,10 +470,62 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) { )} - {r.status === "rejected" && r.reviewNote && ( - - Reviewer note: {r.reviewNote} + {(r.status === "rejected" || r.status === "suspended") && + r.reviewNote && ( + + + {r.status === "suspended" + ? "Suspension reason:" + : "Reviewer note:"} + {" "} + {r.reviewNote} + + )} + + {r.licenseFiles.length === 0 ? ( + + No license document + ) : ( + + {r.licenseFiles.map((f) => ( + + + + void fetchViewableFile(f.id, f.name).then(view) + } + > + {f.name} + + {f.status !== "live" && ( + + {f.status === "pending_remove" + ? "Removal pending" + : "Pending"} + + )} + + ))} + )} @@ -484,7 +541,9 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) { ); })} - + + {viewer} + ); } 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 d3808c8ce..700967b68 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -38,6 +38,7 @@ import { bookingFormSchema, getRouteDirection, initialBookingFormValues, + isForwarderOperation, operationToProfileType, operationToTradeDirection, stepFields, @@ -384,25 +385,23 @@ export default function NewBookingPage() { [profileTypes], ); - // Stamp the booking to the right operational profile. Import/Export (and their - // "as FF" variants) switch the active mode so the matching onboarding documents - // are attached; Intercity uses whatever profile is already active. - const handleOperationSelect = (op: OperationType) => { - if (op === "intercity") return; - const target = operationToProfileType(op, profileTypes); - if (auth.activeProfileType !== target) { - void auth.switchMode(target as never); - } - }; - - // Onboarding documents for the active profile — shown read-only in the - // Documents step and attached to the booking on submit by the backend. - const onboardingDocs = useMemo(() => { + // The company_profile this booking belongs to, derived from the selected + // operation: import→importer, export→exporter, and the "as FF" variants → + // freight_forwarder. A forwarder booking is pinned explicitly on submit + // (companyProfileId) because trade direction alone can't distinguish it. + const selectedProfile = useMemo(() => { const profiles = auth.company?.company?.companyProfiles ?? []; - const active = - profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; - return active?.licenseFiles ?? []; - }, [auth.company, auth.activeCompanyProfileId]); + if (!operationType) return profiles[0] ?? null; + const targetType = operationToProfileType(operationType, profileTypes); + return profiles.find((p) => p.type === targetType) ?? profiles[0] ?? null; + }, [auth.company, operationType, profileTypes]); + + // Onboarding documents for the resolved profile — shown read-only in the + // Documents step and attached to the booking on submit by the backend. + const onboardingDocs = useMemo( + () => selectedProfile?.licenseFiles ?? [], + [selectedProfile], + ); const [pricingData, setPricingData] = useState( null, @@ -483,7 +482,16 @@ export default function NewBookingPage() { (s) => s.id === data.serviceTypeId, )!; + // Pin the profile only for a forwarder booking — trade direction resolves + // importer/exporter on its own, but can't tell a forwarder apart. + const forwarderProfileId = + data.operationType && + isForwarderOperation(data.operationType, profileTypes) + ? selectedProfile?.id + : undefined; + return { + ...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}), bookingType: isContract ? Freight.BookingType.GeneralContract : Freight.BookingType.OneTime, @@ -711,7 +719,6 @@ export default function NewBookingPage() { )} {step === 1 && ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx index 880efb846..48671db4c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step-documents.tsx @@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import { + operationToProfileType, type BookingDocuments, type BookingFormInputValues, type BookingFormValues, @@ -58,11 +59,20 @@ export function StepDocuments({ form }: { form: BookingForm }) { }), ); - // Documents already on file from onboarding (read-only reference). + // Documents already on file from onboarding (read-only reference), for the + // profile this booking's operation resolves to (importer/exporter/forwarder). const onboardingDocs = (() => { const profiles = auth.company?.company?.companyProfiles ?? []; + const operationType = form.watch("operationType"); + const targetType = operationType + ? operationToProfileType( + operationType, + profiles.map((p) => p.type), + ) + : null; const active = - profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; + (targetType && profiles.find((p) => p.type === targetType)) ?? + profiles[0]; return active?.licenseFiles ?? []; })(); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index da03064be..11cb955e0 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -49,6 +49,7 @@ import { } from "./new-contract-form/schema"; import { getRouteDirection, + isForwarderOperation, operationToProfileType, operationToTradeDirection, } from "./new-contract-form/helpers"; @@ -469,10 +470,8 @@ export default function NewContractPage({ form.setValue("operationType", undefined as never, { shouldDirty: true }); return; } - // Case 1 — approved: proceed, switching the active profile if needed. - if (auth.activeProfileType !== target) { - void auth.switchMode(target as never); - } + // Case 1 — approved: proceed. The profile is resolved from the operation at + // submit time (a forwarder operation pins it explicitly), so nothing to set. }; const handleCreateProfileConfirm = () => { @@ -512,10 +511,14 @@ export default function NewContractPage({ const onboardingDocs = useMemo(() => { const profiles = auth.company?.company?.companyProfiles ?? []; + const targetType = operationType + ? operationToProfileType(operationType, profileTypes) + : null; const active = - profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; + (targetType && profiles.find((p) => p.type === targetType)) ?? + profiles[0]; return active?.licenseFiles ?? []; - }, [auth.company, auth.activeCompanyProfileId]); + }, [auth.company, operationType, profileTypes]); async function handleContinue() { const fields = contractStepFields[step]; @@ -569,7 +572,20 @@ export default function NewContractPage({ }, ]; + // Pin the profile only for a forwarder contract (trade direction can't tell + // a forwarder apart from a direct import/export). + const forwarderProfileId = + data.operationType && + isForwarderOperation(data.operationType, profileTypes) + ? (auth.company?.company?.companyProfiles ?? []).find( + (p) => + p.type === + operationToProfileType(data.operationType!, profileTypes), + )?.id + : undefined; + return { + ...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}), contractKind: isGeneral ? Freight.ContractKind.General : Freight.ContractKind.OneTime, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx index 86b6f61a2..f2985007f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx @@ -12,6 +12,7 @@ import { type ContractFormInputValues, type ContractFormValues, } from "./schema"; +import { operationToProfileType } from "./helpers"; import { StepCard, StepHeader } from "./shared"; type ContractForm = UseFormReturn< @@ -73,8 +74,16 @@ export function StepDocuments({ const onboardingDocs = (() => { const profiles = auth.company?.company?.companyProfiles ?? []; + const operationType = form.watch("operationType"); + const targetType = operationType + ? operationToProfileType( + operationType, + profiles.map((p) => p.type), + ) + : null; const active = - profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0]; + (targetType && profiles.find((p) => p.type === targetType)) ?? + profiles[0]; return active?.licenseFiles ?? []; })(); diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ce13fad55..68276a18c 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -240,12 +240,6 @@ export const api = { CompanyInfoResponse >("companies", "startOnboarding", companiesService.startOnboarding), - setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( - "companies", - "setActiveMode", - companiesService.setActiveMode, - ), - setOnboardingStep: endpoint<{ step: string }, void>( "companies", "setOnboardingStep", 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 190a806a8..6b8a8be0f 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -45,10 +45,6 @@ export interface ExternalProfileResponse { nationalId: string | null; jobTitle: string | null; isPrimaryContact: boolean; - /** The active operational mode (importer/exporter/forwarder). */ - activeProfileType: ProfileTypeValue | null; - /** Id of the company_profile matching activeProfileType (server-resolved). */ - activeCompanyProfileId: string | null; onboardingStep: string | null; onboardingCompleted: boolean; createdAt: string; @@ -323,17 +319,6 @@ export const companiesService = { return unwrap(response.data); }, - /** Switch the active operational mode (target profile must already exist). */ - setActiveMode: async (payload: { - type: ProfileTypeValue; - }): Promise => { - const response = await client.patch>( - URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE, - payload, - ); - return unwrap(response.data); - }, - setOnboardingStep: async (payload: { step: string }): Promise => { await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); }, From fd3212f326dfb6f1966fe1a55fb2a9b3eb779e08 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 21 Jul 2026 14:59:00 +0000 Subject: [PATCH 38/71] fix(warehouses): exit-paper truck weight from summed container VGM Per-truck weight now sums the departing truck's container vgm_tons (recorded net for bulk); weighed gross only as fallback. --- .../warehouses/warehouse-inventory.service.ts | 104 +++++++++++++++--- 1 file changed, 90 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index b5df563d9..1a371cd60 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3347,6 +3347,17 @@ export class WarehouseInventoryService { } } + /** + * customer_truck_assignments.gross_weight_kg holds TONNES for gate-out + * recorded exits but real KG for legacy departTruck rows. Exit papers always + * print tonnes — normalise on read. + */ + // ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg); + // migrate the column to tonnes if it ever bites. + private grossAsTons(value: number): number { + return value > 1000 ? Math.round(value) / 1000 : value; + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3406,7 +3417,40 @@ export class WarehouseInventoryService { grossWeightKg: string | number | null; departedAt: string | null; } | null = null; - if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + // The exit-inspection note written at gate-out names the truck doing THIS + // exit — resolve by its plate first. The item's own container may not be on + // the departing truck at all (trucks pick containers freely per trip). + const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)]; + const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null; + if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = a.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at + LIMIT 1`, + [row.bookingId, exitPlate], + ); + truck = truckRow ?? null; + } + if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", @@ -3436,6 +3480,26 @@ export class WarehouseInventoryService { ); truck = truckRow ?? null; } + // Bulk self-haul (no container to match) or an unmatched container: the exit + // paper is still PER TRUCK — use the latest departed customer truck and its + // weighed gross, never the booking's declared total. + if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + NULL AS "containerNumbers", + a.net_weight_tons AS "truckWeightTons" + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL + ORDER BY a.departed_at DESC + LIMIT 1`, + [row.bookingId], + ); + truck = truckRow ?? null; + } const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -3450,7 +3514,9 @@ export class WarehouseInventoryService { customerName: row?.customerName ?? null, freightType: row?.freightType ?? null, tradeDirection: row?.tradeDirection ?? null, - containerNumber: row?.containerNumber ?? null, + // Per-truck exit: list every container leaving on THIS truck, not just + // the inventory item's own container. + containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, quantity: Number(row?.quantity ?? 0), weight: Number(row?.weight ?? 0), @@ -3464,12 +3530,12 @@ export class WarehouseInventoryService { truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, truckGateOut: truck?.departedAt ?? null, - // Prefer the weighed gross captured on departure; fall back to the summed - // container VGM when the truck hasn't been weighed yet. - truckWeightKg: truck - ? Number(truck.grossWeightKg ?? 0) > 0 - ? Number(truck.grossWeightKg) - : Number(truck.truckWeightTons ?? 0) * 1000 + // Per-truck load in tonnes: the summed VGM of the containers on this truck + // (recorded net for bulk); the weighed gross only as fallback. + truckWeightTons: truck + ? Number(truck.truckWeightTons ?? 0) > 0 + ? Number(truck.truckWeightTons) + : this.grossAsTons(Number(truck.grossWeightKg ?? 0)) : null, }); @@ -3642,10 +3708,17 @@ export class WarehouseInventoryService { ); if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); - const containers: Array<{ containerNumber: string; goods: string | null }> = + const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> = await this.dataSource.query( `SELECT c.container_number AS "containerNumber", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + (SELECT SUM(u.vgm_tons) + FROM freight.booking_container_units u + JOIN freight.booking_container bc + ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + WHERE u.container_number = c.container_number + AND bc.booking_id = c.booking_id + AND u.deleted_at IS NULL) AS "vgmTons" FROM freight.customer_truck_containers c JOIN freight.bookings b ON b.id = c.booking_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -3654,6 +3727,9 @@ export class WarehouseInventoryService { [assignmentId], ); + // Truck load in tonnes: summed container VGM; the weighed gross only as + // fallback (bulk trucks carry no containers). + const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0); const html = this.buildTruckExitPaperHtml({ reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, bookingReference: truck.bookingReference, @@ -3661,7 +3737,7 @@ export class WarehouseInventoryService { plateNumber: truck.plateNumber, driverName: truck.driverName, truckType: truck.truckType, - grossWeightKg: Number(truck.grossWeightKg ?? 0), + grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)), gateOut: truck.departedAt, containers, }); @@ -5168,7 +5244,7 @@ export class WarehouseInventoryService { truckDriverName?: string | null; truckType?: string | null; truckGateOut?: string | null; - truckWeightKg?: number | null; + truckWeightTons?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5195,8 +5271,8 @@ export class WarehouseInventoryService { ['Quantity', data.quantity], [ data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', - `${(data.truckPlateNumber && data.truckWeightKg - ? data.truckWeightKg + `${(data.truckPlateNumber && data.truckWeightTons + ? data.truckWeightTons : data.weight ).toLocaleString()} t`, ], From a5ab6fe5c178624b0d4e7b3865e0151ce30a1057 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 07:18:07 +0000 Subject: [PATCH 39/71] chore: filter out safari phone as foreign --- .../src/modules/otp/otp.service.spec.ts | 155 +++++++++--------- .../src/modules/otp/otp.service.ts | 36 ++-- 2 files changed, 97 insertions(+), 94 deletions(-) diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 7b7f97f12..00f8d107a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -1,41 +1,42 @@ -import { OtpService, isDomesticPhone, normalizeOtpTarget } from './otp.service'; +import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service"; -describe('normalizeOtpTarget', () => { - it('canonicalises Ethiopian forms to one E.164 key', () => { - const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099']; +describe("normalizeOtpTarget", () => { + it("canonicalises Ethiopian forms to one E.164 key", () => { + const forms = [ + "+251986680099", + "251986680099", + "0986680099", + "+251 98 668 0099", + ]; const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone); - expect(new Set(keys)).toEqual(new Set(['+251986680099'])); + expect(new Set(keys)).toEqual(new Set(["+251986680099"])); }); - it('maps local 07… mobile to +2517…', () => { - expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678'); - }); - - it('canonicalises email case and surrounding whitespace to one key', () => { - const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM']; + it("canonicalises email case and surrounding whitespace to one key", () => { + const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"]; const keys = forms.map((email) => normalizeOtpTarget({ email }).email); - expect(new Set(keys)).toEqual(new Set(['a@b.com'])); + expect(new Set(keys)).toEqual(new Set(["a@b.com"])); }); - it('keeps an already-normalised email stable (idempotent)', () => { - const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!; + it("keeps an already-normalised email stable (idempotent)", () => { + const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!; expect(normalizeOtpTarget({ email: once }).email).toBe(once); }); - it('keeps an already-normalised number stable (idempotent)', () => { - const once = normalizeOtpTarget({ phone: '0986680099' }).phone!; + it("keeps an already-normalised number stable (idempotent)", () => { + const once = normalizeOtpTarget({ phone: "0986680099" }).phone!; expect(normalizeOtpTarget({ phone: once }).phone).toBe(once); }); }); -describe('isDomesticPhone', () => { - it.each(['+251986680099', '0986680099', '0712345678', '251986680099'])( - 'accepts Ethiopian mobile form %s', +describe("isDomesticPhone", () => { + it.each(["+251986680099", "0986680099", "251986680099"])( + "accepts Ethiopian mobile form %s", (phone) => expect(isDomesticPhone(phone)).toBe(true), ); - it.each(['+14155550123', '+447911123456', '+2519866', '12345'])( - 'rejects non-domestic or malformed %s', + it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( + "rejects non-domestic or malformed %s", (phone) => expect(isDomesticPhone(phone)).toBe(false), ); }); @@ -63,7 +64,8 @@ function makeService( let nextId = 1; const matches = (row: FakeRow, t: { phone?: string; email?: string }) => - (!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone); + (!!t.email && row.email === t.email) || + (!!t.phone && row.phone === t.phone); const repo = { findByTarget: jest.fn( @@ -101,30 +103,30 @@ function makeService( return { service, sms, email, rows: () => rows }; } -describe('OtpService — send/verify agree across phone formats', () => { - it('verifies a code sent to +251… when verify is called with 09…', async () => { +describe("OtpService — send/verify agree across phone formats", () => { + it("verifies a code sent to +251… when verify is called with 09…", async () => { const { service, rows } = makeService(); - await service.sendOtp({ phone: '+251986680099' }); + await service.sendOtp({ phone: "+251986680099" }); await expect( - service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp), + service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => { + it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => { const { service, rows } = makeService(); - await service.sendOtp({ email: ' User@Example.COM ' }); + await service.sendOtp({ email: " User@Example.COM " }); await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), + service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); }); -describe('OtpService — dual-channel send', () => { - const both = { phone: '0986680099', email: 'User@Example.COM' }; +describe("OtpService — dual-channel send", () => { + const both = { phone: "0986680099", email: "User@Example.COM" }; - it('sends ONE code to both transports', async () => { + it("sends ONE code to both transports", async () => { const { service, sms, email, rows } = makeService(); await service.sendOtp(both); @@ -134,92 +136,95 @@ describe('OtpService — dual-channel send', () => { // Same secret on both messages — the user types whichever arrives first. expect(sms.sendSms).toHaveBeenCalledWith( expect.objectContaining({ - to: '+251986680099', + to: "+251986680099", message: expect.stringContaining(otp), }), ); expect(email.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ - to: 'user@example.com', + to: "user@example.com", text: expect.stringContaining(otp), }), ); // One row, both channels canonicalised. expect(rows()).toHaveLength(1); expect(rows()[0]).toMatchObject({ - phone: '+251986680099', - email: 'user@example.com', + phone: "+251986680099", + email: "user@example.com", }); }); it.each([ - ['phone alone', { phone: '0986680099' }], - ['email alone', { email: 'user@example.com' }], - ['both', both], - ])('verifies a dual-channel code when quoted back by %s', async (_label, target) => { - const { service, rows } = makeService(); - await service.sendOtp(both); + ["phone alone", { phone: "0986680099" }], + ["email alone", { email: "user@example.com" }], + ["both", both], + ])( + "verifies a dual-channel code when quoted back by %s", + async (_label, target) => { + const { service, rows } = makeService(); + await service.sendOtp(both); - await expect( - service.verifyOtpForAction(target, rows()[0]!.otp), - ).resolves.toEqual({ success: true }); - }); + await expect( + service.verifyOtpForAction(target, rows()[0]!.otp), + ).resolves.toEqual({ success: true }); + }, + ); - it('consuming the code via one channel kills the other', async () => { + it("consuming the code via one channel kills the other", async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; - await service.verifyOtpForAction({ email: 'user@example.com' }, otp); + await service.verifyOtpForAction({ email: "user@example.com" }, otp); // Single-use is per-code, not per-channel: the phone half must be dead too. await expect( - service.verifyOtpForAction({ phone: '0986680099' }, otp), + service.verifyOtpForAction({ phone: "0986680099" }, otp), ).rejects.toThrow(/No verification code was requested/); }); - it('replaces an overlapping single-channel row instead of colliding with it', async () => { + it("replaces an overlapping single-channel row instead of colliding with it", async () => { const { service, rows } = makeService(); // A pending signup code on the phone only, then a dual-channel send. - await service.sendOtp({ phone: '0986680099' }); + await service.sendOtp({ phone: "0986680099" }); await service.sendOtp(both); expect(rows()).toHaveLength(1); - expect(rows()[0]).toMatchObject({ email: 'user@example.com' }); + expect(rows()[0]).toMatchObject({ email: "user@example.com" }); }); - it('degrades to one channel when the account has only one contact', async () => { + it("degrades to one channel when the account has only one contact", async () => { const { service, sms, email } = makeService(); - await service.sendOtp({ phone: '0986680099' }); + await service.sendOtp({ phone: "0986680099" }); expect(sms.sendSms).toHaveBeenCalledTimes(1); expect(email.sendEmail).not.toHaveBeenCalled(); }); - it('skips SMS for a foreign number when email is available', async () => { + it("skips SMS for a foreign number when email is available", async () => { const { service, sms, email, rows } = makeService(); - await service.sendOtp({ phone: '+14155550123', email: 'user@example.com' }); + await service.sendOtp({ phone: "+14155550123", email: "user@example.com" }); // The gateway is domestic-only — email is the delivery route, but the // foreign phone stays on the row so verify still matches either channel. expect(sms.sendSms).not.toHaveBeenCalled(); expect(email.sendEmail).toHaveBeenCalledTimes(1); await expect( - service.verifyOtpForAction({ phone: '+14155550123' }, rows()[0]!.otp), + service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('still attempts SMS for a foreign number when it is the only channel', async () => { + it("still attempts SMS for a foreign number when it is the only channel", async () => { const { service, sms } = makeService(); - await service.sendOtp({ phone: '+14155550123' }); + await service.sendOtp({ phone: "+14155550123" }); expect(sms.sendSms).toHaveBeenCalledTimes(1); }); - it('still succeeds when one transport throws', async () => { + it("still succeeds when one transport throws", async () => { const { service, rows } = makeService({ sms: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, }); @@ -229,24 +234,24 @@ describe('OtpService — dual-channel send', () => { }); // The code is live and verifiable on the channel that worked. await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), + service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('fails the request when every transport throws', async () => { + it("fails the request when every transport throws", async () => { const { service } = makeService({ sms: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, email: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, }); - await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP'); + await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP"); }); - it('shares one brute-force budget across both channels', async () => { + it("shares one brute-force budget across both channels", async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; @@ -254,17 +259,17 @@ describe('OtpService — dual-channel send', () => { // Alternating channels must not hand the attacker two independent budgets: // 5 wrong guesses in total burn the code regardless of how they are split. for (const target of [ - { phone: '0986680099' }, - { email: 'user@example.com' }, - { phone: '0986680099' }, - { email: 'user@example.com' }, + { phone: "0986680099" }, + { email: "user@example.com" }, + { phone: "0986680099" }, + { email: "user@example.com" }, ]) { - await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow( - 'Invalid verification code', - ); + await expect( + service.verifyOtpForAction(target, "000000"), + ).rejects.toThrow("Invalid verification code"); } await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, '000000'), + service.verifyOtpForAction({ email: "user@example.com" }, "000000"), ).rejects.toThrow(/Too many incorrect attempts/); // Burned: even the correct code no longer works. diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index 283a7f77d..557548b00 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> { */ function normalizePhone(rawPhone: string): string { const raw = rawPhone.trim(); - const digits = raw.replace(/[^\d+]/g, ''); - if (digits.startsWith('+')) return digits; - const bare = digits.replace(/^0+/, ''); + const digits = raw.replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) return digits; + const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if @@ -53,7 +53,7 @@ function normalizePhone(rawPhone: string): string { * pretending an SMS is on its way. */ export function isDomesticPhone(rawPhone: string): boolean { - return /^\+251[79]\d{8}$/.test(normalizePhone(rawPhone)); + return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); } /** @@ -180,10 +180,8 @@ export class OtpService { for (const outcome of outcomes) { this.logger.log( - `otp.dispatch channel=${outcome.channel} target=${label} queued=${ - outcome.queued - } latencyMs=${Date.now() - startedAt}${ - outcome.error ? ` error=${outcome.error}` : "" + `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued + } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" }`, ); } @@ -207,8 +205,7 @@ export class OtpService { // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( - `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ - process.env.RABBITMQ_ENABLED ?? "unset" + `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } @@ -233,8 +230,7 @@ export class OtpService { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( - `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ - Date.now() - startedAt + `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); @@ -294,9 +290,7 @@ export class OtpService { * address while printing the credential next to it would buy nothing. */ private targetLabel(target: OtpTarget): string { - return ( - [target.email, target.phone].filter(Boolean).join("+") || "unknown" - ); + return [target.email, target.phone].filter(Boolean).join("+") || "unknown"; } /** @@ -312,9 +306,8 @@ export class OtpService { ) { const line = `otp.verify channels=${channelsOf(target).join( "+", - )} target=${this.targetLabel(target)} mode=${mode} result=${result}${ - detail ? ` ${detail}` : "" - }`; + )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" + }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); } @@ -463,7 +456,12 @@ export class OtpService { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); - this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`); + this.logVerify( + target, + "action", + "expired", + `ageMs=${ageMs} ttlMs=${ttlMs}`, + ); throw new BadRequestException( "Verification code has expired. Request a new one.", ); From 8d4b1332771322abbe55f74a2931455a148b7917 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 07:20:51 +0000 Subject: [PATCH 40/71] fix: port --- apps/edr-freight-web/backoffice/package.json | 2 +- apps/edr-freight-web/portal/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 04fc14a77..9f7fd27bf 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5283 --clearScreen false", + "dev": "vite --port 5183 --clearScreen false", "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 8fce928f5..1e7b7155d 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --port 5273 --clearScreen false", + "dev": "vite --port 5173 --clearScreen false", "build": "tsc -b && vite build", "preview": "vite preview --port 5173", "lint": "eslint src", From 19906b273e7fe281909a903342012be25436bf2c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:03:49 +0000 Subject: [PATCH 41/71] fix(operations): prefill booking context on single assign; enforce per-truck container loads - single-row Assign vehicle uses the full single-record flow (details + containers) - release() rejects exit containers not assigned to the departing truck - weighing modal offers only the selected truck's assigned containers --- .../warehouses/warehouse-inventory.service.ts | 49 +++++++ apps/edr-freight-web/backoffice/src/App.tsx | 57 ++++++-- .../overview/OverviewQuickLinks.tsx | 20 ++- .../warehouses/ReleaseOrderModal.tsx | 10 +- .../src/pages/dashboard/OverviewPage.tsx | 122 ++++++++++++------ .../src/pages/operations/FirstMilePage.tsx | 6 + .../src/pages/operations/LastMilePage.tsx | 6 + 7 files changed, 213 insertions(+), 57 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 1a371cd60..bd702542b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3011,6 +3011,29 @@ export class WarehouseInventoryService { ); } + // A truck leaves with the containers ASSIGNED to it — never another + // truck's. Enforced whenever the truck has an assigned load on file + // (customer self-haul or EDR last-mile). + if (dto.containerNumber && dto.truckPlateNumber?.trim()) { + const selectedNumbers = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim().toUpperCase()) + .filter(Boolean); + const assigned = await this.truckAssignedContainers( + item.bookingId, + dto.truckPlateNumber.trim(), + ); + if (assigned.length && selectedNumbers.length) { + const foreign = selectedNumbers.filter((n) => !assigned.includes(n)); + if (foreign.length) { + throw new BadRequestException( + `Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` + + `not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`, + ); + } + } + } + // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. // Skipped when the operator chose not to weigh (containers only). @@ -3653,6 +3676,32 @@ export class WarehouseInventoryService { })); } + /** + * Container numbers assigned to a truck (by plate) on this booking, from both + * haulage paths: customer self-haul (customer_truck_containers) and EDR + * last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased. + */ + private async truckAssignedContainers(bookingId: string, plate: string): Promise { + const rows: Array<{ cn: string | null }> = await this.dataSource.query( + `SELECT UPPER(cc.container_number) AS cn + FROM freight.customer_truck_assignments a + JOIN freight.customer_truck_containers cc + ON cc.assignment_id = a.id AND cc.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + UNION + SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`, + [bookingId, plate], + ); + return rows.map((r) => r.cn).filter((n): n is string => Boolean(n)); + } + /** * The booking's containers with their VGM cargo weight (tonnes), keyed by * container number. Drives the truck-leaving exit weighing: the selected diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index aed710e2e..f8e17fe76 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -189,6 +189,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , + permission: FREIGHT_PERMS.bookings.view, }, ...demoItems, ], @@ -383,26 +384,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Import Overview", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Arrival Queue", href: "/dashboard/arrival-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -416,36 +422,43 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Export Overview", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loading Queue", href: "/dashboard/loading-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loaded Inventory", href: "/dashboard/loaded-inventory", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Djibouti Unloading", href: "/dashboard/export-djibouti-unloading", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Interchange Documents", href: "/dashboard/interchange-documents", icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -459,6 +472,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity Cargo", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -490,7 +504,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Allocation & Fees", href: "/dashboard/warehouse-rules", icon: , - permission: FREIGHT_PERMS.warehouseAllocationRules.view, + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], }, { label: "Fee Invoices", @@ -553,7 +570,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , - permission: FREIGHT_PERMS.admin, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ], }, @@ -598,16 +620,27 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; - const itemAllowed = (item: SidebarItem): boolean => { - // GL positions are locked to their single clearance page. - if (etGl) return isEtClearanceItem(item); - if (djGl) return isDjClearanceItem(item); - - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - - return permissionAllowed(item); - }; + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children ? { ...item, children: filterItems(item.children) } : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); // Recursive: a group's own permission gates the whole subtree, leaves are // checked individually, and a group with no surviving children disappears. diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx index d1598403b..4bc98e671 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewQuickLinks.tsx @@ -2,41 +2,59 @@ import { useNavigate } from "react-router-dom"; import { ArrowRight, FileText, Train, Users } from "lucide-react"; import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; + const links = [ { title: "Booking requests", description: "Review and action incoming freight bookings", href: "/dashboard/booking-requests", icon: FileText, + permission: [FREIGHT_PERMS.bookings.view], }, { title: "Train scheduling v2", description: "Full allocation workflow — assign, pin wagons, finalize", href: "/dashboard/operations/train-scheduling-v2", icon: Train, + permission: [FREIGHT_PERMS.trainScheduling.view], }, { title: "Trains", description: "Manage train master data and fleet status", href: "/dashboard/trains", icon: Train, + permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view], }, { title: "User management", description: "Employees, roles, and permissions", href: "/user-management", icon: Users, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ]; export function OverviewQuickLinks() { const navigate = useNavigate(); + const { user } = useAuth(); + + const visible = links.filter((link) => + link.permission.some((key) => hasPermission(user, key)), + ); + if (!visible.length) return null; return ( Quick links - {links.map((link) => { + {visible.map((link) => { const Icon = link.icon; return ( [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), ); + // A truck may only carry out its OWN assigned containers — when the selected + // truck has an assigned load, other trucks' containers are not offered. + const assignedLoad = (selectedOption?.containerNumbers ?? []).map((n) => n.toUpperCase()); // Mantine Selects throw on duplicate option values — legacy bookings can carry // the same container number on two lines, so dedupe defensively. const containerSelectData = [ @@ -390,7 +393,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }, ]), ).values(), - ]; + ].filter( + (option) => + assignedLoad.length === 0 || + assignedLoad.includes(option.value.toUpperCase()) || + containerNumbers.some((n) => n.trim().toUpperCase() === option.value.toUpperCase()), + ); const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedCargoWeight = Number( selectedContainerNumbers diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 0813460f7..c7606641e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -20,12 +20,14 @@ import { } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/auth/useAuth"; import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader"; import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks"; import { OverviewTabContent } from "@/components/overview/OverviewTabContent"; import "@/components/overview/overview.css"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useOverview } from "@/hooks/useOverview"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import type { OverviewRange, OverviewTabKey } from "@/types/overview"; const TAB_ITEMS: Array<{ @@ -40,6 +42,8 @@ const TAB_ITEMS: Array<{ | "customers" | "staff"; metricKey: string; + /** Any of these keys grants the tab. */ + permission: string[]; }> = [ { value: "bookings", @@ -47,6 +51,7 @@ const TAB_ITEMS: Array<{ icon: FileText, kpiKey: "bookings", metricKey: "totalActive", + permission: [FREIGHT_PERMS.bookings.view], }, { value: "contracts", @@ -54,6 +59,7 @@ const TAB_ITEMS: Array<{ icon: FileSignature, kpiKey: "contracts", metricKey: "totalActive", + permission: [FREIGHT_PERMS.contracts.view], }, { value: "billing", @@ -61,6 +67,7 @@ const TAB_ITEMS: Array<{ icon: Banknote, kpiKey: "billing", metricKey: "successfulPaymentsMtd", + permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], }, { value: "operations", @@ -68,6 +75,12 @@ const TAB_ITEMS: Array<{ icon: Train, kpiKey: "operations", metricKey: "trainsActive", + permission: [ + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.lastMile.view, + ], }, { value: "customers", @@ -75,6 +88,7 @@ const TAB_ITEMS: Array<{ icon: Users, kpiKey: "customers", metricKey: "totalCustomers", + permission: [FREIGHT_PERMS.customers.view], }, { value: "staff", @@ -82,6 +96,12 @@ const TAB_ITEMS: Array<{ icon: UserCheck, kpiKey: "staff", metricKey: "activeEmployees", + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ]; @@ -98,7 +118,19 @@ const OverviewPage = () => { const [range, setRange] = useState("30d"); const [activeTab, setActiveTab] = useState("bookings"); const queryClient = useQueryClient(); - const { data: summary, isLoading, isError, refetch, isFetching } = useOverview(range); + const { user } = useAuth(); + const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range); + + // Permission-scoped view: only tabs the user may see; a restricted role + // (e.g. operations) gets a summary 403 — that is not a connection problem. + const visibleTabs = TAB_ITEMS.filter((tab) => + tab.permission.some((key) => hasPermission(user, key)), + ); + const currentTab = visibleTabs.some((t) => t.value === activeTab) + ? activeTab + : visibleTabs[0]?.value; + const accessDenied = + (error as { response?: { status?: number } } | null)?.response?.status === 403; const handleRefresh = () => { void refetch(); @@ -126,7 +158,7 @@ const OverviewPage = () => { /> )} - {isError && ( + {isError && !accessDenied && ( } color="red" @@ -142,48 +174,52 @@ const OverviewPage = () => { )} - setActiveTab((value as OverviewTabKey) ?? "bookings")} - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - {TAB_ITEMS.map((tab) => { - const Icon = tab.icon; - const isActive = activeTab === tab.value; - return ( - } - rightSection={ - summary ? ( - - {getTabBadge(tab)} - - ) : undefined - } - > - {tab.label} - - ); - })} - + {visibleTabs.length > 0 && ( + + setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value) + } + variant="pills" + color="edr-green" + keepMounted={false} + classNames={{ list: "ov-tablist", tab: "ov-tab" }} + > + + {visibleTabs.map((tab) => { + const Icon = tab.icon; + const isActive = currentTab === tab.value; + return ( + } + rightSection={ + summary ? ( + + {getTabBadge(tab)} + + ) : undefined + } + > + {tab.label} + + ); + })} + - {TAB_ITEMS.map((tab) => ( - - - - ))} - + {visibleTabs.map((tab) => ( + + + + ))} + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 69812182f..f30330d96 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -936,6 +936,12 @@ const FirstMilePage = () => { }; const openBulkAssign = () => { + // A single selection has full booking context (details, container list) — + // use the richer single-record flow instead of the blank bulk form. + if (selectedIds.length === 1) { + openAssign(selectedIds[0]); + return; + } setBulkMode(true); setActiveId(null); setVehicleRows([{ vehicleId: null, containerNumber: "" }]); diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 3f96edd2f..a8edab7ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1066,6 +1066,12 @@ const LastMilePage = () => { }; const openBulkAssign = () => { + // A single selection has full booking context (details, container list) — + // use the richer single-record flow instead of the blank bulk form. + if (selectedIds.length === 1) { + openAssign(selectedIds[0]); + return; + } setBulkMode(true); setActiveId(null); setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); From 64b205a71e4b5f8e9f475dda7694d252be4bfcf1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:25:52 +0000 Subject: [PATCH 42/71] feat(seed): operations_chief and dispatcher freight positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operations Chief gets the full freight permission catalog (all CRUD). Dispatcher gets warehouse floor operations — receive/GRN, load/unload, inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, mile truck assignment — with allocation & fee rules view-only. --- .../src/seed/edr-freight.seed.ts | 2 + .../src/seed/freight-permissions.registry.ts | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e6ea89fbe..7ebcfb89c 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 396ac95db..1f82f6e93 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -804,6 +804,48 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, ]), + // Operations Chief: full operational authority — the entire freight + // permission catalog (all CRUD across bookings, contracts, scheduling, + // fleet, warehouse, mile, finance, settings, staff). + operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), + // Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, + // inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, + // plus truck dispatch on the mile legs and read-only operational context. + // Allocation & fee rules are VIEW-ONLY — never create/update/delete. + dispatcher: dedupe([ + FREIGHT_PERMS.warehouseDashboard.view, + FREIGHT_PERMS.warehouses.view, + FREIGHT_PERMS.warehouseYards.view, + FREIGHT_PERMS.warehouseZones.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.warehouseInventory.receive, + FREIGHT_PERMS.warehouseInventory.move, + FREIGHT_PERMS.warehouseInventory.load, + FREIGHT_PERMS.warehouseInventory.unload, + FREIGHT_PERMS.warehouseInventory.dispatch, + FREIGHT_PERMS.warehouseInventory.gatePass, + FREIGHT_PERMS.warehouseInventory.release, + FREIGHT_PERMS.warehouseInventory.deliver, + FREIGHT_PERMS.warehouseInventory.inspect, + FREIGHT_PERMS.warehouseInspectionReports.view, + FREIGHT_PERMS.warehouseInspectionReports.create, + FREIGHT_PERMS.warehouseInspectionReports.update, + FREIGHT_PERMS.interchangeDocuments.view, + FREIGHT_PERMS.interchangeDocuments.generate, + FREIGHT_PERMS.interchangeDocuments.acknowledge, + FREIGHT_PERMS.warehouseFeeInvoices.view, + FREIGHT_PERMS.warehouseFeeInvoices.generate, + // View-only on the rules that govern allocation and fees. + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + // Truck dispatch on the EDR mile legs + operational context. + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.firstMile.assignVehicles, + FREIGHT_PERMS.lastMile.view, + FREIGHT_PERMS.lastMile.assignVehicles, + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.bookings.operations, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ From 1da08131ea7c6eebba74790db6aca7e777784543 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 07:26:25 +0000 Subject: [PATCH 43/71] fix: merge --- .../src/pages/contracts/NewContractPage.tsx | 64 ++++++++----------- 1 file changed, 27 insertions(+), 37 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 1074ccd0d..a3ea59195 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -442,7 +442,9 @@ export default function NewContractPage({ mutationFn: async (profileId: string) => { const res = await auth.reapplyProfile(profileId); if (!res.success) { - throw new Error(res.error?.message ?? "Failed to resubmit for approval"); + throw new Error( + res.error?.message ?? "Failed to resubmit for approval", + ); } }, onSuccess: () => setPendingApprovalProfile(null), @@ -509,17 +511,6 @@ export default function NewContractPage({ ? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget) : ""; - const onboardingDocs = useMemo(() => { - const profiles = auth.company?.company?.companyProfiles ?? []; - const targetType = operationType - ? operationToProfileType(operationType, profileTypes) - : null; - const active = - (targetType && profiles.find((p) => p.type === targetType)) ?? - profiles[0]; - return active?.licenseFiles ?? []; - }, [auth.company, operationType, profileTypes]); - async function handleContinue() { const fields = contractStepFields[step]; if (fields.length > 0) { @@ -552,16 +543,16 @@ export default function NewContractPage({ // GENERAL contract until its validity expires. const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer ? data.enabledContainerSizes.map((size) => ({ - containerSize: size, - // Required cargo description — what the containers carry. - cargoFreeText: data.cargoFreeText.trim() || undefined, - })) + containerSize: size, + // Required cargo description — what the containers carry. + cargoFreeText: data.cargoFreeText.trim() || undefined, + })) : [ - { - cargoTypeId: data.cargoTypePath?.[1] || undefined, - cargoFreeText: data.cargoFreeText || undefined, - }, - ]; + { + cargoTypeId: data.cargoTypePath?.[1] || undefined, + cargoFreeText: data.cargoFreeText || undefined, + }, + ]; // Route — a single origin→destination lane, general contracts included. const routes: Freight.CreateContractRouteInputDto[] = [ @@ -576,12 +567,12 @@ export default function NewContractPage({ // a forwarder apart from a direct import/export). const forwarderProfileId = data.operationType && - isForwarderOperation(data.operationType, profileTypes) + isForwarderOperation(data.operationType, profileTypes) ? (auth.company?.company?.companyProfiles ?? []).find( - (p) => - p.type === - operationToProfileType(data.operationType!, profileTypes), - )?.id + (p) => + p.type === + operationToProfileType(data.operationType!, profileTypes), + )?.id : undefined; return { @@ -600,11 +591,11 @@ export default function NewContractPage({ // booking time, but only on contracts created WITH_RETURN. ...(isContainer ? { - equipmentReturn: - data.equipmentReturn === "with_return" - ? "WITH_RETURN" - : "WITHOUT_RETURN", - } + equipmentReturn: + data.equipmentReturn === "with_return" + ? "WITH_RETURN" + : "WITHOUT_RETURN", + } : {}), isHazardous: data.isHazardous, // Reefer is a contract-level flag for both container and bulk. @@ -633,7 +624,8 @@ export default function NewContractPage({ ? { customsClearingEnabled: true } : { customsClearingEnabled: false, - customsClearingAgent: data.customsClearingAgent?.trim() || undefined, + customsClearingAgent: + data.customsClearingAgent?.trim() || undefined, }), cargoScope, routes, @@ -748,10 +740,7 @@ export default function NewContractPage({ What the reviewer asked for: - + {editContract.latestChangeRequestNote} @@ -1300,7 +1289,8 @@ export default function NewContractPage({ <> Your {label} profile was submitted and is under staff - review. You can start a contract under it once it's approved. + review. You can start a contract under it once it's + approved. diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 9e66d9861..681f12ad5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; import { warehouseService } from '@/services/warehouse.service'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -440,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + // No backdating: gate times are recorded as they happen. The locked + // entrance (exit step) keeps its original past gate-in untouched. + if (!isEntranceLocked && isBackdated(gateInTime)) { + toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' }); + return; + } if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { toast({ variant: 'destructive', @@ -447,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + if (isExitStep && isBackdated(gateOutTime)) { + toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' }); + return; + } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; @@ -646,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} - setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> {hasContainerWeights && ( @@ -679,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`} - setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> + setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> {weightMismatch && ( } color="red" variant="light"> diff --git a/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts new file mode 100644 index 000000000..0ce0541ee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts @@ -0,0 +1,20 @@ +/** + * Backdating guard for operational time entries (gate in/out, mile truck + * times, delivery pickups): times must be recorded as they happen, never + * dated back. A one-hour grace covers real-world lag (weighbridge queue, + * operator finishing the form after the event). + */ +export const BACKDATE_GRACE_MS = 60 * 60 * 1000; + +/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */ +export const nowLocalDateTimeInput = (): string => + new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + .toISOString() + .slice(0, 16); + +/** True when the value is more than the grace period in the past. */ +export const isBackdated = (value: string | Date | null | undefined): boolean => { + if (!value) return false; + const t = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS; +}; From e6e2c88054d87ae588bc5205482ada6371be904d Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 10:44:48 +0300 Subject: [PATCH 48/71] Ticket number on voucher updates --- .../src/modules/payments/payments.service.ts | 8 ++++++++ .../portal/src/app/booking/detail/page.tsx | 14 +++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index e9e4d2636..7ee67be76 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -900,6 +900,9 @@ export class PaymentsService { this.logger.error( `Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`, ); + // Re-throw so callers (e.g. force-confirm) know tickets weren't issued. + // Webhook handlers catch this themselves and still return 200 to avoid redelivery. + throw err; } try { @@ -1012,6 +1015,11 @@ export class PaymentsService { intentId: intent.id, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, + }).catch((err) => { + this.logger.error( + `finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`, + ); + return { alreadyFinalized: false }; }); return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index c6e6a4817..c6f823053 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -249,8 +249,20 @@ function BookingDetailContent() { setIsGeneratingVoucher(true); try { + // If tickets are missing (generate failed silently at payment time), issue them now. + if (!booking.tickets?.length && booking.id) { + try { + await apiClient.post(`/tickets/generate/${booking.id}`, {}); + } catch { + // ignore — generate() will throw if payment not succeeded; voucher will show + // "Not yet issued" in that case, which is correct + } + } + // Always refetch so the voucher has the latest ticket barcodes. + const fresh = await apiClient.get(`/bookings/${booking.bookingRef}`); + const bookingData = (fresh as any)?.data || fresh; const { generateVoucherPDF } = await import("@/lib/generate-voucher"); - await generateVoucherPDF(booking as any); + await generateVoucherPDF(bookingData as any); } catch (error) { alert( `Failed to generate voucher: ${error instanceof Error ? error.message : "Unknown error"}`, From ba6fd0f41b9659ae770c8db618d35664341e3606 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:46:54 +0000 Subject: [PATCH 49/71] feat: bulk upload for customer truck assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Excel template-based bulk upload for customer truck assignments (self-haul + EDR). Supports up to 2x20ft or 1x40ft containers per truck. API: POST /bookings/:id/customer-trucks/bulk accepts array of trucks. Portal: DownloadTemplate → ParseExcel → PreviewUpload → Commit flow. Validates truck load rules per booking container configuration. Co-Authored-By: Claude Haiku 4.5 --- .../modules/bookings/bookings.controller.ts | 14 ++ .../bookings/customer-truck.service.ts | 31 +++ .../bookings/dto/bulk-customer-truck.dto.ts | 48 +++++ .../components/BulkTruckUploadModal.tsx | 183 ++++++++++++++++++ .../CustomerTruckAssignmentCard.tsx | 22 ++- .../src/utils/truck-assignment-template.ts | 108 +++++++++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx create mode 100644 apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts 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 bc8c80982..ac5b3c3cd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -480,6 +480,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -576,4 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx new file mode 100644 index 000000000..23c661247 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core"; +import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { client } from "@/utils/api"; +import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template"; + +interface BulkTruckUploadModalProps { + opened: boolean; + onClose: () => void; + bookingId: string; + onSuccess?: () => void; +} + +export function BulkTruckUploadModal({ + opened, + onClose, + bookingId, + onSuccess, +}: BulkTruckUploadModalProps) { + const [file, setFile] = useState(null); + const [parsed, setParsed] = useState< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> + >([]); + const [parseError, setParseError] = useState(null); + + const uploadMutation = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, { + trucks: parsed, + }); + return data; + }, + onSuccess: () => { + onSuccess?.(); + setFile(null); + setParsed([]); + onClose(); + }, + }); + + const handleFileSelect = async (selectedFile: File | null) => { + if (!selectedFile) { + setFile(null); + setParsed([]); + setParseError(null); + return; + } + + try { + setParseError(null); + const trucks = await parseTruckAssignmentFile(selectedFile); + setFile(selectedFile); + setParsed(trucks); + } catch (err: any) { + setParseError(err.message || "Failed to parse Excel file"); + setFile(null); + setParsed([]); + } + }; + + const handleDownloadTemplate = () => { + generateTruckAssignmentTemplate("truck-assignments.xlsx"); + }; + + return ( + + + } color="blue"> + Download template, fill with truck data, upload Excel file to bulk-create truck assignments. + + + + + + + } + /> + + {parseError && ( + } color="red" title="Parse Error"> + {parseError} + + )} + + {parsed.length > 0 && ( + <> +
+ + Preview ({parsed.length} trucks) + + + + + Plate Number + Driver Name + Truck Type + Containers + + + + {parsed.map((truck, idx) => ( + + + {truck.truckPlateNumber} + + + {truck.driverName} + + + {truck.truckType} + + + {truck.containerNumbers?.length ? ( + + {truck.containerNumbers.map((c) => ( + + {c} + + ))} + + ) : ( + + — + + )} + + + ))} + +
+
+ + + + Ready to upload {parsed.length} truck(s) + + + + + )} + + {uploadMutation.isError && ( + } color="red"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed"} + + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 7c9cbeb0e..5d2bac886 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -23,6 +23,7 @@ import { api } from "@/services/api"; import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; +import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; @@ -65,6 +66,7 @@ export function CustomerTruckAssignmentCard({ const [containers, setContainers] = useState([]); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); + const [bulkModalOpen, setBulkModalOpen] = useState(false); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( @@ -163,6 +165,14 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment + {pendingAssignmentCount > 0 && ( {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment @@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({ )} + + setBulkModalOpen(false)} + bookingId={booking.id} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + onAssigned(); + }} + /> ); } diff --git a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts new file mode 100644 index 000000000..7a0fef1cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts @@ -0,0 +1,108 @@ +import * as XLSX from 'xlsx'; + +export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void { + const data = [ + { + 'Truck Plate Number': '3-12345/67890', + 'Driver Name': 'John Doe', + 'Truck Type': 'Flatbed', + 'Container 1': 'MAEU1234567', + 'Container 2': 'HLXU7654321', + }, + { + 'Truck Plate Number': '3-98765/43210', + 'Driver Name': 'Jane Smith', + 'Truck Type': 'Flatbed', + 'Container 1': 'COSCO1111111', + 'Container 2': '', + }, + ]; + + const instructions = [ + ['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'], + [], + ['Column', 'Required', 'Notes'], + ['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'], + ['Driver Name', 'Yes', 'Full name of truck driver'], + ['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'], + ['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'], + ['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'], + [], + ['CONTAINER RULES'], + ['- A 40ft container fills one truck (max 1 per truck)'], + ['- Two 20ft containers fit on one truck (max 2 per truck)'], + ['- No size mixing on same truck'], + ['- Containers must be from the booking'], + [], + ['Example Data Below →'], + ]; + + const wb = XLSX.utils.book_new(); + + // Instructions sheet + const wsInstructions = XLSX.utils.aoa_to_sheet(instructions); + wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }]; + XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions'); + + // Data template sheet + const wsData = XLSX.utils.json_to_sheet(data, { + header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'], + }); + wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }]; + XLSX.utils.book_append_sheet(wb, wsData, 'Trucks'); + + XLSX.writeFile(wb, filename); +} + +export function parseTruckAssignmentFile( + file: File, +): Promise< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> +> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result as ArrayBuffer; + const wb = XLSX.read(data, { type: 'array' }); + const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0]; + + if (!wsData) { + reject(new Error('No data sheet found in Excel file')); + return; + } + + const jsonData = XLSX.utils.sheet_to_json(wsData) as Array>; + + const trucks = jsonData.map((row) => { + const containers = [ + row['Container 1'], + row['Container 2'], + ] + .filter((c) => c && c.trim()) + .map((c) => c.trim().toUpperCase()); + + return { + truckPlateNumber: row['Truck Plate Number']?.trim() || '', + driverName: row['Driver Name']?.trim() || '', + truckType: row['Truck Type']?.trim() || '', + containerNumbers: containers.length > 0 ? containers : undefined, + }; + }); + + resolve(trucks); + } catch (error) { + reject(error); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); +} From 905bf75bce65d69d351f6382dd9be2c3e5e00cba Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 07:47:25 +0000 Subject: [PATCH 50/71] fix: test --- .../train-scheduling.service.spec.ts | 95 +++++++++++++------ 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index b6410d0cf..6757a6e21 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -80,7 +80,12 @@ const makeBooking = ( describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + query: jest.Mock; + manager: { getRepository: jest.Mock }; + }; let bookingsRepository: Record; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock }; @@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => { let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { + // findGroupSiblings runs a query builder off dataSource.manager; default it + // to "no sibling schedules" so isolated unit tests don't need to wire it. + const emptySiblingQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; dataSource = { getRepository: jest.fn(), transaction: jest.fn(), // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". query: jest.fn().mockResolvedValue([]), + manager: { + getRepository: jest.fn(() => ({ + createQueryBuilder: jest.fn(() => emptySiblingQb), + })), + }, }; bookingsRepository = { findEligibleForScheduling: jest.fn(), @@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => { findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), updateStatus: jest.fn(), + maxReferenceSequence: jest.fn().mockResolvedValue(0), }; trainScheduleBookingsRepository = { findByBookingIds: jest.fn(), + findByScheduleId: jest.fn().mockResolvedValue([]), createMany: jest.fn(), deleteByScheduleAndBooking: jest.fn(), }; @@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => { }); it('allows preview when bookings are already on the target schedule', async () => { - const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; + // A booking already pinned to the target schedule is exempt from the + // corridor/day/status gates — mark it so on the entity, matching the link row. + const bookings = [ + { ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' }, + ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); @@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); }); - it('allows preview when selected bookings are on different schedule dates', async () => { + it('flags a booking scheduled for a different day than the train departure', async () => { + // The old cross-booking "must share the same schedule date" rule is gone; + // the live rule is that every booking must match the departure day. b2 + // departs a day later, so it's the one flagged. const bookings = [ makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), @@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => { expect(result.violations).not.toContain( 'Selected bookings must share the same schedule date', ); - expect(result.valid).toBe(true); + expect(result.violations.some((v) => v.includes('different day'))).toBe(true); + expect(result.valid).toBe(false); }); it('rejects bookings that are not in schedulable status', async () => { @@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => { originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, + status: 'AVAILABLE', + direction: 'IMPORT', }; const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' }; @@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => { const trainScheduleRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + // findGroupWindowAnchor looks for same-day sibling schedules; none here. + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), @@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => { callback(manager), ); + // Departure must clear the import lead window (≥ importWindowLeadDays ahead + // of now), so use a comfortably-future date rather than a hardcoded one. + const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); const result = await service.createContainerTrainSchedule({ routeId: 'route-1', - scheduleDate: '2026-06-20T08:00:00.000Z', + scheduleDate: futureDeparture, locomotiveIds: ['loc-1', 'loc-2'], }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainSetLocomotiveRepo.save).toHaveBeenCalled(); - expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith( - { id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) }, - { status: 'ASSIGNED' }, - ); + // Advance scheduling locks locomotives but does NOT flip them to ASSIGNED — + // one locomotive may sit on several future schedules. + expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled(); expect(result.id).toBe('schedule-1'); }); @@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => { destinationYardId: 'yard-destination', status: 'PAID', bookingContainers: [], - cargoType: { code: 'COFFEE' }, + cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] }, }; wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { @@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => { }); expect(result.valid).toBe(true); - expect(result.summary.wagonType).toBe('MIXED'); + // Mixed freight now labels the summary by the concrete wagon type codes it uses. + expect(result.summary.wagonType).toContain('NW5'); + expect(result.summary.wagonType).toContain('CW3'); expect(result.wagonPlan.length).toBeGreaterThan(2); expect(result.containerUnits).toHaveLength(2); }); @@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => { }); it('rejects create when the locked locomotive is no longer available', async () => { + // Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other + // non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning. const manager = { getRepository: jest.fn(() => ({ - findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }), })), }; @@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => { originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, + status: 'AVAILABLE', + direction: 'IMPORT', }), }; } @@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => { }); describe('getAvailableLocomotivesForRoute', () => { - it('returns locomotives at the route origin yard', async () => { + it('returns every in-service locomotive, annotated with origin-yard presence', async () => { const routeId = 'route-export'; const originYardId = 'yard-addis'; const routeRepo = { @@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => { id: routeId, name: 'Addis → Djibouti', isActive: true, + status: 'AVAILABLE', originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Djibouti' }, @@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => { if ((entity as { name?: string })?.name === 'Route') return routeRepo; return { findOne: jest.fn(), update: jest.fn() }; }); + // Advance-scheduling picker: nothing is filtered by yard — every in-service + // locomotive is returned and annotated with whether it's at the origin yet. locomotivesRepository.findAll.mockResolvedValue([ { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, + { id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' }, ]); const result = await service.getAvailableLocomotivesForRoute(routeId); - expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ - where: { status: 'AVAILABLE', currentYardId: originYardId }, - order: { code: 'ASC' }, - }); - expect(result).toHaveLength(1); - expect(result[0].code).toBe('EXP'); + expect(result).toHaveLength(2); + expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true); + expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false); }); - it('returns all locomotives returned by the repository for domestic routes', async () => { + it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => { const routeId = 'route-domestic'; const originYardId = 'yard-addis'; const routeRepo = { @@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => { id: routeId, name: 'Addis → Dire Dawa', isActive: true, + status: 'AVAILABLE', originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' }, @@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => { if ((entity as { name?: string })?.name === 'Route') return routeRepo; return { findOne: jest.fn(), update: jest.fn() }; }); - locomotivesRepository.findAll.mockResolvedValue([ - { id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId }, - { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, - ]); - const result = await service.getAvailableLocomotivesForRoute(routeId); - - expect(result).toHaveLength(2); + await expect( + service.getAvailableLocomotivesForRoute(routeId), + ).rejects.toBeInstanceOf(BadRequestException); }); }); From 16dc143e66ea46530b2aa4efd6257734509fbf73 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 08:00:38 +0000 Subject: [PATCH 51/71] fix: the invoice and receipt documents in the booking --- .../components/BookingPaymentPanel.tsx | 99 +++++++++++-------- .../components/WarehousePaymentsSection.tsx | 74 ++++++++++---- 2 files changed, 112 insertions(+), 61 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index 81f727b29..1cb46d156 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -211,10 +211,6 @@ export function BookingPaymentPanel({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), }); - // The invoice worth a prominent "Download" — the first issued one, else any. - const primary = - invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0]; - const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false; const downloadInvoice = async (inv: PortalInvoice) => { try { @@ -380,50 +376,67 @@ export function BookingPaymentPanel({ - downloadInvoice(inv)} - > - - + {invoices.length > 1 && ( + <> + downloadInvoice(inv)} + > + + + {Number(inv.paidAmount) > 0 && ( + downloadReceipt(inv)} + > + + + )} + + )} ))} - - )} - {primary && ( - - )} - {primary && primaryPaid && ( - + {/* Single invoice: a prominent download instead of a lone row icon. */} + {invoices.length === 1 && ( + <> + + {Number(invoices[0].paidAmount) > 0 && ( + + )} + + )} + )} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index acd4dbb25..927f1dbfb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,6 +1,6 @@ import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard, Download, Receipt } from "lucide-react"; +import { CreditCard, Download, FileText, Receipt } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -184,23 +184,27 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { Pay )} - download(inv)} - > - - - {Number(inv.paidAmount) > 0 && ( - downloadReceipt(inv)} - > - - + {invoices.length > 1 && ( + <> + download(inv)} + > + + + {Number(inv.paidAmount) > 0 && ( + downloadReceipt(inv)} + > + + + )} + )} @@ -208,6 +212,40 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { })} + {/* Single invoice: a prominent download instead of a lone row icon. */} + {invoices.length === 1 && ( + <> + + {Number(invoices[0].paidAmount) > 0 && ( + + )} + + )} + Date: Wed, 22 Jul 2026 07:41:48 +0000 Subject: [PATCH 52/71] Customer Name + Customer ID columns removed from the Export Marshalling Document --- .../train-scheduling.service.ts | 9 ++------- .../components/cargoes/DeliverCargoDialog.tsx | 6 ++++++ .../operations/TruckDetentionModal.tsx | 19 +++++++++++++++++- .../warehouses/ReleaseOrderModal.tsx | 15 ++++++++++++-- .../backoffice/src/lib/no-backdate.ts | 20 +++++++++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/lib/no-backdate.ts 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 13624691a..4a26236b0 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 @@ -2811,13 +2811,12 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); - const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; @@ -2826,8 +2825,6 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} - ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -2909,8 +2906,6 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity - Customer Name - Customer ID Cargo Type Container No Chassis No @@ -2918,7 +2913,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx index 546bd0def..6591ffa7c 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx @@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea'; import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; /** * Customer Pickup + Proof of Delivery capture for a LOADED cargo. @@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on toast({ title: 'Receiver name is required', variant: 'destructive' }); return; } + if (isBackdated(pickupDate)) { + toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' }); + return; + } try { await deliver.mutateAsync({ id: cargoId, @@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on setPickupDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index 2088db32d..c9748d80a 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -18,6 +18,7 @@ import { useEffect, useState } from 'react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated } from '@/lib/no-backdate'; import { lastMileService, type LastMileRecord } from '@/services/last-mile.service'; interface TruckDetentionModalProps { @@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM description="Detention clock start" value={arrived} onChange={(v) => setArrived(v ? new Date(v) : null)} + minDate={new Date()} clearable /> setDelivered(v ? new Date(v) : null)} + minDate={new Date()} clearable /> - diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 9e66d9861..681f12ad5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; import { warehouseService } from '@/services/warehouse.service'; import type { WarehouseInventoryItem } from '@/types/warehouse'; import { extractErrorMessage } from './options'; @@ -440,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + // No backdating: gate times are recorded as they happen. The locked + // entrance (exit step) keeps its original past gate-in untouched. + if (!isEntranceLocked && isBackdated(gateInTime)) { + toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' }); + return; + } if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { toast({ variant: 'destructive', @@ -447,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }); return; } + if (isExitStep && isBackdated(gateOutTime)) { + toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' }); + return; + } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; @@ -646,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} - setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> {hasContainerWeights && ( @@ -679,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea Computed net: {computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`} - setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> + setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} /> {weightMismatch && ( } color="red" variant="light"> diff --git a/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts new file mode 100644 index 000000000..0ce0541ee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/no-backdate.ts @@ -0,0 +1,20 @@ +/** + * Backdating guard for operational time entries (gate in/out, mile truck + * times, delivery pickups): times must be recorded as they happen, never + * dated back. A one-hour grace covers real-world lag (weighbridge queue, + * operator finishing the form after the event). + */ +export const BACKDATE_GRACE_MS = 60 * 60 * 1000; + +/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */ +export const nowLocalDateTimeInput = (): string => + new Date(Date.now() - new Date().getTimezoneOffset() * 60_000) + .toISOString() + .slice(0, 16); + +/** True when the value is more than the grace period in the past. */ +export const isBackdated = (value: string | Date | null | undefined): boolean => { + if (!value) return false; + const t = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS; +}; From 7763d67ea86d94cb21f9fab4705c2ffe695c5202 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 07:46:54 +0000 Subject: [PATCH 53/71] feat: bulk upload for customer truck assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Excel template-based bulk upload for customer truck assignments (self-haul + EDR). Supports up to 2x20ft or 1x40ft containers per truck. API: POST /bookings/:id/customer-trucks/bulk accepts array of trucks. Portal: DownloadTemplate → ParseExcel → PreviewUpload → Commit flow. Validates truck load rules per booking container configuration. Co-Authored-By: Claude Haiku 4.5 --- .../modules/bookings/bookings.controller.ts | 14 ++ .../bookings/customer-truck.service.ts | 31 +++ .../bookings/dto/bulk-customer-truck.dto.ts | 48 +++++ .../components/BulkTruckUploadModal.tsx | 183 ++++++++++++++++++ .../CustomerTruckAssignmentCard.tsx | 22 ++- .../src/utils/truck-assignment-template.ts | 108 +++++++++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx create mode 100644 apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts 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 bc8c80982..ac5b3c3cd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -480,6 +480,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -576,4 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx new file mode 100644 index 000000000..23c661247 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BulkTruckUploadModal.tsx @@ -0,0 +1,183 @@ +import { useState } from "react"; +import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core"; +import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; + +import { client } from "@/utils/api"; +import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template"; + +interface BulkTruckUploadModalProps { + opened: boolean; + onClose: () => void; + bookingId: string; + onSuccess?: () => void; +} + +export function BulkTruckUploadModal({ + opened, + onClose, + bookingId, + onSuccess, +}: BulkTruckUploadModalProps) { + const [file, setFile] = useState(null); + const [parsed, setParsed] = useState< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> + >([]); + const [parseError, setParseError] = useState(null); + + const uploadMutation = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, { + trucks: parsed, + }); + return data; + }, + onSuccess: () => { + onSuccess?.(); + setFile(null); + setParsed([]); + onClose(); + }, + }); + + const handleFileSelect = async (selectedFile: File | null) => { + if (!selectedFile) { + setFile(null); + setParsed([]); + setParseError(null); + return; + } + + try { + setParseError(null); + const trucks = await parseTruckAssignmentFile(selectedFile); + setFile(selectedFile); + setParsed(trucks); + } catch (err: any) { + setParseError(err.message || "Failed to parse Excel file"); + setFile(null); + setParsed([]); + } + }; + + const handleDownloadTemplate = () => { + generateTruckAssignmentTemplate("truck-assignments.xlsx"); + }; + + return ( + + + } color="blue"> + Download template, fill with truck data, upload Excel file to bulk-create truck assignments. + + + + + + + } + /> + + {parseError && ( + } color="red" title="Parse Error"> + {parseError} + + )} + + {parsed.length > 0 && ( + <> +
+ + Preview ({parsed.length} trucks) + + + + + Plate Number + Driver Name + Truck Type + Containers + + + + {parsed.map((truck, idx) => ( + + + {truck.truckPlateNumber} + + + {truck.driverName} + + + {truck.truckType} + + + {truck.containerNumbers?.length ? ( + + {truck.containerNumbers.map((c) => ( + + {c} + + ))} + + ) : ( + + — + + )} + + + ))} + +
+
+ + + + Ready to upload {parsed.length} truck(s) + + + + + )} + + {uploadMutation.isError && ( + } color="red"> + {uploadMutation.error instanceof Error + ? uploadMutation.error.message + : "Upload failed"} + + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 7c9cbeb0e..5d2bac886 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Freight } from "@edr/types"; -import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react"; +import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; @@ -23,6 +23,7 @@ import { api } from "@/services/api"; import { customerTrucksService } from "@/services/customer-trucks.service"; import { CardTitle, SectionCard } from "./layout"; +import { BulkTruckUploadModal } from "./BulkTruckUploadModal"; const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"]; @@ -65,6 +66,7 @@ export function CustomerTruckAssignmentCard({ const [containers, setContainers] = useState([]); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(null); + const [bulkModalOpen, setBulkModalOpen] = useState(false); // Container numbers on the booking that aren't already loaded onto a truck. const assignedNumbers = new Set( @@ -163,6 +165,14 @@ export function CustomerTruckAssignmentCard({ External Truck Assignment + {pendingAssignmentCount > 0 && ( {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment @@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({ )} + + setBulkModalOpen(false)} + bookingId={booking.id} + onSuccess={() => { + queryClient.invalidateQueries({ queryKey: trucksKey }); + onAssigned(); + }} + /> ); } diff --git a/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts new file mode 100644 index 000000000..7a0fef1cc --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/truck-assignment-template.ts @@ -0,0 +1,108 @@ +import * as XLSX from 'xlsx'; + +export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void { + const data = [ + { + 'Truck Plate Number': '3-12345/67890', + 'Driver Name': 'John Doe', + 'Truck Type': 'Flatbed', + 'Container 1': 'MAEU1234567', + 'Container 2': 'HLXU7654321', + }, + { + 'Truck Plate Number': '3-98765/43210', + 'Driver Name': 'Jane Smith', + 'Truck Type': 'Flatbed', + 'Container 1': 'COSCO1111111', + 'Container 2': '', + }, + ]; + + const instructions = [ + ['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'], + [], + ['Column', 'Required', 'Notes'], + ['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'], + ['Driver Name', 'Yes', 'Full name of truck driver'], + ['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'], + ['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'], + ['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'], + [], + ['CONTAINER RULES'], + ['- A 40ft container fills one truck (max 1 per truck)'], + ['- Two 20ft containers fit on one truck (max 2 per truck)'], + ['- No size mixing on same truck'], + ['- Containers must be from the booking'], + [], + ['Example Data Below →'], + ]; + + const wb = XLSX.utils.book_new(); + + // Instructions sheet + const wsInstructions = XLSX.utils.aoa_to_sheet(instructions); + wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }]; + XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions'); + + // Data template sheet + const wsData = XLSX.utils.json_to_sheet(data, { + header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'], + }); + wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }]; + XLSX.utils.book_append_sheet(wb, wsData, 'Trucks'); + + XLSX.writeFile(wb, filename); +} + +export function parseTruckAssignmentFile( + file: File, +): Promise< + Array<{ + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers?: string[]; + }> +> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = e.target?.result as ArrayBuffer; + const wb = XLSX.read(data, { type: 'array' }); + const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0]; + + if (!wsData) { + reject(new Error('No data sheet found in Excel file')); + return; + } + + const jsonData = XLSX.utils.sheet_to_json(wsData) as Array>; + + const trucks = jsonData.map((row) => { + const containers = [ + row['Container 1'], + row['Container 2'], + ] + .filter((c) => c && c.trim()) + .map((c) => c.trim().toUpperCase()); + + return { + truckPlateNumber: row['Truck Plate Number']?.trim() || '', + driverName: row['Driver Name']?.trim() || '', + truckType: row['Truck Type']?.trim() || '', + containerNumbers: containers.length > 0 ? containers : undefined, + }; + }); + + resolve(trucks); + } catch (error) { + reject(error); + } + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); +} From 24c2ef5d6570a597fbf055ebfe75dae261ce7bff Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 22 Jul 2026 08:01:54 +0000 Subject: [PATCH 54/71] fix: payement table --- .../2460000000000-AddCacBankPaymentMethod.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts diff --git a/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts new file mode 100644 index 000000000..e6cfe70c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface { + name = "AddCacBankPaymentMethod2460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // The entity + frontend already list 'cac-bank' as a valid method, but the + // DB enum was never extended. Filtering payments by 'cac-bank' cast the + // literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301. + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added value and update the column. + } +} From de1736580220e23402fd372c464da694d0e69d8a Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 11:06:16 +0300 Subject: [PATCH 55/71] Separate ticket generation for portal and back office --- .../src/modules/tickets/tickets.controller.ts | 49 +++++++++++-------- .../backoffice/src/lib/api/index.ts | 2 + 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index d82b24f71..9aa21be44 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; -import { JwtGuard } from '../../common/jwt.guard'; import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @@ -24,13 +23,23 @@ export class TicketsController { } @Post('generate/:bookingId') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'Generate ticket for booking (confirmation page)', + description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' + }) + generateTicket(@Param('bookingId') bookingId: string) { + return this.service.generate(bookingId); + } + + @Post('force-generate/:bookingId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') @ApiOperation({ - summary: 'Generate ticket for booking (confirmation page)', - description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' + summary: 'Force-generate ticket for booking (staff only)', + description: 'Staff override: regenerates tickets for a confirmed booking regardless of prior state.' }) - generateTicket(@Param('bookingId') bookingId: string) { + forceGenerateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId); } @@ -45,8 +54,8 @@ export class TicketsController { } @Get() - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'List all tickets with optional filters' }) @ApiQuery({ name: 'search', required: false }) @ApiQuery({ name: 'status', required: false }) @@ -88,8 +97,8 @@ export class TicketsController { } @Get('by-order/:merchantOrderId') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Get ticket by merchant order ID', description: 'Looks up the booking ID from the PaymentIntent using merchantOrderId, then returns the full ticket information.' @@ -106,8 +115,8 @@ export class TicketsController { } @Post('scan-board/:qrCodeOrRef') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Scan QR code or booking ref and automatically board ticket', description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.' @@ -131,8 +140,8 @@ export class TicketsController { } @Post(':bookingRef/validate') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Validate ticket at gate with audit logging', description: 'Validates ticket QR/barcode at station gate. For round-trip bookings, supply `leg` (OUTBOUND or RETURN) to record which leg is being used. Defaults to OUTBOUND if omitted. Records validation in audit log with timestamp, gate, and validator.' @@ -162,24 +171,24 @@ export class TicketsController { } @Get(':ticketId/validation-logs') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Get validation logs for ticket' }) getValidationLogs(@Param('ticketId') ticketId: string) { return this.service.getValidationLogs(ticketId); } @Get('offline/export') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Export tickets for offline validation' }) exportOfflineData(@Query('scheduleId') scheduleId: string) { return this.service.exportOfflineData(scheduleId); } @Post('validate/offline') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Batch import offline validations', description: 'Processes validations collected offline. Each entry may include an optional `leg` field (OUTBOUND | RETURN) for round-trip tickets. Deduplication is per bookingRef+leg combination so both legs of the same booking can be submitted in one batch.' @@ -221,8 +230,8 @@ export class TicketsController { } @Patch(':id/restore') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' }) restore(@Param('id') id: string) { return this.service.restore(id); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1b917afe2..095500450 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -48,6 +48,8 @@ export const bookingsApi = { apiClient.post(`/payments/${bookingId}/force-confirm`, data), smartAssign: (bookingId: string) => apiClient.post(`/tickets/smart-assign/${bookingId}`, {}), + forceGenerate: (bookingId: string) => + apiClient.post(`/tickets/force-generate/${bookingId}`, {}), }; // Passengers API From e45c1bcfe2f408384f9579f545a726adc478cc81 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 11:15:30 +0300 Subject: [PATCH 56/71] Ticket generation issue resolution --- .../src/modules/tickets/tickets.controller.ts | 11 ----------- .../backoffice/src/app/bookings/page.tsx | 17 +++++++---------- .../backoffice/src/lib/api/index.ts | 2 -- 3 files changed, 7 insertions(+), 23 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 9aa21be44..cc53be263 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -32,17 +32,6 @@ export class TicketsController { return this.service.generate(bookingId); } - @Post('force-generate/:bookingId') - @PassengerStaff(PASSENGER_PERMS.tickets.generate) - @ApiBearerAuth('IAM-auth') - @ApiOperation({ - summary: 'Force-generate ticket for booking (staff only)', - description: 'Staff override: regenerates tickets for a confirmed booking regardless of prior state.' - }) - forceGenerateTicket(@Param('bookingId') bookingId: string) { - return this.service.generate(bookingId); - } - @Patch('update-seats/:bookingId') @SetMetadata('isPublic', true) @ApiOperation({ diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 07013269a..66b5c6c5a 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -81,15 +81,6 @@ function BookingsPageContent() { const forceConfirmMutation = useMutation({ mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) => bookingsApi.forceConfirm(bookingId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookings'] }); - setSuccessMessage('Payment confirmed and ticket generated successfully'); - setTimeout(() => setSuccessMessage(''), 4000); - setSelectedBooking(null); - setGenerateTicketBooking(null); - setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); - setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); - }, }); const deleteMutation = useMutation({ @@ -649,7 +640,13 @@ function BookingsPageContent() { setGenerateTicketTouched({ paymentReference: true, paymentMethod: true }); if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return; forceConfirmMutation.reset(); - smartAssignMutation.mutate(generateTicketBooking.id); + smartAssignMutation.reset(); + forceConfirmMutation.mutate( + { bookingId: generateTicketBooking.id, data: { paymentReference: generateTicketForm.paymentReference, paymentMethod: generateTicketForm.paymentMethod, notes: generateTicketForm.notes } }, + { + onSuccess: () => smartAssignMutation.mutate(generateTicketBooking.id), + }, + ); }} disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending} > diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 095500450..1b917afe2 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -48,8 +48,6 @@ export const bookingsApi = { apiClient.post(`/payments/${bookingId}/force-confirm`, data), smartAssign: (bookingId: string) => apiClient.post(`/tickets/smart-assign/${bookingId}`, {}), - forceGenerate: (bookingId: string) => - apiClient.post(`/tickets/force-generate/${bookingId}`, {}), }; // Passengers API From 32e1c5e5704857816100a255994a3e1ba52be005 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 11:44:24 +0300 Subject: [PATCH 57/71] Schedule date and time picket updates --- .../backoffice/src/app/schedules/page.tsx | 128 +++--- .../src/components/ui/DateTimePicker.tsx | 414 ++++-------------- 2 files changed, 139 insertions(+), 403 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index b9b2765a7..0f37825fb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { id: string; @@ -228,6 +229,20 @@ export default function SchedulesPage() { }, }); + /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ + const eatLocalToISO = (local: string): string => { + if (!local) return ''; + return new Date(local + ':00+03:00').toISOString(); + }; + + /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ + const isoToEATLocal = (iso: string): string => { + if (!iso) return ''; + const utcMs = new Date(iso).getTime(); + const eatMs = utcMs + 3 * 60 * 60 * 1000; + return new Date(eatMs).toISOString().slice(0, 16); + }; + const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -240,7 +255,7 @@ export default function SchedulesPage() { const payload: any = { trainId: bulkForm.trainId, routeId: bulkForm.routeId, - startDateTime: bulkForm.startDateTime, + startDateTime: eatLocalToISO(bulkForm.startDateTime), durationHours: parseInt(bulkForm.durationHours), repeatEveryDays: parseInt(bulkForm.repeatEveryDays), forNextDays: parseInt(bulkForm.forNextDays), @@ -257,8 +272,8 @@ export default function SchedulesPage() { const handleAddSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const dep = new Date(addForm.departureAt); - const arr = new Date(addForm.arrivalAt); + const dep = new Date(eatLocalToISO(addForm.departureAt)); + const arr = new Date(eatLocalToISO(addForm.arrivalAt)); if (arr <= dep) { setError('Arrival must be after departure'); return; } const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ @@ -276,18 +291,18 @@ export default function SchedulesPage() { if (!editingSchedule) return; - // Convert local datetime-local values to UTC for API - const depLocal = new Date(editForm.departureAt); - const arrLocal = new Date(editForm.arrivalAt); - - if (arrLocal <= depLocal) { + if (!editForm.departureAt || !editForm.arrivalAt) { + setError('Departure and arrival times are required'); + return; + } + if (new Date(eatLocalToISO(editForm.arrivalAt)) <= new Date(eatLocalToISO(editForm.departureAt))) { setError('Arrival time must be after departure time'); return; } const payload: any = { - departureAt: depLocal.toISOString(), - arrivalAt: arrLocal.toISOString(), + departureAt: eatLocalToISO(editForm.departureAt), + arrivalAt: eatLocalToISO(editForm.arrivalAt), status: editForm.status, isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ @@ -328,23 +343,9 @@ export default function SchedulesPage() { const handleEditClick = (schedule: Schedule) => { setEditingSchedule(schedule); - - // Convert UTC dates to local time for datetime-local input - // datetime-local expects local time (no timezone info) - const dep = new Date(schedule.departureAt); - const arr = new Date(schedule.arrivalAt); - - // Convert to local time by adding the timezone offset - const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000); - const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000); - - // Format for datetime-local input (YYYY-MM-DDTHH:mm) - const depStr = depLocal.toISOString().slice(0, 16); - const arrStr = arrLocal.toISOString().slice(0, 16); - setEditForm({ - departureAt: depStr, - arrivalAt: arrStr, + departureAt: isoToEATLocal(schedule.departureAt), + arrivalAt: isoToEATLocal(schedule.arrivalAt), status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], isPackageOnly: schedule.isPackageOnly ?? false, @@ -681,7 +682,7 @@ export default function SchedulesPage() { isOpen={showAddModal} onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }} title="Add Schedule" - size="lg" + size="xl" >
{error &&
{error}
} @@ -703,15 +704,19 @@ export default function SchedulesPage() { -
-
- - setAddForm({ ...addForm, departureAt: e.target.value })} required /> -
-
- - setAddForm({ ...addForm, arrivalAt: e.target.value })} required /> -
+
+ setAddForm({ ...addForm, departureAt: v })} + required + /> + setAddForm({ ...addForm, arrivalAt: v })} + required + />
@@ -839,16 +844,12 @@ export default function SchedulesPage() {
-
- - setBulkForm({ ...bulkForm, startDateTime: e.target.value })} - className="input" - required - /> -
+ setBulkForm({ ...bulkForm, startDateTime: v })} + required + />
@@ -1016,7 +1017,7 @@ export default function SchedulesPage() { setError(null); }} title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`} - size="lg" + size="xl" > {editingSchedule && ( @@ -1027,27 +1028,18 @@ export default function SchedulesPage() { )}
-
- - setEditForm({ ...editForm, departureAt: e.target.value })} - className="input" - required - /> -
- -
- - setEditForm({ ...editForm, arrivalAt: e.target.value })} - className="input" - required - /> -
+ setEditForm({ ...editForm, departureAt: v })} + required + /> + setEditForm({ ...editForm, arrivalAt: v })} + required + />
diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx index 6e71fc268..2f3e05bae 100644 --- a/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DateTimePicker.tsx @@ -1,358 +1,102 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import { DayPicker } from 'react-day-picker'; -import { ChevronLeft, ChevronRight, Calendar, ChevronUp, ChevronDown, X } from 'lucide-react'; -import { cn } from '@/lib/utils'; +/** + * DateTimePicker — label + date / hour / minute / AM-PM all on one row. + * + * Value contract: + * value : "YYYY-MM-DDTHH:mm" (24-hr, EAT local) + * onChange: called with the same shape whenever any part changes + */ interface DateTimePickerProps { - value: string; // YYYY-MM-DDTHH:mm (datetime-local format) - onChange: (value: string) => void; + value: string; + onChange: (v: string) => void; required?: boolean; - id?: string; - placeholder?: string; label?: string; } -function parseLocalString(s: string) { - if (!s) return null; - const [datePart, timePart] = s.split('T'); - if (!datePart || !timePart) return null; - const [yyyy, mm, dd] = datePart.split('-').map(Number); - const [h, m] = timePart.split(':').map(Number); - if (isNaN(yyyy) || isNaN(mm) || isNaN(dd) || isNaN(h) || isNaN(m)) return null; - const period: 'AM' | 'PM' = h >= 12 ? 'PM' : 'AM'; - const hours12 = h % 12 === 0 ? 12 : h % 12; - const date = new Date(yyyy, mm - 1, dd); - return { date, hours12, minutes: m, period }; +const HOURS = Array.from({ length: 12 }, (_, i) => String(i === 0 ? 12 : i).padStart(2, '0')); +const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']; + +function parse(value: string) { + if (!value) return { date: '', h24: 0, min: 0 }; + const [datePart, timePart] = value.split('T'); + const [hStr, mStr] = (timePart ?? '00:00').split(':'); + return { date: datePart ?? '', h24: parseInt(hStr ?? '0'), min: parseInt(mStr ?? '0') }; } -function toLocalString(date: Date, hours12: number, minutes: number, period: 'AM' | 'PM') { - let h = hours12 % 12; - if (period === 'PM') h += 12; - const yyyy = date.getFullYear(); - const mm = String(date.getMonth() + 1).padStart(2, '0'); - const dd = String(date.getDate()).padStart(2, '0'); - const hh = String(h).padStart(2, '0'); - const min = String(minutes).padStart(2, '0'); - return `${yyyy}-${mm}-${dd}T${hh}:${min}`; +function build(date: string, h24: number, min: number): string { + if (!date) return ''; + return `${date}T${String(h24).padStart(2, '0')}:${String(min).padStart(2, '0')}`; } -function formatDisplay(parsed: ReturnType): string { - if (!parsed) return ''; - const { date, hours12, minutes, period } = parsed; - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const dateStr = `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; - const timeStr = `${String(hours12).padStart(2, '0')}:${String(minutes).padStart(2, '0')} ${period}`; - return `${dateStr} ${timeStr}`; -} +export default function DateTimePicker({ value, onChange, required, label }: DateTimePickerProps) { + const { date, h24, min } = parse(value); -export default function DateTimePicker({ - value, - onChange, - id, - placeholder = 'Select date & time', - label, -}: DateTimePickerProps) { - const [open, setOpen] = useState(false); - const [mounted, setMounted] = useState(false); + const isPM = h24 >= 12; + const h12 = h24 % 12 === 0 ? 12 : h24 % 12; + const minStr = String(min).padStart(2, '0'); - useEffect(() => { setMounted(true); }, []); + const emit = (newDate: string, newH24: number, newMin: number) => + onChange(build(newDate, newH24, newMin)); - const parsed = parseLocalString(value); - const [selectedDate, setSelectedDate] = useState(parsed?.date); - const [hours12, setHours12] = useState(parsed?.hours12 ?? 12); - const [minutes, setMinutes] = useState(parsed?.minutes ?? 0); - const [period, setPeriod] = useState<'AM' | 'PM'>(parsed?.period ?? 'AM'); - - // Sync internal state when value changes externally - useEffect(() => { - const p = parseLocalString(value); - if (p) { - setSelectedDate(p.date); - setHours12(p.hours12); - setMinutes(p.minutes); - setPeriod(p.period); - } - }, [value]); - - // Close on Escape - useEffect(() => { - if (!open) return; - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; - document.addEventListener('keydown', handler); - return () => document.removeEventListener('keydown', handler); - }, [open]); - - const emit = useCallback( - (date: Date | undefined, h: number, m: number, p: 'AM' | 'PM') => { - if (!date) return; - onChange(toLocalString(date, h, m, p)); - }, - [onChange], - ); - - const handleDaySelect = (date: Date | undefined) => { - setSelectedDate(date); - if (date) emit(date, hours12, minutes, period); + const handleHour = (v: string) => { + const h = parseInt(v); + const next24 = isPM ? (h === 12 ? 12 : h + 12) : (h === 12 ? 0 : h); + emit(date, next24, min); }; - const cycleHour = (dir: 1 | -1) => { - const next = hours12 + dir; - const h = next > 12 ? 1 : next < 1 ? 12 : next; - setHours12(h); - emit(selectedDate, h, minutes, period); + const handleAmPm = (v: string) => { + const pm = v === 'PM'; + let next24 = h24; + if (pm && h24 < 12) next24 = h24 + 12; + if (!pm && h24 >= 12) next24 = h24 - 12; + emit(date, next24, min); }; - const cycleMinute = (dir: 1 | -1) => { - const next = minutes + dir; - const m = next > 59 ? 0 : next < 0 ? 59 : next; - setMinutes(m); - emit(selectedDate, hours12, m, period); - }; - - const togglePeriod = (p: 'AM' | 'PM') => { - setPeriod(p); - emit(selectedDate, hours12, minutes, p); - }; - - const handleHourInput = (raw: string) => { - const h = parseInt(raw); - if (isNaN(h)) return; - const clamped = Math.max(1, Math.min(12, h)); - setHours12(clamped); - emit(selectedDate, clamped, minutes, period); - }; - - const handleMinuteInput = (raw: string) => { - const m = parseInt(raw); - if (isNaN(m)) return; - const clamped = Math.max(0, Math.min(59, m)); - setMinutes(clamped); - emit(selectedDate, hours12, clamped, period); - }; - - const modal = open && mounted ? createPortal( -
- {/* Backdrop */} -
setOpen(false)} - /> - - {/* Panel */} -
- {/* Header */} -
-

- {label ?? placeholder} -

- -
- - {/* Calendar */} - - orientation === 'left' ? ( - - ) : ( - - ), - DayButton: ({ day, modifiers, className, ...props }) => ( - - handleHourInput(e.target.value)} - onFocus={e => e.target.select()} - className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" - /> - -
- - : - - {/* Minute spinner */} -
- - handleMinuteInput(e.target.value)} - onFocus={e => e.target.select()} - className="w-10 text-center text-base font-mono border border-border rounded-lg py-1.5 bg-background focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" - /> - -
- - {/* AM / PM */} -
- - -
-
-
- - {/* Confirm */} - -
-
, - document.body, - ) : null; - - const displayText = parsed ? formatDisplay(parsed) : placeholder; - return ( -
- - {modal} +
+ {label && ( + + )} +
+ {/* Date */} + emit(e.target.value, h24, min)} + /> + {/* Hour */} + + : + {/* Minute */} + + {/* AM / PM */} + +
); } From b5546b9d6f3de2354ceb7e3c7a74503f7a24cde9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 22 Jul 2026 08:57:47 +0000 Subject: [PATCH 58/71] feat(freight-me): add position type lookup and enrich user profile with position type --- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../backoffice/src/lib/permissions.ts | 6 + e2e/freight/cypress.config.ts | 16 + .../e2e/flows/intercity_one_time.cy.ts | 549 ++++++++++++++++++ .../cypress/fixtures/seed-intercity.sql | 116 ++++ e2e/freight/cypress/support/commands.ts | 5 +- 6 files changed, 693 insertions(+), 1 deletion(-) create mode 100644 e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts create mode 100644 e2e/freight/cypress/fixtures/seed-intercity.sql diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index aed710e2e..f81e733e0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -146,6 +146,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Overview", href: "/dashboard/overview", icon: , + permission: FREIGHT_PERMS.overview.view, }, { label: "Customers", @@ -189,6 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , + permission: FREIGHT_PERMS.support.view, }, ...demoItems, ], diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index b11d7bffa..f793f7006 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -2,6 +2,12 @@ import type { AuthUser } from "@/auth/types"; import type { RuleEngineResourceSlug } from "@/types/rule-engine"; export const FREIGHT_PERMS = { + overview: { + view: "edr_freight_app:overview:view", + }, + support: { + view: "edr_freight_app:support:view", + }, bookings: { view: "edr_freight_app:bookings:view", create: "edr_freight_app:bookings:create", diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index a2acffe24..dda8c1703 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -59,6 +59,22 @@ export default defineConfig({ * user seeders are disabled in app code, so the fixture replicates * their output. Idempotent — safe to run before every spec file. */ + /** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */ + async "db:seedFile"(file: string) { + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + const sql = readFileSync( + join(process.cwd(), "cypress", "fixtures", file), + "utf8", + ); + await client.query(sql); + return true; + } finally { + await client.end(); + } + }, + async "db:seedUsers"() { // cwd = the e2e/freight project root when Cypress runs. // seed-company.sql depends on rows from seed-users.sql — keep order. diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts new file mode 100644 index 000000000..43eca6b4a --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -0,0 +1,549 @@ +/** + * Intercity ONE_TIME journey — the full life of a domestic ride-along shipment: + * + * 1. portal — customer creates an INTERCITY / One-Time / Container contract + * (Mojo Dry Port → Dire Dawa Yard) and submits it + * 2. backoffice — marketer REJECTS it with a reason + * 3. portal — customer sees the rejection banner + reason, then submits a + * fresh contract + * 4. backoffice — marketer accepts + approves LINE_STAFF, director approves + * → CONTRACT_READY + * 5. portal — customer OTP-signs → SIGNED_CUSTOMER + * 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS + * (ONE_TIME intercity always passes the intercity-documents + * step; the e2e setting has no required fields) + * 7. backoffice — operations finalizes document approval → FULLY_EXECUTED + * 8. portal — customer books 2 × 20ft under the contract (intercity has + * no shipment date) → booking OPERATION_REQUEST_PENDING + * 9. backoffice — operations accepts the operation request → booking + * FULLY_EXECUTED (intercity waiting pool) + * 10. backoffice — operations creates the EXPORT route + * Mojo → Dire Dawa → Djibouti Port (distances seeded) + * 11. backoffice — operations schedules the export train (built Train-Builder + * train seeded by seed-intercity.sql) + * 12. backoffice — operations accepts the intercity booking onto the train + * (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with + * a pay deadline that never outlives the export window close + * 13. staff mark-paid (API — the batch panel has no mounted UI button) + * → PAID + SCHEDULED + linked to the schedule + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql +const opsStaff = "operation@edr.local"; + +const ORIGIN_YARD = "Mojo Dry Port"; +const DEST_YARD = "Dire Dawa Yard"; +const PORT_YARD = "Djibouti Port Terminal"; +const TRAIN_CODE = "TRN-E2E-1"; + +const apiUrl = () => Cypress.env("apiUrl") as string; + +/** Latest contract of the seeded company — the journey's contract. */ +function dbContract() { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 AND ct.trade_direction = 'DOMESTIC' + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin], + }, + ); +} + +function withContract(fn: (c: { id: string; reference: string; status: string }) => void) { + dbContract().then(({ rows }) => { + expect(rows, "latest contract for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +function expectContractStatus(expected: string) { + dbContract().then(({ rows }) => { + expect(rows[0]?.status, "contract status").to.eq(expected); + }); +} + +/** Latest DOMESTIC booking of the seeded company — the journey's booking. */ +function dbBooking() { + return cy.task<{ + rows: Array<{ + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }>; + }>("db:query", { + sql: `SELECT b.id, b.reference, b.status, b.scheduling_status, + b.train_schedule_id, b.payment_deadline, b.scheduled_date + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC' + ORDER BY b.created_at DESC LIMIT 1`, + params: [companyTin], + }); +} + +function withBooking( + fn: (b: { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }) => void, +) { + dbBooking().then(({ rows }) => { + expect(rows, "intercity booking for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +/** Latest export schedule created by this journey. */ +function dbSchedule() { + return cy.task<{ + rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>; + }>("db:query", { + sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at + FROM freight.train_schedules ts + ORDER BY ts.created_at DESC LIMIT 1`, + }); +} + +/** Fill a labelled Mantine input (label[for] → input id). */ +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** + * Run the portal wizard for an INTERCITY / One-Time / Container contract and + * submit it. Reused for the initial (to-be-rejected) and the second contract. + */ +function createIntercityContract() { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + // Step 0 — Setup. Intercity forces ETB and hides the customs section. + cy.mantineSelect(/^Operation Type/, /^Intercity$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click(); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + // Step 1 — Cargo & Route (Ethiopian yards only for intercity). + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type( + "E2E intercity electronics between Ethiopian yards", + ); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, DEST_YARD); + cy.contains("button", "Continue").click({ force: true }); + + // Step 2 — Review & Submit → quotation modal. + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + dbContract().then(({ rows }) => { + expect(rows, "contract row").to.have.length(1); + expect(rows[0].status).to.eq("SUBMITTED"); + expect(rows[0].reference).to.match(/^CTR-/); + }); +} + +describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => { + before(() => { + // Container types, locomotives, built train, yard distances — the + // infrastructure the UI journey cannot create in-flow. + cy.task("db:seedFile", "seed-intercity.sql"); + }); + + // ── Contract: submit → reject → resubmit → approve → sign ──────────────── + + it("customer submits an intercity one-time contract", () => { + createIntercityContract(); + }); + + it("marketer rejects the submission with a reason", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Reject contract", { timeout: 20000 }).click(); + cy.get(".mantine-Modal-content") + .contains("label", "Reason for rejection") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete"); + }); + cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click(); + + // Modal closes on success; the status pill can sit inside clipped layout, + // so the authoritative check is the DB row. + cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist"); + expectContractStatus("REJECTED"); + }); + + it("customer sees the rejection reason on the contracts list", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts"); + + // The list is a collapsed table — expand the rejected contract's row to + // reveal its step banner with the staff reason. + withContract((c) => { + cy.contains("tr", c.reference, { timeout: 20000 }) + .find("button") + .first() + .click(); + }); + cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible"); + cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible"); + }); + + it("customer submits a fresh intercity contract", () => { + createIntercityContract(); + }); + + it("marketer accepts the submission and approves the LINE_STAFF step", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + expectContractStatus("PENDING_APPROVAL"); + }); + + it("director approves the final step — contract PDF becomes ready", () => { + cy.loginBackoffice("director@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + expectContractStatus("CONTRACT_READY"); + }); + + it("customer signs the contract with OTP", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + // Scroll the contract iframe to the bottom so the consent bar unlocks. + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + const el = win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + + cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible"); + expectContractStatus("SIGNED_CUSTOMER"); + }); + + it("marketer counter-signs — intercity one-time enters the documents step", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + + // DOMESTIC one-time always routes through the intercity-documents step — + // unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE. + expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS"); + }); + + it("operations finalizes document approval — contract fully executed", () => { + cy.loginBackoffice(opsStaff); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + // The clearance review section lives behind its own tab on the detail page. + cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); + + // The e2e intercity_documents setting has no required fields, so the + // review section is immediately finalizable. + cy.contains("button", "Finalize document approval", { timeout: 30000 }) + .should("not.be.disabled") + .click(); + + cy.contains("finalized", { timeout: 30000 }).should("be.visible"); + expectContractStatus("FULLY_EXECUTED"); + }); + + // ── Booking under the contract ──────────────────────────────────────────── + + it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + // 20ft quantities must be even (pairs share a wagon). + fill(/^Quantity/, "2"); + + // One ISO container number per unit. + cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2); + cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567"); + cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321"); + + // VGM per unit — column inputs carry a placeholder, not a linked label. + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input).clear({ force: true }).type("10", { force: true }); + }); + + // Intercity: no "Shipment day" picker — the ride-along note renders instead. + cy.contains("Shipment day").should("not.exist"); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking((b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null); + }); + }); + + it("operations accepts the operation request — booking joins the intercity pool", () => { + cy.loginBackoffice(opsStaff); + withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); + + cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); + cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); + + withBooking((b) => { + expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED"); + expect(b.train_schedule_id).to.eq(null); + }); + }); + + // ── Route + export schedule ─────────────────────────────────────────────── + + it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => { + cy.loginBackoffice(opsStaff); + + // Skip creation when a previous run already added the route (unique yards + // pair) — the journey stays re-runnable against a warm DB. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/routes"); + cy.contains("button", "Add route", { timeout: 20000 }).click(); + cy.contains("Add Route", { timeout: 15000 }).should("be.visible"); + + // Third stop row, then fill Origin / Milestone / Destination in order. + cy.get(".mantine-Modal-content").contains("button", "Add milestone").click(); + const pickYard = (index: number, yard: string) => { + cy.get('.mantine-Modal-content input[placeholder="Select yard"]') + .eq(index) + .click({ force: true }); + // Three yard selects share option texts — only the open dropdown counts. + cy.get('[role="option"]:visible').contains(yard).click(); + }; + pickYard(0, ORIGIN_YARD); + pickYard(1, DEST_YARD); + pickYard(2, PORT_YARD); + + // Distances (when the build has them) resolve from the seeded rows. + cy.get(".mantine-Modal-content").contains("button", "Save").click(); + }); + + cy.task<{ rows: Array<{ direction: string }> }>("db:query", { + sql: `SELECT r.direction + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + expect(rows, "export route").to.have.length.at.least(1); + expect(rows[0].direction).to.eq("EXPORT"); + }); + }); + + it("operations schedules the export train from the built consist", () => { + cy.loginBackoffice(opsStaff); + + // One departure per route per day — a warm DB from a previous run already + // has this train scheduled, so only create when none is live. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.train_schedules ts + JOIN freight.routes r ON r.id = ts.route_id + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/operations/train-scheduling-v2"); + cy.contains("button", "New schedule", { timeout: 20000 }).click(); + cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible"); + + cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD)); + + // Two days out, local datetime-local format. + const departure = new Date(Date.now() + 2 * 86400000); + const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + cy.get('.mantine-Modal-content input[type="datetime-local"]') + .clear({ force: true }) + .type(local, { force: true }); + + cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE)); + cy.get(".mantine-Modal-content").contains("button", "Create").click(); + + // Create navigates straight to the new schedule's detail page. + cy.location("pathname", { timeout: 30000 }).should( + "match", + /\/dashboard\/operations\/train-scheduling-v2\/.+/, + ); + }); + + dbSchedule().then(({ rows }) => { + expect(rows, "created schedule").to.have.length(1); + expect(rows[0].direction).to.eq("EXPORT"); + }); + }); + + // ── Intercity ride-along: accept → pay → allocated ──────────────────────── + + it("operations accepts the intercity booking onto the export train", () => { + cy.loginBackoffice(opsStaff); + dbSchedule().then(({ rows: schedules }) => { + cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`); + }); + + cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click(); + // Presence, not viewport visibility — the panel can sit below the fold / + // inside clipped layout once earlier runs' rows stack up. + cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist"); + + withBooking((b) => { + cy.contains("tr", b.reference, { timeout: 30000 }) + .find('input[type="checkbox"]') + .check({ force: true }); + cy.contains("button", /Accept .*onto this train/).click(); + + // Accepted table shows the pay-window state (inside a horizontal + // Table.ScrollContainer — assert presence, not viewport visibility). + cy.contains("Awaiting payment", { timeout: 30000 }).should("exist"); + }); + + // Export parity: the ride-along's pay deadline never outlives the export + // booking window (reserve() clamps it to window_closes_at). + dbSchedule().then(({ rows: schedules }) => { + withBooking((b) => { + expect(b.status).to.eq("SELECTED_FOR_BATCH"); + expect(b.train_schedule_id).to.eq(schedules[0].id); + expect(b.payment_deadline, "pay deadline set").to.be.a("string"); + expect(new Date(b.payment_deadline!).getTime()).to.be.at.most( + new Date(schedules[0].window_closes_at).getTime(), + ); + }); + }); + }); + + it("staff mark the ride-along paid — booking allocates onto the train", () => { + // ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the + // current UI, so drive the staff override endpoint directly. + withBooking((b) => { + cy.apiLogin(opsStaff).then(({ token }) => { + cy.request({ + method: "POST", + url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + }); + + withBooking((b) => { + expect(b.status).to.eq("PAID"); + expect(b.scheduling_status).to.eq("SCHEDULED"); + expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string"); + + // The schedule↔booking link row is what makes the booking visible on the + // train board, in yard work, and to the wagon planner. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + params: [b.id], + }).then(({ rows }) => { + expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1); + }); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/fixtures/seed-intercity.sql b/e2e/freight/cypress/fixtures/seed-intercity.sql new file mode 100644 index 000000000..4c45f8de9 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-intercity.sql @@ -0,0 +1,116 @@ +-- Arrange-data for flows/intercity_one_time.cy.ts. Idempotent. +-- +-- The e2e DB boots with yards + a wagon fleet only: no container types, no +-- locomotives, no Train-Builder train, no yard distances, no routes. The spec +-- drives route + schedule creation through the UI; this fixture provides only +-- the infrastructure the UI journey cannot reasonably create in-flow: +-- +-- 1. container types (booking form resolves 20ft/40ft by size_ft) +-- 2. container-type → wagon-type allow-list (wagon planner) +-- 3. two locomotives (a schedulable train needs >= 2) +-- 4. a built Train-Builder train at Mojo with four NW5 flat wagons +-- 5. yard distances for Mojo–Dire Dawa–Djibouti Port (route creation +-- refuses unconfigured pairs) + +-- 1. Container types. +INSERT INTO freight.container_types (id, code, label, size_ft, is_active) +SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true +FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft) +WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code); + +-- 2. 20ft/40ft containers ride NW5 flat wagons. +INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.container_types ct +JOIN freight.wagon_types wt ON wt.code = 'NW5' +WHERE ct.code IN ('20FT', '40FT') + AND NOT EXISTS ( + SELECT 1 FROM freight.container_type_wagon_types x + WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +-- 3. Two locomotives at Mojo (status defaults to AVAILABLE). +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id) +SELECT gen_random_uuid(), v.code, 4000, 760, y.id +FROM (VALUES ('LOCO-E2E-1'), ('LOCO-E2E-2')) AS v(code) +JOIN freight.yards y ON y.code = 'MOJO' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +-- 4a. Built train at Mojo (status defaults to AVAILABLE). +INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id) +SELECT gen_random_uuid(), 'TRN-E2E-1', 'E2E Export Carrier', 2000, y.id +FROM freight.yards y +WHERE y.code = 'MOJO' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-E2E-1'); + +-- 4b. Couple both locomotives (available-trains filter requires >= 2). +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, + row_number() OVER (ORDER BY l.code) - 1 +FROM freight.trains t +JOIN freight.locomotives l ON l.code IN ('LOCO-E2E-1', 'LOCO-E2E-2') +WHERE t.code = 'TRN-E2E-1' + AND NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id + ); + +-- 4c. Couple four free NW5 flat wagons onto the train and park them at Mojo +-- with it (the seeded fleet sits at Doraleh; the planner reads the consist by +-- train_id, the yard only matters for warnings). +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 4 + ) sub +WHERE t.code = 'TRN-E2E-1' + AND w.id = sub.id + AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id); + +-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing +-- hard-blocks any container line without a rate on its exact leg; rates are +-- configured in USD and converted to the booking currency). +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), 'INTERCITY_CONTAINER', 'INTERCITY', 'ALWAYS', 'USD', 500, + 'PER_CONTAINER', 'LIVE', a.id, b.id, u.id +FROM freight.yards a +JOIN freight.yards b ON b.code = 'DIRE_DAWA' +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE a.code = 'MOJO' + AND NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = 'INTERCITY_CONTAINER' + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL + ); + +-- 6. Segment distances (symmetric — one row covers both directions). Guarded: +-- an e2e image built from a branch that predates the yard_distances feature +-- has no table, and its route form doesn't require distances either. +DO $$ +BEGIN + IF to_regclass('freight.yard_distances') IS NOT NULL THEN + INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km) + SELECT gen_random_uuid(), a.id, b.id, v.km + FROM (VALUES ('MOJO', 'DIRE_DAWA', 300), ('DIRE_DAWA', 'DJIB_PORT', 450)) + AS v(from_code, to_code, km) + JOIN freight.yards a ON a.code = v.from_code + JOIN freight.yards b ON b.code = v.to_code + WHERE NOT EXISTS ( + SELECT 1 FROM freight.yard_distances d + WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id) + OR (d.from_yard_id = b.id AND d.to_yard_id = a.id) + ); + END IF; +END $$; diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts index 6a958d5be..2d18350a0 100644 --- a/e2e/freight/cypress/support/commands.ts +++ b/e2e/freight/cypress/support/commands.ts @@ -110,7 +110,10 @@ Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | .then((id) => { cy.get(`[id="${id}"]`).click({ force: true }); }); - cy.get('[role="option"]').contains(option).click(); + // :visible — closed dropdowns can linger in the DOM, and two selects on one + // page may list the same option text (e.g. the intercity wizard's origin + + // destination both list every Ethiopian yard). + cy.get('[role="option"]:visible').contains(option).click(); }); /** Type a 6-digit code into a Mantine PinInput. */ From e0dc60361aa3918512529fd2b30199e2c77b72d2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 12:17:51 +0300 Subject: [PATCH 59/71] Ticket generate on confirmation update --- .../portal/src/app/booking/detail/page.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index c6f823053..3ac0efd56 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -117,6 +117,19 @@ function BookingDetailContent() { retry: 1, }); + // When the booking is CONFIRMED but has no tickets (generate failed silently at + // payment time), call generate now so tickets are ready before the user clicks Download. + useEffect(() => { + if ( + booking?.status === 'CONFIRMED' && + booking?.id && + Array.isArray(booking?.tickets) && + booking.tickets.length === 0 + ) { + apiClient.post(`/tickets/generate/${booking.id}`, {}).then(() => refetch()).catch(() => {}); + } + }, [booking?.id, booking?.status, booking?.tickets?.length]); + const { data: paymentMethods } = useQuery({ queryKey: ["payment-methods"], queryFn: () => apiClient.get("/payments/methods"), @@ -246,19 +259,9 @@ function BookingDetailContent() { alert("Booking data not available. Please try again."); return; } - setIsGeneratingVoucher(true); try { - // If tickets are missing (generate failed silently at payment time), issue them now. - if (!booking.tickets?.length && booking.id) { - try { - await apiClient.post(`/tickets/generate/${booking.id}`, {}); - } catch { - // ignore — generate() will throw if payment not succeeded; voucher will show - // "Not yet issued" in that case, which is correct - } - } - // Always refetch so the voucher has the latest ticket barcodes. + // Always fetch fresh booking data so tickets are included. const fresh = await apiClient.get(`/bookings/${booking.bookingRef}`); const bookingData = (fresh as any)?.data || fresh; const { generateVoucherPDF } = await import("@/lib/generate-voucher"); From 44b4779ca6b52aafe5fe0c0e5e5a20c11bc5e8ff Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 12:54:21 +0300 Subject: [PATCH 60/71] Ticketing updates --- .../src/modules/bookings/bookings.module.ts | 3 +- .../src/modules/bookings/bookings.service.ts | 30 +++++++++++++++++++ .../src/modules/tickets/tickets.controller.ts | 11 +++++++ .../src/modules/tickets/tickets.service.ts | 28 +++++++++++++++++ .../backoffice/src/app/tickets/page.tsx | 27 ++++++++++++++++- .../backoffice/src/lib/api/index.ts | 1 + 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts index 63fd8be85..0ce77ad5b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.module.ts @@ -9,9 +9,10 @@ import { VerifaydaModule } from '../verifayda/verifayda.module'; import { CurrencyModule } from '../currency/currency.module'; import { AuthModule } from '../auth/auth.module'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; +import { TicketsModule } from '../tickets/tickets.module'; @Module({ - imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule], + imports: [AuditModule, SeatsModule, VerifaydaModule, CurrencyModule, FareEngineModule, HttpModule, AuthModule, TicketsModule], controllers: [BookingsController], providers: [BookingsService, GuestBookingService], exports: [BookingsService, GuestBookingService] diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 64dcb2c6c..2c4d2cee4 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; +import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; import { Cron, CronExpression } from '@nestjs/schedule'; @@ -101,6 +102,7 @@ export class BookingsService { private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, private readonly seatsService: SeatsService, + private readonly ticketsService: TicketsService, private readonly eventEmitter: EventEmitter2, private readonly verifaydaService: VerifaydaService, private readonly currencyService: CurrencyService, @@ -1903,6 +1905,34 @@ export class BookingsService { }; } + // Auto-heal: if booking is CONFIRMED, payment SUCCEEDED, but tickets are missing + // (ticket generation failed silently after payment — see finalizePaymentSuccess in + // payments.service.ts), attempt to generate them now so the confirmation page + // doesn't show "Not yet issued". + if ( + booking.status === 'CONFIRMED' && + (booking as any).tickets?.length === 0 && + (booking as any).paymentIntent?.status === 'SUCCEEDED' + ) { + try { + await this.ticketsService.generate(booking.id); + // Re-fetch to include the newly created tickets + const refreshed = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, + paymentIntent: true, tickets: true, + priceTier: { select: { priceMinor: true } }, + }, + }); + if (refreshed) Object.assign(booking, refreshed); + } catch (err) { + this.logger.warn(`getByRef: auto-generate tickets failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + const outboundSegment = this.resolveSegmentStations( (booking as any).schedule, (booking as any).originStationId, diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index cc53be263..3a48ca2a1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -9,6 +9,17 @@ import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; export class TicketsController { constructor(private service: TicketsService) {} + @Post('generate-missing') + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Generate tickets for all confirmed bookings that are missing them', + description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.', + }) + generateMissing() { + return this.service.generateMissing(); + } + @Post('smart-assign/:bookingId') @PassengerStaff(PASSENGER_PERMS.tickets.generate) @ApiBearerAuth('IAM-auth') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 1cff652e0..965026c92 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -830,6 +830,34 @@ export class TicketsService { }; } + async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { + const confirmedWithNoTickets = await this.prisma.booking.findMany({ + where: { + status: 'CONFIRMED', + tickets: { none: {} }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + select: { id: true, bookingRef: true }, + }); + + const details: any[] = []; + let generated = 0; + let failed = 0; + + for (const booking of confirmedWithNoTickets) { + try { + await this.generate(booking.id); + generated++; + details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'generated' }); + } catch (err) { + failed++; + details.push({ bookingId: booking.id, bookingRef: booking.bookingRef, status: 'failed', error: err instanceof Error ? err.message : String(err) }); + } + } + + return { processed: confirmedWithNoTickets.length, generated, failed, details }; + } + async delete(id: string) { const ticket = await this.prisma.ticket.findUnique({ where: { id } }); if (!ticket) throw new NotFoundException('Ticket not found'); diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index 4b55b37be..5c6354fd4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -98,6 +98,21 @@ export default function TicketsPage() { queryFn: () => apiClient.get('/fleet/coaches'), }); + const [generateMissingResult, setGenerateMissingResult] = useState(null); + + const generateMissingMutation = useMutation({ + mutationFn: () => ticketsApi.generateMissing(), + onSuccess: (result: any) => { + queryClient.invalidateQueries({ queryKey: ['tickets'] }); + setGenerateMissingResult(result); + setSuccessMessage(`Generated ${result.generated} ticket(s) for ${result.processed} booking(s)${result.failed ? ` (${result.failed} failed)` : ''}`); + setTimeout(() => setSuccessMessage(''), 6000); + }, + onError: (error: any) => { + alert(error?.response?.data?.message || error?.message || 'Failed to generate missing tickets'); + }, + }); + const boardMutation = useMutation({ mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }), @@ -546,7 +561,17 @@ export default function TicketsPage() {

Tickets

Manage tickets and validations

- setExportModalOpen(true)}>Export +
+ generateMissingMutation.mutate()} + > + Generate Missing + + setExportModalOpen(true)}>Export +
{/* Filters */} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 1b917afe2..0d35ed2d3 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,6 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), + generateMissing: () => apiClient.post('/tickets/generate-missing', {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`), From 698dbd47e6b1ff4d81e9a6a5c546e591403ba181 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 13:43:38 +0300 Subject: [PATCH 61/71] Ticket generation updates --- .../src/modules/payments/payments.service.ts | 26 ++++++++++++++----- .../src/modules/tickets/tickets.service.ts | 16 +++++++++--- .../src/app/booking/confirmation/page.tsx | 10 ++++++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7ee67be76..b3befbaea 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -3,6 +3,7 @@ import { Logger, NotFoundException, BadRequestException, + ConflictException, } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { SeatsService } from "../seats/seats.service"; @@ -897,12 +898,25 @@ export class PaymentsService { try { await this.ticketsService.generate(booking.id); } catch (err) { - this.logger.error( - `Error generating ticket for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`, - ); - // Re-throw so callers (e.g. force-confirm) know tickets weren't issued. - // Webhook handlers catch this themselves and still return 200 to avoid redelivery. - throw err; + const msg = err instanceof Error ? err.message : String(err); + // Only reassign seats when a *different* booking genuinely holds the seat + // (ConflictException). Any other error (transient DB issue, etc.) is logged + // and swallowed — the passenger keeps their original seat and the ticket can + // be retried via "Generate Missing" in the backoffice. + if (err instanceof ConflictException) { + this.logger.warn( + `Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`, + ); + try { + await this.ticketsService.smartAssignAndGenerate(booking.id); + } catch (retryErr) { + this.logger.error( + `Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`, + ); + } + } else { + this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`); + } } try { diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 965026c92..2ba05dd4e 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -352,8 +352,20 @@ export class TicketsService { } } - // Check for seat conflicts before deleting existing tickets or issuing new ones + await this.prisma.ticket.deleteMany({ where: { bookingId } }); + + // Remove any SeatBlock rows left over from a previous generate() run for this + // booking — they reference the old ticket IDs which are now deleted, and would + // otherwise cause the conflict check below to see this booking's own seats as + // blocked by another booking. const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); + await this.prisma.seatBlock.deleteMany({ + where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, + }); + + // Check for seat conflicts — only seats confirmed/boarded by a *different* booking + // on the same schedule are a real conflict. SeatBlock rows created by a previous + // generate() run for this booking are NOT a conflict; they are cleaned up above. const conflictingSeats = await this.prisma.bookingSeat.findMany({ where: { seatId: { in: seatIds }, @@ -371,8 +383,6 @@ export class TicketsService { ); } - await this.prisma.ticket.deleteMany({ where: { bookingId } }); - // Generate one ticket per passenger per leg. // Round-trip / transit bookings have seats on multiple legs — each leg needs its own // ticket so the voucher can match by (passengerName, leg) and gate scanners can diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index cd24bbe0c..19bae536b 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -70,7 +70,9 @@ export default function ConfirmationPage() { const CONFIRMATION_GRACE_PERIOD_MS = 10_000; const FAST_POLL_INTERVAL_MS = 2_500; const SLOW_POLL_INTERVAL_MS = 10_000; + const MAX_TICKET_POLL_ATTEMPTS = 12; // 12 × 2.5s = 30s max wait for tickets const mountTimeRef = useRef(Date.now()); + const ticketPollAttemptsRef = useRef(0); const [withinGracePeriod, setWithinGracePeriod] = useState(true); useEffect(() => { @@ -122,7 +124,13 @@ export default function ConfirmationPage() { if (!data || data.status !== "CONFIRMED") return false; const adultCount = searchCriteria?.adultCount ?? passengers.filter((p) => !isChild(p)).length; const expectedTickets = Math.max(1, adultCount); - return (data.tickets?.length ?? 0) >= expectedTickets ? false : FAST_POLL_INTERVAL_MS; + if ((data.tickets?.length ?? 0) >= expectedTickets) { + ticketPollAttemptsRef.current = 0; + return false; + } + if (ticketPollAttemptsRef.current >= MAX_TICKET_POLL_ATTEMPTS) return false; + ticketPollAttemptsRef.current += 1; + return FAST_POLL_INTERVAL_MS; }, }); From 44cad7a52b41706a8faab864f01442b68ea7f30d Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 22 Jul 2026 10:50:36 +0000 Subject: [PATCH 62/71] changes --- apps/edr-freight-api/src/main.ts | 7 + .../cypress/e2e/flows/export_one_time.cy.ts | 525 ++++++++++++++++++ .../e2e/flows/intercity_one_time.cy.ts | 37 +- e2e/freight/cypress/fixtures/seed-export.sql | 73 +++ .../cypress/fixtures/seed-intercity.sql | 15 + 5 files changed, 651 insertions(+), 6 deletions(-) create mode 100644 e2e/freight/cypress/e2e/flows/export_one_time.cy.ts create mode 100644 e2e/freight/cypress/fixtures/seed-export.sql diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 0fa1056dd..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -33,6 +33,13 @@ async function bootstrap() { "delegator-position-id", "current-project-id", "current-position-id", + // x-prefixed variants sent by the user-management / record-management + // frontend modules (same values, different naming convention) + "x-organization-unit-id", + "x-delegator-id", + "x-delegator-position-id", + "x-current-project-id", + "x-current-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts new file mode 100644 index 000000000..a1cc00c57 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -0,0 +1,525 @@ +/** + * Export ONE_TIME journeys — two contracts ride the same export train + * (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal): + * + * A. CONTAINER (20ft + 40ft): + * portal wizard → staff approval chain → OTP sign → counter-sign + * → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required + * docs in e2e) → ops finalize → FULLY_EXECUTED → customer books + * 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the + * operation request → EXPORT is FCFS, so accept reserves the train slot + * immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the + * export window close → staff mark-paid → PAID + SCHEDULED + linked. + * + * B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and + * bulk booking form, riding CW4 covered wagons on the same train. + * + * Infrastructure (route, built train, distances, rates, cargo types) comes + * from seed-intercity.sql + seed-export.sql; the export route and the + * departing-today schedule are created through the UI when missing. + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql +const opsStaff = "operation@edr.local"; + +const ORIGIN_YARD = "Mojo Dry Port"; +const MID_YARD = "Dire Dawa Yard"; +const PORT_YARD = "Djibouti Port Terminal"; +const TRAIN_CODE = "TRN-E2E-1"; + +// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run. +const stamp = String(Date.now()); +const isoNumber = (prefix: string, offset: number) => + `${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`; + +const apiUrl = () => Cypress.env("apiUrl") as string; + +function dbContract(freight: "CONTAINER" | "BULK") { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2 + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin, freight], + }, + ); +} + +function withContract( + freight: "CONTAINER" | "BULK", + fn: (c: { id: string; reference: string; status: string }) => void, +) { + dbContract(freight).then(({ rows }) => { + expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1); + fn(rows[0]); + }); +} + +function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) { + dbContract(freight).then(({ rows }) => { + expect(rows[0]?.status, "contract status").to.eq(expected); + }); +} + +function dbBooking(freight: "CONTAINER" | "BULK") { + return cy.task<{ + rows: Array<{ + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }>; + }>("db:query", { + sql: `SELECT b.id, b.reference, b.status, b.scheduling_status, + b.train_schedule_id, b.payment_deadline, b.scheduled_date + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2 + ORDER BY b.created_at DESC LIMIT 1`, + params: [companyTin, freight], + }); +} + +function withBooking( + freight: "CONTAINER" | "BULK", + fn: (b: { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }) => void, +) { + dbBooking(freight).then(({ rows }) => { + expect(rows, `EXPORT ${freight} booking`).to.have.length(1); + fn(rows[0]); + }); +} + +/** + * The journey's export schedule. Export trains must be scheduled ≥ the booking + * lead (24h) ahead, and their window OPENS at departure − lead — so the spec + * departs at now + 24h + a couple of minutes: creatable now, window opens + * minutes later. (The intercity spec's train leaves in 2 days — outside 25h.) + */ +function dbUpcomingSchedule() { + return cy.task<{ + rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>; + }>("db:query", { + sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status + FROM freight.train_schedules ts + WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL + AND ts.scheduled_departure_date > now() + AND ts.scheduled_departure_date < now() + interval '25 hours' + ORDER BY ts.created_at DESC LIMIT 1`, + }); +} + +/** The train departs ~24h out — bookings ride its departure day (tomorrow). */ +const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000); + +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */ +function fillNth(label: RegExp, index: number, value: string) { + cy.get("label").then(($labels) => { + const matches = $labels.filter((_, el) => label.test(el.textContent ?? "")); + expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index); + const id = matches.eq(index).attr("for"); + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** Open the Shipment day picker and choose the train's departure day. */ +function pickShipmentDay() { + cy.contains("label", /^Shipment day/) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).click({ force: true }); + }); + const day = String(SHIPMENT_DAY.getDate()); + cy.get(".mantine-Popover-dropdown button:not([data-disabled]):not([disabled])", { + timeout: 15000, + }) + .contains(new RegExp(`^${day}$`)) + .click({ force: true }); +} + +/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */ +function approveChain(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice("marketer@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + cy.loginBackoffice("director@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + expectContractStatus(freight, "CONTRACT_READY"); +} + +/** Shared customer OTP-signature step. */ +function customerSigns(freight: "CONTAINER" | "BULK") { + cy.loginPortal(customer); + withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + const el = win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "SIGNED_CUSTOMER"); +} + +/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */ +function counterSignAndFinalize(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice("marketer@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS"); + + cy.loginBackoffice(opsStaff); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); + cy.contains("button", "Finalize document approval", { timeout: 30000 }) + .should("not.be.disabled") + .click(); + cy.contains("finalized", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "FULLY_EXECUTED"); +} + +/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */ +function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice(opsStaff); + withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); + cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); + cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); + cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist"); + + dbUpcomingSchedule().then(({ rows: schedules }) => { + expect(schedules, "departing-today export schedule").to.have.length(1); + withBooking(freight, (b) => { + expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH"); + expect(b.train_schedule_id).to.eq(schedules[0].id); + // Export parity: the pay window never outlives the booking window close. + expect(b.payment_deadline, "pay deadline set").to.be.a("string"); + expect(new Date(b.payment_deadline!).getTime()).to.be.at.most( + new Date(schedules[0].window_closes_at).getTime(), + ); + }); + }); +} + +function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") { + withBooking(freight, (b) => { + cy.apiLogin(opsStaff).then(({ token }) => { + cy.request({ + method: "POST", + url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + }); + withBooking(freight, (b) => { + expect(b.status).to.eq("PAID"); + expect(b.scheduling_status).to.eq("SCHEDULED"); + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + params: [b.id], + }).then(({ rows }) => { + expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1); + }); + }); +} + +describe("export one-time journeys: container + bulk on one train", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-intercity.sql"); + cy.task("db:seedFile", "seed-export.sql"); + }); + + // ── Shared infrastructure ───────────────────────────────────────────────── + + it("operations ensures the export route exists", () => { + cy.loginBackoffice(opsStaff); + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/routes"); + cy.contains("button", "Add route", { timeout: 20000 }).click(); + cy.contains("Add Route", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", "Add milestone").click(); + const pickYard = (index: number, yard: string) => { + cy.get('.mantine-Modal-content input[placeholder="Select yard"]') + .eq(index) + .click({ force: true }); + cy.get('[role="option"]:visible').contains(yard).click(); + }; + pickYard(0, ORIGIN_YARD); + pickYard(1, MID_YARD); + pickYard(2, PORT_YARD); + cy.get(".mantine-Modal-content").contains("button", "Save").click(); + }); + }); + + it("operations schedules the export train — booking window opens", () => { + cy.loginBackoffice(opsStaff); + + dbUpcomingSchedule().then(({ rows }) => { + if (rows.length > 0) return; + + cy.visit("/dashboard/operations/train-scheduling-v2"); + cy.contains("button", "New schedule", { timeout: 20000 }).click(); + cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible"); + cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD)); + + // Just past the 24h scheduling lead: creatable now, and the export + // window (opens departure − lead) flips OPEN a couple of minutes later. + const local = new Date( + SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000, + ) + .toISOString() + .slice(0, 16); + cy.get('.mantine-Modal-content input[type="datetime-local"]') + .clear({ force: true }) + .type(local, { force: true }); + cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE)); + cy.get(".mantine-Modal-content").contains("button", "Create").click(); + cy.location("pathname", { timeout: 30000 }).should( + "match", + /\/dashboard\/operations\/train-scheduling-v2\/.+/, + ); + }); + + // The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes. + const waitForOpenWindow = (attempt: number) => { + dbUpcomingSchedule().then(({ rows }) => { + expect(rows, "upcoming export schedule").to.have.length(1); + if (rows[0].booking_window_status === "OPEN") return; + expect(attempt, "export booking window OPEN").to.be.lessThan(40); + cy.wait(10000).then(() => waitForOpenWindow(attempt + 1)); + }); + }; + waitForOpenWindow(0); + }); + + // ── Journey A: container 20ft + 40ft ────────────────────────────────────── + + it("customer submits an export container contract (20ft + 40ft)", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + cy.mantineSelect(/^Operation Type/, /^Export$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('[role="checkbox"][aria-label="40ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics"); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, PORT_YARD); + cy.contains("button", "Continue").click({ force: true }); + + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + expectContractStatus("CONTAINER", "SUBMITTED"); + }); + + it("staff approve the container contract (marketer + director)", () => { + approveChain("CONTAINER"); + }); + + it("customer signs the container contract with OTP", () => { + customerSigns("CONTAINER"); + }); + + it("staff counter-sign and operations finalize the container contract", () => { + counterSignAndFinalize("CONTAINER"); + }); + + it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => { + cy.loginPortal(customer); + withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + // Two size editors, each with its own "Quantity *" (20ft first, then 40ft). + fillNth(/^Quantity/, 0, "2"); + fillNth(/^Quantity/, 1, "1"); + + cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3); + cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0)); + cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1)); + cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2)); + + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input).clear({ force: true }).type("10", { force: true }); + }); + + pickShipmentDay(); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking("CONTAINER", (b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string"); + }); + }); + + it("operations accepts the container request — FCFS reserves today's train", () => { + acceptAndAssertReserved("CONTAINER"); + }); + + it("staff mark the container booking paid — allocated onto the train", () => { + markPaidAndAssertAllocated("CONTAINER"); + }); + + // ── Journey B: bulk (E2E Wheat) ─────────────────────────────────────────── + + it("customer submits an export bulk contract (wheat)", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + cy.mantineSelect(/^Operation Type/, /^Export$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/); + cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains"); + cy.mantineSelect(/^Commodity/, "E2E Wheat"); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, PORT_YARD); + cy.contains("button", "Continue").click({ force: true }); + + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + expectContractStatus("BULK", "SUBMITTED"); + }); + + it("staff approve the bulk contract (marketer + director)", () => { + approveChain("BULK"); + }); + + it("customer signs the bulk contract with OTP", () => { + customerSigns("BULK"); + }); + + it("staff counter-sign and operations finalize the bulk contract", () => { + counterSignAndFinalize("BULK"); + }); + + it("customer books 60 tons of wheat with a shipment day", () => { + cy.loginPortal(customer); + withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + fill(/^Quantity \(tons\)/, "60"); + pickShipmentDay(); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking("BULK", (b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + }); + }); + + it("operations accepts the bulk request — FCFS reserves today's train", () => { + acceptAndAssertReserved("BULK"); + }); + + it("staff mark the bulk booking paid — allocated onto the train", () => { + markPaidAndAssertAllocated("BULK"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts index 43eca6b4a..b8ca2fd1b 100644 --- a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -10,9 +10,13 @@ * → CONTRACT_READY * 5. portal — customer OTP-signs → SIGNED_CUSTOMER * 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS - * (ONE_TIME intercity always passes the intercity-documents - * step; the e2e setting has no required fields) - * 7. backoffice — operations finalizes document approval → FULLY_EXECUTED + * (ONE_TIME intercity always routes through the + * intercity-documents step; the fixture seeds one REQUIRED + * document so the step is real) + * 6b. portal — customer uploads the required intercity document + * → CLEARANCE_UNDER_REVIEW + * 7. backoffice — operations approves the document, then finalizes document + * approval → FULLY_EXECUTED * 8. portal — customer books 2 × 20ft under the contract (intercity has * no shipment date) → booking OPERATION_REQUEST_PENDING * 9. backoffice — operations accepts the operation request → booking @@ -314,15 +318,36 @@ describe("intercity one-time journey: contract → booking → export train", { expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS"); }); - it("operations finalizes document approval — contract fully executed", () => { + it("customer uploads the required intercity document", () => { + cy.loginPortal(customer); + // Deep link auto-opens the clearance documents modal. + withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`)); + + cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist"); + cy.get('.mantine-Modal-content input[type="file"]') + .first() + .selectFile("cypress/fixtures/docs/license.pdf", { force: true }); + cy.contains("button", "Submit documents", { timeout: 15000 }) + .should("not.be.disabled") + .click(); + + // Upload hands the contract to Operations review — the card flips to + // "Under review" (the host modal may linger while queries refetch). + cy.contains("Under review", { timeout: 30000 }).should("exist"); + expectContractStatus("CLEARANCE_UNDER_REVIEW"); + }); + + it("operations approves the document and finalizes — contract fully executed", () => { cy.loginBackoffice(opsStaff); withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); // The clearance review section lives behind its own tab on the detail page. cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); - // The e2e intercity_documents setting has no required fields, so the - // review section is immediately finalizable. + // Approve the uploaded Cargo Manifest, then finalize. + cy.contains("button", /Approve all/, { timeout: 30000 }).click(); + cy.contains("1/1 approved", { timeout: 30000 }).should("exist"); + cy.contains("button", "Finalize document approval", { timeout: 30000 }) .should("not.be.disabled") .click(); diff --git a/e2e/freight/cypress/fixtures/seed-export.sql b/e2e/freight/cypress/fixtures/seed-export.sql new file mode 100644 index 000000000..023125106 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-export.sql @@ -0,0 +1,73 @@ +-- Arrange-data for flows/export_one_time.cy.ts. Idempotent. +-- Run AFTER seed-intercity.sql (reuses its container types, locomotives, +-- built train TRN-E2E-1 and yard distances). +-- +-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat", +-- carried on CW4 covered wagons +-- 2. two CW4 wagons coupled onto the train (bulk capacity) +-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container) +-- and bulk (per ton) — booking pricing hard-blocks without them + +-- 1a. Cargo type group + commodity. +INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active) +SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true +WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS'); + +INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active) +SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true +FROM freight.cargo_types g +WHERE g.code = 'E2E_GRAINS' + AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT'); + +-- 1b. Wheat rides CW4 covered wagons. +INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.cargo_types ct +JOIN freight.wagon_types wt ON wt.code = 'CW4' +WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS') + AND NOT EXISTS ( + SELECT 1 FROM freight.cargo_type_wagon_types x + WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it. +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = 100 + sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 2 + ) sub +WHERE t.code = 'TRN-E2E-1' + AND w.id = sub.id + AND NOT EXISTS ( + SELECT 1 FROM freight.wagons wx + JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4' + WHERE wx.train_id = t.id + ); + +-- 3. LIVE export rates Mojo → Djibouti Port. +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value, + v.unit, 'LIVE', a.id, b.id, u.id +FROM (VALUES + ('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'), + ('BULK_EXPORT', 'BULK', 25, 'PER_TON') + ) AS v(rate_type, applies_to, value, unit) +JOIN freight.yards a ON a.code = 'MOJO' +JOIN freight.yards b ON b.code = 'DJIB_PORT' +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = v.rate_type + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL +); diff --git a/e2e/freight/cypress/fixtures/seed-intercity.sql b/e2e/freight/cypress/fixtures/seed-intercity.sql index 4c45f8de9..732624ff4 100644 --- a/e2e/freight/cypress/fixtures/seed-intercity.sql +++ b/e2e/freight/cypress/fixtures/seed-intercity.sql @@ -76,6 +76,21 @@ WHERE t.code = 'TRN-E2E-1' AND w.id = sub.id AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id); +-- 4d. One REQUIRED intercity clearance document, so the journey exercises the +-- real customer-upload → ops-review → finalize step (the seeder leaves the +-- intercity_documents setting empty). +INSERT INTO freight.file_upload_fields + (id, setting_id, file_key, file_label, is_required, is_multiple, max_files, + allowed_extensions, max_size_mb, display_order) +SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1, + '{pdf,jpg,jpeg,png}'::text[], 10, 1 +FROM freight.file_upload_settings s +WHERE s.code = 'intercity_documents' + AND NOT EXISTS ( + SELECT 1 FROM freight.file_upload_fields f + WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL + ); + -- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing -- hard-blocks any container line without a rate on its exact leg; rates are -- configured in USD and converted to the booking currency). From 2aa405d8e46477671c7a74038b7c29424466cd27 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 22 Jul 2026 11:04:17 +0000 Subject: [PATCH 63/71] chnages --- apps/edr-freight-web/backoffice/src/App.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 15daad79a..d216447f3 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -643,18 +643,6 @@ const filterSidebarByPermission = ( return true; }); - // Recursive: a group's own permission gates the whole subtree, leaves are - // checked individually, and a group with no surviving children disappears. - const filterItems = (items: SidebarItem[]): SidebarItem[] => - items.flatMap((item) => { - if (item.children?.length) { - if (item.permission && !permissionAllowed(item)) return []; - const children = filterItems(item.children); - return children.length ? [{ ...item, children }] : []; - } - return itemAllowed(item) ? [item] : []; - }); - return sections .map((section) => ({ ...section, From 2632e2d50ca8f77b9fb8da86db3abe54299cb70b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 14:06:00 +0300 Subject: [PATCH 64/71] Ticketing updates --- .../src/modules/bookings/bookings.service.ts | 31 +++++++++++-------- .../src/modules/tickets/tickets.service.ts | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2c4d2cee4..8b37f9841 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -1916,21 +1916,26 @@ export class BookingsService { ) { try { await this.ticketsService.generate(booking.id); - // Re-fetch to include the newly created tickets - const refreshed = await this.prisma.booking.findUnique({ - where: { id: booking.id }, - include: { - schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, - paymentIntent: true, tickets: true, - priceTier: { select: { priceMinor: true } }, - }, - }); - if (refreshed) Object.assign(booking, refreshed); } catch (err) { - this.logger.warn(`getByRef: auto-generate tickets failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}`); + this.logger.warn(`getByRef: generate failed for booking ${booking.id}: ${err instanceof Error ? err.message : String(err)}. Trying smart assign.`); + try { + await this.ticketsService.smartAssignAndGenerate(booking.id); + } catch (retryErr) { + this.logger.error(`getByRef: smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`); + } } + // Re-fetch to include any newly created tickets + const refreshed = await this.prisma.booking.findUnique({ + where: { id: booking.id }, + include: { + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } }, + paymentIntent: true, tickets: true, + priceTier: { select: { priceMinor: true } }, + }, + }); + if (refreshed) Object.assign(booking, refreshed); } const outboundSegment = this.resolveSegmentStations( diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 2ba05dd4e..b788c03df 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -360,7 +360,7 @@ export class TicketsService { // blocked by another booking. const seatIds = (booking as any).seats.map((bs: any) => bs.seatId); await this.prisma.seatBlock.deleteMany({ - where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' }, + where: { seatId: { in: seatIds }, blockedBy: 'SYSTEM' }, }); // Check for seat conflicts — only seats confirmed/boarded by a *different* booking From 7ff98bfb8182cd328de80acd3fc894c3a872da27 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 14:28:47 +0300 Subject: [PATCH 65/71] Ticketing seats conflict issue resolution --- .../src/modules/tickets/tickets.service.ts | 133 +++++++++++++----- 1 file changed, 99 insertions(+), 34 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index b788c03df..736b781bd 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -210,15 +210,6 @@ export class TicketsService { }); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); - // Seats taken by other confirmed/boarded bookings on this schedule - const takenByOthers = await this.prisma.bookingSeat.findMany({ - where: { - booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, - seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } }, - }, - select: { seatId: true }, - }).then(rows => new Set(rows.map(r => r.seatId))); - // Seats held by any active SeatHold (not yet expired) const heldSeatIds = await this.prisma.seatHold.findMany({ where: { expiresAt: { gt: new Date() } }, @@ -230,42 +221,62 @@ export class TicketsService { select: { seatId: true }, }).then(rows => new Set(rows.map(r => r.seatId))); - // Union of all unavailable seat IDs (excluding the booking's own seats) const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string)); + const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; + + // Track newly assigned seats so the same seat isn't given to two passengers const unavailableIds = new Set([ - ...[...takenByOthers].filter(id => !ownSeatIds.has(id)), ...[...heldSeatIds], ...[...blockedSeatIds], ]); - const reassigned: { seatNumber: string; newSeatNumber: string }[] = []; - for (const bs of (booking as any).seats) { const originalSeatId: string = bs.seatId; + // Use the per-seat scheduleId — for ROUND_TRIP leg 2 this is the return schedule, + // not booking.scheduleId (the outbound schedule). + const legScheduleId: string = bs.scheduleId ?? booking.scheduleId; - // Case 1: original seat is still free — nothing to do - if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue; + // Seats taken by other confirmed/boarded bookings on THIS leg's schedule + const takenByOthersOnLeg = await this.prisma.bookingSeat.findMany({ + where: { + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + seat: { coach: { assignments: { some: { scheduleId: legScheduleId } } } }, + }, + select: { seatId: true }, + }).then(rows => new Set(rows.map(r => r.seatId))); - // Case 2: original seat is unavailable — find a truly available seat in the same coach type + // Case 1: original seat is still free on this leg — nothing to do + if ( + !takenByOthersOnLeg.has(originalSeatId) && + !heldSeatIds.has(originalSeatId) && + !blockedSeatIds.has(originalSeatId) + ) continue; + + // Case 2: original seat is unavailable — find a free seat of the same coach type on this leg's schedule const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId; + const allUnavailable = new Set([ + ...[...takenByOthersOnLeg].filter(id => !ownSeatIds.has(id)), + ...[...unavailableIds], + ]); + const candidate = await this.prisma.seat.findFirst({ where: { status: 'AVAILABLE', seatNumber: { not: '' }, NOT: [ { seatNumber: { startsWith: '-' } }, - { id: { in: [...unavailableIds] } }, + { id: { in: [...allUnavailable] } }, ], coach: { - assignments: { some: { scheduleId: booking.scheduleId } }, + assignments: { some: { scheduleId: legScheduleId } }, ...(coachTypeId ? { coachTypeId } : {}), }, }, orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }], }); - // Case 3: no seats left in that class + // Case 3: no seats left in that class on this leg if (!candidate) { const className = bs.seat?.coach?.coachType?.name ?? 'the same class'; throw new ConflictException( @@ -278,10 +289,7 @@ export class TicketsService { data: { seatId: candidate.id }, }); - // Mark the newly assigned seat as taken so subsequent passengers in the - // same booking don't get assigned the same seat. unavailableIds.add(candidate.id); - reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber }); } @@ -364,22 +372,79 @@ export class TicketsService { }); // Check for seat conflicts — only seats confirmed/boarded by a *different* booking - // on the same schedule are a real conflict. SeatBlock rows created by a previous - // generate() run for this booking are NOT a conflict; they are cleaned up above. - const conflictingSeats = await this.prisma.bookingSeat.findMany({ + // on the SAME schedule AND with OVERLAPPING segments are a real conflict. + // Segment overlap: two bookings conflict on a seat when their stop-sequence ranges + // overlap: A.originSeq < B.destSeq AND B.originSeq < A.destSeq. + // We resolve sequences via TripStopTime using each booking's originStationId / + // destinationStationId. Bookings with no station IDs (full-route) are treated as + // seq 0 → ∞ and always overlap. + const thisBookingSeats = (booking as any).seats as Array<{ seatId: string; scheduleId: string | null }>; + + // Resolve this booking's stop sequences per leg schedule + const thisSeqMap = new Map(); + const legScheduleIds = [...new Set(thisBookingSeats.map(bs => bs.scheduleId ?? booking.scheduleId))]; + for (const schedId of legScheduleIds) { + const originId = (booking as any).originStationId; + const destId = (booking as any).destinationStationId; + if (!originId || !destId) { + thisSeqMap.set(schedId, { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }); + continue; + } + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: schedId, stationId: { in: [originId, destId] } }, + select: { stationId: true, sequence: true }, + }); + const oStop = stops.find(s => s.stationId === originId); + const dStop = stops.find(s => s.stationId === destId); + thisSeqMap.set(schedId, { + originSeq: oStop?.sequence ?? 0, + destSeq: dStop?.sequence ?? Number.MAX_SAFE_INTEGER, + }); + } + + // Find other confirmed/boarded bookings that share any (seatId, scheduleId) pair + const candidateConflicts = await this.prisma.bookingSeat.findMany({ where: { - seatId: { in: seatIds }, - booking: { - id: { not: bookingId }, - status: { in: ['CONFIRMED', 'BOARDED'] }, - }, + OR: thisBookingSeats.map(bs => ({ + seatId: bs.seatId, + scheduleId: bs.scheduleId ?? booking.scheduleId, + booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } }, + })), + }, + include: { + seat: true, + booking: { select: { id: true, originStationId: true, destinationStationId: true } }, }, - include: { seat: true }, }); - if (conflictingSeats.length > 0) { - const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', '); + + const trueConflicts: string[] = []; + for (const other of candidateConflicts) { + const legScheduleId = other.scheduleId ?? booking.scheduleId; + const thisSeq = thisSeqMap.get(legScheduleId) ?? { originSeq: 0, destSeq: Number.MAX_SAFE_INTEGER }; + + const otherOriginId = (other.booking as any).originStationId; + const otherDestId = (other.booking as any).destinationStationId; + let otherOriginSeq = 0; + let otherDestSeq = Number.MAX_SAFE_INTEGER; + if (otherOriginId && otherDestId) { + const stops = await this.prisma.tripStopTime.findMany({ + where: { scheduleId: legScheduleId, stationId: { in: [otherOriginId, otherDestId] } }, + select: { stationId: true, sequence: true }, + }); + otherOriginSeq = stops.find(s => s.stationId === otherOriginId)?.sequence ?? 0; + otherDestSeq = stops.find(s => s.stationId === otherDestId)?.sequence ?? Number.MAX_SAFE_INTEGER; + } + + // Segments overlap when: thisOrigin < otherDest AND otherOrigin < thisDest + if (thisSeq.originSeq < otherDestSeq && otherOriginSeq < thisSeq.destSeq) { + trueConflicts.push((other as any).seat.seatNumber); + } + } + + if (trueConflicts.length > 0) { + const labels = [...new Set(trueConflicts)].join(', '); throw new ConflictException( - `Seat(s) ${labels} are already confirmed for another booking.`, + `Seat(s) ${labels} are already confirmed for another booking on the same schedule and overlapping segment.`, ); } From c6f6616218813c6e5fe743e4de78eca2ffc2aaaa Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Wed, 22 Jul 2026 14:31:27 +0300 Subject: [PATCH 66/71] Restore ticket generation guard --- .../src/modules/tickets/tickets.controller.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 3a48ca2a1..4ecaeb267 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -34,10 +34,11 @@ export class TicketsController { } @Post('generate/:bookingId') - @SetMetadata('isPublic', true) + @PassengerStaff(PASSENGER_PERMS.tickets.generate) + @ApiBearerAuth('IAM-auth') @ApiOperation({ - summary: 'Generate ticket for booking (confirmation page)', - description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' + summary: 'Generate ticket for booking', + description: 'Creates a ticket for a confirmed booking with succeeded payment. Requires payment to be SUCCEEDED and booking to be CONFIRMED.' }) generateTicket(@Param('bookingId') bookingId: string) { return this.service.generate(bookingId); From 59f06a9bd112eab96b05fd34f2666e57f6e120ef Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 11:59:36 +0000 Subject: [PATCH 67/71] feat(warehouses): enforce yard/zone capacity limits; broaden dispatcher perms Yard create/update now rejects capacities that overflow the parent warehouse, and zone create/update rejects capacities that overflow the parent yard, via BadRequestException. Dispatcher permission preset expanded to full CRUD on warehouse and fleet management; allocation and fee rules remain view-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-yards.service.ts | 50 +++++++++++++++- .../warehouses/warehouse-zones.service.ts | 50 +++++++++++++++- .../src/seed/freight-permissions.registry.ts | 58 +++++++++---------- 3 files changed, 123 insertions(+), 35 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 3279e9092..5b5e2b227 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -44,6 +44,7 @@ export class WarehouseYardsService { // Ensure the parent warehouse exists. await this.warehousesService.findById(warehouseId); await this.assertCodeUnique(warehouseId, dto.code.trim()); + await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.yardsRepository.create({ warehouseId, @@ -69,14 +70,22 @@ export class WarehouseYardsService { await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed warehouse limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.yardsRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -97,4 +106,39 @@ export class WarehouseYardsService { throw new ConflictException(`Yard code ${code} already exists in this warehouse`); } } + + private async assertCapacityWithinWarehouse( + warehouseId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeYardId?: string, + ): Promise { + const warehouse = await this.warehousesService.findById(warehouseId); + const yards = await this.findByWarehouse(warehouseId); + + // Sum existing yard capacities, excluding the yard being updated if provided + const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards; + const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0); + const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && warehouse.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > warehouse.capacityWeight) { + throw new BadRequestException( + `Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && warehouse.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > warehouse.capacityContainers) { + throw new BadRequestException( + `Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index b4ae2e0de..367a5a75e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; @@ -43,6 +43,7 @@ export class WarehouseZonesService { // Ensure the parent yard exists. await this.yardsService.findById(yardId); await this.assertCodeUnique(yardId, dto.code.trim()); + await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.zonesRepository.create({ yardId, @@ -68,14 +69,22 @@ export class WarehouseZonesService { await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed yard limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.zonesRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -96,4 +105,39 @@ export class WarehouseZonesService { throw new ConflictException(`Zone code ${code} already exists in this yard`); } } + + private async assertCapacityWithinYard( + yardId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeZoneId?: string, + ): Promise { + const yard = await this.yardsService.findById(yardId); + const zones = await this.findByYard(yardId); + + // Sum existing zone capacities, excluding the zone being updated if provided + const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones; + const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0); + const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && yard.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > yard.capacityWeight) { + throw new BadRequestException( + `Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && yard.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > yard.capacityContainers) { + throw new BadRequestException( + `Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 1f82f6e93..741ceafb1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -808,41 +808,41 @@ export const POSITION_PERMISSION_PRESETS = { // permission catalog (all CRUD across bookings, contracts, scheduling, // fleet, warehouse, mile, finance, settings, staff). operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), - // Dispatcher: warehouse floor operations — receive/GRN, move, load/unload, - // inspect, dispatch, gate, release/deliver, interchange docs, fee invoices, - // plus truck dispatch on the mile legs and read-only operational context. - // Allocation & fee rules are VIEW-ONLY — never create/update/delete. + // Dispatcher: full CRUD on warehouse management (incl. import/export/intercity + // inventory flows) and fleet management, plus truck dispatch on the mile legs + // and operational context. The ONE carve-out: allocation & fee rules stay + // VIEW-ONLY — a dispatcher never creates/updates/deletes those rules. dispatcher: dedupe([ + // Warehouse management — full CRUD. FREIGHT_PERMS.warehouseDashboard.view, - FREIGHT_PERMS.warehouses.view, - FREIGHT_PERMS.warehouseYards.view, - FREIGHT_PERMS.warehouseZones.view, - FREIGHT_PERMS.warehouseInventory.view, - FREIGHT_PERMS.warehouseInventory.receive, - FREIGHT_PERMS.warehouseInventory.move, - FREIGHT_PERMS.warehouseInventory.load, - FREIGHT_PERMS.warehouseInventory.unload, - FREIGHT_PERMS.warehouseInventory.dispatch, - FREIGHT_PERMS.warehouseInventory.gatePass, - FREIGHT_PERMS.warehouseInventory.release, - FREIGHT_PERMS.warehouseInventory.deliver, - FREIGHT_PERMS.warehouseInventory.inspect, - FREIGHT_PERMS.warehouseInspectionReports.view, - FREIGHT_PERMS.warehouseInspectionReports.create, - FREIGHT_PERMS.warehouseInspectionReports.update, - FREIGHT_PERMS.interchangeDocuments.view, - FREIGHT_PERMS.interchangeDocuments.generate, - FREIGHT_PERMS.interchangeDocuments.acknowledge, - FREIGHT_PERMS.warehouseFeeInvoices.view, - FREIGHT_PERMS.warehouseFeeInvoices.generate, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), // View-only on the rules that govern allocation and fees. FREIGHT_PERMS.warehouseAllocationRules.view, FREIGHT_PERMS.warehouseFeeRules.view, + // Fleet management — full CRUD. + ...Object.values(FREIGHT_PERMS.fleet), + FREIGHT_PERMS.fleetDashboard.view, + ...Object.values(FREIGHT_PERMS.fleetReports), + ...Object.values(FREIGHT_PERMS.vehicles), + ...Object.values(FREIGHT_PERMS.drivers), + ...Object.values(FREIGHT_PERMS.tracking), + ...Object.values(FREIGHT_PERMS.fuel), + ...Object.values(FREIGHT_PERMS.maintenance), + ...Object.values(FREIGHT_PERMS.locomotives), + ...Object.values(FREIGHT_PERMS.wagons), + ...Object.values(FREIGHT_PERMS.trains), + ...Object.values(FREIGHT_PERMS.routes), + ...Object.values(FREIGHT_PERMS.containers), + ...Object.values(FREIGHT_PERMS.cargoes), // Truck dispatch on the EDR mile legs + operational context. - FREIGHT_PERMS.firstMile.view, - FREIGHT_PERMS.firstMile.assignVehicles, - FREIGHT_PERMS.lastMile.view, - FREIGHT_PERMS.lastMile.assignVehicles, + ...Object.values(FREIGHT_PERMS.firstMile), + ...Object.values(FREIGHT_PERMS.lastMile), FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.bookings.operations, ]), From 8dc4dd585ec6fce490916f44c3292af843233ea6 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 22 Jul 2026 11:59:37 +0000 Subject: [PATCH 68/71] feat(procurement): validate acquisition lease fields and enforce bulk-receive capacity Reject lease start/end and monthly payment on PURCHASE acquisitions (create and update, validated against the resulting record). Add asset_acquisitions.item_name column + migration. Enforce warehouse/yard/zone capacity on bulk receive and apply capacity-counter deltas on save. Adds acquisition-guard spec. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2470000000000-AddAcquisitionItemName.ts | 23 ++++ .../procurement/dto/procurement.dto.ts | 11 ++ .../entities/asset-acquisition.entity.ts | 6 + .../procurement.acquisition-guard.spec.ts | 50 +++++++++ .../procurement/procurement.service.ts | 34 +++++- .../warehouses/warehouse-inventory.service.ts | 13 ++- .../src/pages/fleet/ProcurementPage.tsx | 105 +++++++++++------- .../src/services/procurement.service.ts | 1 + 8 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts create mode 100644 apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts index 943d79296..cfab53a5d 100644 --- a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsEnum, IsBoolean, + MinLength, } from 'class-validator'; import { VendorType } from '../entities/vendor.entity'; import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; @@ -72,6 +73,11 @@ export class UpdateVendorDto { } export class CreateAcquisitionDto { + /** WHAT was acquired — required so an acquisition can't be saved empty. */ + @IsString() + @MinLength(2) + itemName!: string; + @IsOptional() @IsUUID() vehicleId?: string; @@ -120,6 +126,11 @@ export class CreateAcquisitionDto { } export class UpdateAcquisitionDto { + @IsOptional() + @IsString() + @MinLength(2) + itemName?: string; + @IsOptional() @IsUUID() vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts index d4f781c15..e1a9f4ff5 100644 --- a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -18,6 +18,12 @@ export enum AcquisitionStatus { @Entity({ name: 'asset_acquisitions', schema: 'freight' }) @Index(['vehicleId', 'acquisitionDate']) export class AssetAcquisition extends BaseEntity { + /** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */ + @Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true }) + itemName?: string; + + /** Optional link — only when the acquisition IS a fleet vehicle. Parts and + * general procurement stay unlinked so reports don't misattribute them. */ @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts new file mode 100644 index 000000000..8004d9d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ProcurementService } from './procurement.service'; +import { AcquisitionType } from './entities/asset-acquisition.entity'; + +// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may. +describe('ProcurementService acquisition lease-field guard', () => { + const repo = { + createAcquisition: jest.fn(async (dto) => dto), + findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })), + updateAcquisition: jest.fn(async (_id, dto) => dto), + }; + const svc = new ProcurementService(repo as never); + + it('rejects a PURCHASE with lease dates', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + } as never), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a LEASE with lease dates and a plain PURCHASE', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Rented crane', + acquisitionType: AcquisitionType.LEASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + leaseEnd: '2027-07-01', + } as never), + ).resolves.toBeDefined(); + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + } as never), + ).resolves.toBeDefined(); + }); + + it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => { + await expect( + svc.updateAcquisition('a1', { monthlyPayment: 500 } as never), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts index e799d5ff9..7095a6a09 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { ProcurementRepository } from './procurement.repository'; import { Vendor } from './entities/vendor.entity'; -import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity'; import { AssetDisposal } from './entities/asset-disposal.entity'; import { CreateVendorDto, @@ -51,7 +51,23 @@ export class ProcurementService { } // ---- Acquisitions ---- + /** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */ + private assertLeaseFieldsValid(dto: { + acquisitionType?: string; + leaseStart?: string; + leaseEnd?: string; + monthlyPayment?: number; + }): void { + if (dto.acquisitionType !== AcquisitionType.PURCHASE) return; + if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) { + throw new BadRequestException( + 'Lease start/end and monthly payment are not valid for a PURCHASE acquisition', + ); + } + } + async createAcquisition(dto: CreateAcquisitionDto): Promise { + this.assertLeaseFieldsValid(dto); return this.procurementRepository.createAcquisition(dto); } @@ -64,6 +80,20 @@ export class ProcurementService { } async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + // Validate against the resulting record, not just the patch — switching an + // acquisition to PURCHASE must also shed any stored lease terms. + const existing = await this.procurementRepository.findAcquisitionById(id); + if (existing) { + const next = { ...existing, ...dto }; + if (next.acquisitionType === AcquisitionType.PURCHASE) { + this.assertLeaseFieldsValid({ + acquisitionType: next.acquisitionType, + leaseStart: dto.leaseStart, + leaseEnd: dto.leaseEnd, + monthlyPayment: dto.monthlyPayment, + }); + } + } return this.procurementRepository.updateAcquisition(id, dto); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index bd702542b..8e9e0bc76 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1578,6 +1578,14 @@ export class WarehouseInventoryService { notes: `Bulk received (${dto.direction})`, truckEntrance, }); + + // Validate capacity before saving + const weight = Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1585,7 +1593,7 @@ export class WarehouseInventoryService { zoneId: dto.zoneId, bookingId, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight: Number(booking.weight) || 0, + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1593,6 +1601,9 @@ export class WarehouseInventoryService { }), ); + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + // Receiving the booking flags every container unit as received into the // port (self-haul export: the delivering truck's goods are now in) so // staff can raise the per-container GRN over what's received. diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx index 0c6bbabf0..584e3e13b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/ProcurementPage.tsx @@ -61,6 +61,7 @@ const clean = >(obj: T): Partial => ) as Partial; const emptyAcquisition = { + itemName: "", vehicleId: "", vendorId: "", acquisitionType: "PURCHASE" as AcquisitionType, @@ -239,6 +240,7 @@ export default function ProcurementPage() { + Item / Asset Vehicle Type Date @@ -249,7 +251,7 @@ export default function ProcurementPage() { {loadingAcquisitions ? ( - + @@ -257,7 +259,7 @@ export default function ProcurementPage() { ) : acquisitions.length === 0 ? ( - + No acquisitions recorded yet. @@ -266,6 +268,7 @@ export default function ProcurementPage() { ) : null} {acquisitions.map((a: AssetAcquisition) => ( + {a.itemName || "—"} {vehicleLabel(a.vehicle, a.vehicleId)} @@ -411,31 +414,51 @@ export default function ProcurementPage() { size="lg" > + setAcqForm({ ...acqForm, itemName: e.currentTarget.value })} + required + /> setAcqForm({ ...acqForm, vendorId: val || "" })} - searchable - clearable - /> + + - setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" }) - } + onChange={(val) => { + const acquisitionType = (val as AcquisitionType) || "PURCHASE"; + // Lease terms are invalid on a purchase — drop them on switch. + setAcqForm( + acquisitionType === "PURCHASE" + ? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined } + : { ...acqForm, acquisitionType }, + ); + }} required /> - setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })} - /> - setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })} - /> - - setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined }) - } - decimalScale={2} - min={0} - /> + {acqForm.acquisitionType !== "PURCHASE" && ( + <> + setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })} + /> + setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })} + /> + + setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined }) + } + decimalScale={2} + min={0} + /> + + )}