diff --git a/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts new file mode 100644 index 000000000..5468e2207 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Locomotive names must be unique so staff can identify a unit by name alone + * (the card view leads with `name`, falling back to `code`). Uniqueness is: + * + * - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name; + * - scoped to live rows — a decommissioned (soft-deleted) locomotive must not + * hold its name hostage, matching how the fleet reuses yard codes; + * - skipped for blank names — `name` stays optional, and NULL/'' rows are + * excluded rather than colliding with each other. + * + * A partial expression index gives all three; a plain UNIQUE column cannot. + */ +export class UniqueLocomotiveName2430000000000 implements MigrationInterface { + name = 'UniqueLocomotiveName2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every + // copy after the oldest (…-2, …-3) so the index can build; the oldest row + // keeps the original name. Deterministic on created_at, then id. + await queryRunner.query(` + WITH ranked AS ( + SELECT + id, + name, + row_number() OVER ( + PARTITION BY lower(btrim(name)) + ORDER BY created_at, id + ) AS rn + FROM "freight"."locomotives" + WHERE deleted_at IS NULL + AND name IS NOT NULL + AND btrim(name) <> '' + ) + UPDATE "freight"."locomotives" AS l + SET name = btrim(ranked.name) || '-' || ranked.rn + FROM ranked + WHERE l.id = ranked.id + AND ranked.rn > 1 + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active" + ON "freight"."locomotives" (lower(btrim("name"))) + WHERE "deleted_at" IS NULL + AND "name" IS NOT NULL + AND btrim("name") <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`, + ); + // The de-duplicating renames are not reversed: the original names are no + // longer recoverable, and restoring them would re-introduce the conflict. + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index d3214b6c4..d7a875c24 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -27,6 +27,13 @@ export class Locomotive extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; + /** + * Optional, but unique when set. Enforced in the DB by the partial expression + * index `UQ_locomotives_name_active` (see UniqueLocomotiveName2430000000000): + * case- and whitespace-insensitive, live rows only, blanks exempt. Not a + * `unique: true` column — that would be case-sensitive and would let a + * soft-deleted locomotive keep holding its name. + */ @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) name?: string | null; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index af2a40f50..18a42205e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository { ) { super(repository); } + + /** + * A live locomotive already holding this name, compared the same way the + * `UQ_locomotives_name_active` index compares: case- and whitespace- + * insensitive, soft-deleted rows excluded. `excludeId` skips the row being + * updated so it can keep its own name. + */ + findByName(name: string, excludeId?: string): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .where('lower(btrim(locomotive.name)) = lower(btrim(:name))', { name }); + + if (excludeId) { + qb.andWhere('locomotive.id != :excludeId', { excludeId }); + } + + // createQueryBuilder already filters soft-deleted rows (no withDeleted()). + return qb.getOne(); + } } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 7c0e973e5..cbf9dfc0c 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -60,6 +60,23 @@ export class LocomotivesService { return `LOCO-${String(max + 1).padStart(3, '0')}`; } + /** + * Reject a name already worn by another live locomotive. Compared + * case-insensitively on the trimmed value so this matches the DB index + * `UQ_locomotives_name_active` — otherwise a clash the guard waved through + * would surface as a raw 500 from the index instead of a 409. `excludeId` + * lets an update keep its own name. + */ + private async assertNameAvailable(name: string, excludeId?: string): Promise { + const clash = await this.locomotivesRepository.findByName(name, excludeId); + + if (clash) { + throw new ConflictException( + `Locomotive name "${name.trim()}" is already used by ${clash.code}`, + ); + } + } + async create(dto: CreateLocomotiveDto): Promise { const code = dto.code?.trim() || (await this.generateCode()); @@ -68,9 +85,15 @@ export class LocomotivesService { throw new ConflictException(`Locomotive code ${code} already exists`); } + // Name stays optional; only a non-blank one has to be unique. + const name = dto.name?.trim() || null; + if (name) { + await this.assertNameAvailable(name); + } + return this.locomotivesRepository.create({ code, - name: dto.name?.trim() || null, + name, locomotiveType: dto.locomotiveType as LocomotiveType, status: dto.status as LocomotiveStatus, currentYardId: dto.currentYardId ?? null, @@ -107,6 +130,15 @@ export class LocomotivesService { } } + // Only when the caller actually sends a name — an omitted field keeps the + // current one, and clearing it to blank is allowed. + if (dto.name !== undefined) { + const nextName = dto.name?.trim() || null; + if (nextName) { + await this.assertNameAvailable(nextName, id); + } + } + // A locomotive coupled to a built train follows the train: its yard and // status are owned by the train-builder flow, not this generic PATCH. const link = await this.findTrainLink(id); diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 67841e5f1..c10b1b033 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -205,6 +205,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; const otherCount = otherWagons.length; + // Split "Other" so a coupled wagon is visible as such. The Available/Assigned + // buckets deliberately count only UNCOUPLED wagons (see above), so a yard + // holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" — + // accurate for shunting, but unreadable unless the other 53 are named. + const onTrainCount = useMemo( + () => matching.filter((w) => w.trainId != null).length, + [matching], + ); const destinationYardOptions = useMemo( () => @@ -397,9 +405,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro - - - {otherCount > 0 ? : null} + + + {onTrainCount > 0 ? ( + + ) : null} + {otherCount - onTrainCount > 0 ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css new file mode 100644 index 000000000..d5bb8df5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css @@ -0,0 +1,84 @@ +/* + * Scoped to .edr-booking-requests-table — the DataTable container div on the + * backoffice booking-requests list only; no other DataTable is affected. + * Mirrors the portal's /bookings table (bookings-table.css): horizontal + * scroll on the container, a sticky header row, and a sticky/shadowed + * action column — with a compact 40–60px column width band (content + * beyond that is clipped with an ellipsis) instead of the portal's + * content-sized columns. + */ +.edr-booking-requests-table { + overflow-x: auto; +} + +/* + * width: max-content — the table is exactly as wide as its columns need, + * never squeezed to fit the viewport; the container scrolls instead. + * min-width: 100% keeps it filling the card when content is narrow. + */ +.edr-booking-requests-table table { + table-layout: auto; + width: max-content; + min-width: 100%; +} + +/* Compact column band: 40px floor, 60px ceiling, ellipsis past that. */ +.edr-booking-requests-table th, +.edr-booking-requests-table td:not([colspan]) { + min-width: 40px; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * Full-width rows (loading skeleton / error / empty state) span every + * column via colspan — leave their sizing and wrapping alone. + */ +.edr-booking-requests-table td[colspan] { + max-width: none; + white-space: normal; +} + +/* Sticky header row. */ +.edr-booking-requests-table thead th { + position: sticky; + top: 0; + z-index: 1; +} + +/* + * Fixed, sticky action column. Overrides the inline width DataTable stamps + * from tanstack's column size (`size: 140` on the actions column) — hence + * !important. `:not([colspan])` keeps the full-width error/empty rows out. + */ +.edr-booking-requests-table th:last-child, +.edr-booking-requests-table td:last-child:not([colspan]) { + width: 60px !important; + min-width: 60px; + max-width: 60px; + position: sticky; + right: 0; + box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3); +} + +/* + * Sticky cells sit above the scrolling ones, so they need their own opaque + * background or the columns underneath show through. + */ +.edr-booking-requests-table td:last-child:not([colspan]) { + background: #f5f8fb; + z-index: 2; +} + +/* Row hover uses the tailwind `hover:bg-accent` class on the . */ +.edr-booking-requests-table tbody tr:hover td:last-child:not([colspan]) { + background: var(--accent, #f4fbf8); +} + +/* Header cell is sticky on both axes — it must outrank the body's sticky column. */ +.edr-booking-requests-table th:last-child { + background: #f4f7fa; + z-index: 3; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index caa0ee6c4..3487cbed8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -128,6 +128,8 @@ const LOCOMOTIVE_STATUS_OPTIONS = [ { label: "Out of service", value: "OUT_OF_SERVICE" }, ]; +// Every status a wagon can hold — for FILTERING the list. ASSIGNED belongs here: +// staff still need to search for assigned wagons. const WAGON_STATUS_OPTIONS = [ { label: "Available", value: Freight.WagonStatus.Available }, { label: "Assigned", value: Freight.WagonStatus.Assigned }, @@ -135,6 +137,15 @@ const WAGON_STATUS_OPTIONS = [ { label: "Detained", value: Freight.WagonStatus.Detained }, ]; +// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted +// on purpose: a wagon becomes ASSIGNED as a side effect of being built into a +// train, never by editing it directly. Setting it by hand produced wagons that +// claim to be assigned while coupled to nothing, which the yard workspace then +// counts as in-yard stock. +const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter( + (o) => o.value !== Freight.WagonStatus.Assigned, +); + @@ -333,7 +344,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, - { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, + { name: "status", label: "Status", type: "select", required: true, options: WAGON_EDITABLE_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index aae182103..6fbc32cce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -20,7 +20,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { isAxiosError } from "axios"; import { ArrowRight, - Ban, + // Ban, — used only by the commented-out "Cancel schedule" row action CalendarClock, Clock, Eye, @@ -196,7 +196,7 @@ export default function TrainScheduleV2ListPage() { }), ); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); - const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); + // const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the // API rejects them, so keep them out of the picker entirely. @@ -464,6 +464,9 @@ export default function TrainScheduleV2ListPage() { Booking window settings ) : null} + {/* Cancel schedule — hidden for now (frontend only; the + cancelSchedule mutation is untouched). Restore by + uncommenting. {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( ) : null} + */} @@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() { }, }, ]; - }, [navigate, cancel.isPending, cancel, toast]); + }, [navigate, toast]); const handleCreate = async () => { if (!routeId || !scheduleDate || !trainId) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx index 0599d685e..6b4c8927d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -21,6 +21,9 @@ import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow"; import { CardTitle, PageShell, SectionCard } from "./components/layout"; import { BodyGrid } from "./components/layout"; +import { CompanyInfoCard } from "./components/CompanyInfoCard"; +import { ContainersCard } from "./components/ContainersCard"; +import { ContractInfoCard } from "./components/ContractInfoCard"; import { ActionRequiredBanner, MutationErrors } from "./components/Notices"; import { PageHeader } from "./components/PageHeader"; import { EstimateCard } from "./components/pricing"; @@ -94,6 +97,10 @@ export function ChangesRequestedView({ <> + + + + Your documents @@ -143,6 +150,7 @@ export function ChangesRequestedView({ chip="Not invoiced" /> + setCancelDialogOpen(true)} /> } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index d372f6e37..277bd1a24 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -29,6 +29,9 @@ import type { Freight } from "@edr/types"; import { REQUIRED_DOC_FIELDS } from "./constants"; import { CardTitle, PageShell, SectionCard } from "./components/layout"; +import { CompanyInfoCard } from "./components/CompanyInfoCard"; +import { ContainersCard } from "./components/ContainersCard"; +import { ContractInfoCard } from "./components/ContractInfoCard"; import { CountChip, DocRow, IconSquare } from "./components/Documents"; import { EstimateCard } from "./components/pricing"; import { HeaderButton, PageHeader } from "./components/PageHeader"; @@ -241,6 +244,10 @@ export function DraftBookingView({ + + + + {/* Documents (uploadable) */} @@ -399,6 +406,7 @@ export function DraftBookingView({ chip="Not invoiced" /> + setCancelDialogOpen(true)} /> } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 244a38739..fc701b924 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -16,8 +16,10 @@ import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { DocumentsTab } from "./components/DocumentsTab"; +import { CompanyInfoCard } from "./components/CompanyInfoCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; +import { ContractInfoCard } from "./components/ContractInfoCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; import { MileSummaryCard } from "./components/MileSummaryCard"; @@ -271,6 +273,8 @@ export function ReadonlyBookingView({ + + {canAssignCustomerTruck && ( @@ -300,6 +304,7 @@ export function ReadonlyBookingView({ title="Consignment & Schedule" consignment /> + } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts new file mode 100644 index 000000000..7479e758d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts @@ -0,0 +1,93 @@ +import type { Freight } from "@edr/types"; + +/** + * `GET /api/bookings/:id` serializes the raw TypeORM `Booking` entity with its + * relations attached (company, bookingContainers → units, shippingLine, + * cargoType…). That's a strict superset of the `Freight.IBooking` DTO, which + * doesn't declare these relations (and still lists a couple of fields — + * `freightSubtype`, the string-enum `serviceType` — that the API never + * actually sends). This augments the shared type with what the endpoint + * really returns so the detail page can render it without unsafe casts. + */ + +export interface BookingContainerUnitDetail { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number | string; + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + receivedToPort?: boolean; + receivedAt?: string | null; + grnNumber?: string | null; + sortOrder?: number; +} + +export interface BookingContainerLineDetail { + id: string; + quantity: number; + vgmPerUnitTons: number | string; + totalVgmTons: number | string; + hazardousQuantity?: number; + reeferQuantity?: number; + returnQuantity?: number; + isOverweight?: boolean; + overweightExcessTons?: number | string | null; + containerNumber?: string | null; + containerType?: { + code: string; + label?: string | null; + sizeFt?: number | null; + isReefer?: boolean | null; + } | null; + units?: BookingContainerUnitDetail[]; +} + +export interface BookingShippingLineDetail { + code: string; + label: string; + showExtraFeeNotice?: boolean; +} + +export interface BookingCargoTypeDetail { + code: string; + cargoTypeName: string; + unitOfMeasure?: string | null; +} + +/** The API's real (object) shape for the joined service-type relation. */ +export type BookingServiceTypeRef = NonNullable; + +export type BookingDetail = Freight.IBooking & { + /** Billed-to company relation, always joined on the detail endpoint. */ + company?: Freight.BookingRequestCompany | null; + isGovernment?: boolean; + governmentInstitution?: string | null; + /** Real container line-items (with per-unit numbers/seals/VGM). */ + bookingContainers?: BookingContainerLineDetail[] | null; + shippingLine?: BookingShippingLineDetail | null; + cargoType?: BookingCargoTypeDetail | null; + cargoFreeText?: string | null; + /** Joined paired-booking relation (consolidation partner), not just its id. */ + consolidationPartner?: { + id: string; + reference: string; + status: string; + } | null; +}; + +/** + * `booking.serviceType` is declared as the legacy `"RAIL_ONLY" | + * "RAIL_AND_FORWARDING"` string enum on `Freight.IBooking`, but the API + * actually sends the joined ServiceType relation object (`{ code, + * serviceName, includesFirstMile, includesLastMile, includesCustoms, … }`). + * Read it through this helper instead of comparing directly — see + * `serviceTypeLabel()` in `utils.ts`. + */ +export function rawServiceType( + booking: BookingDetail, +): string | BookingServiceTypeRef | null | undefined { + return (booking as unknown as { serviceType?: string | BookingServiceTypeRef | null }) + .serviceType; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx new file mode 100644 index 000000000..bdcdb71fd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx @@ -0,0 +1,117 @@ +import { Box, Divider, Group, Text } from "@mantine/core"; +import { Building2, Landmark, Mail, MapPin, Phone, User } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { BookingDetail } from "../booking-detail-types"; +import { CardTitle, SectionCard } from "./layout"; + +function InfoRow({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} + +/** + * Customer/company information billed on this booking — the joined + * `company` relation the detail endpoint always returns (name, TIN, contact + * details), which the previous UI never surfaced at all. + */ +export function CompanyInfoCard({ booking }: { booking: BookingDetail }) { + const company = booking.company; + if (!company) return null; + + const contact = company.contactPersonName + ? company.contactPersonPhone + ? `${company.contactPersonName} · ${company.contactPersonPhone}` + : company.contactPersonName + : null; + + return ( + + + Customer Information + {booking.isGovernment && ( + + + Government + + )} + + + {company.name || "—"} + + {booking.governmentInstitution && ( + + {booking.governmentInstitution} + + )} + + + + + {company.tin && ( + } label="TIN" value={company.tin} /> + )} + {company.email && ( + } label="Email" value={company.email} /> + )} + {company.phone && ( + } label="Phone" value={company.phone} /> + )} + {company.address && ( + } label="Address" value={company.address} /> + )} + {contact && ( + } label="Contact person" value={contact} /> + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx index 75fe65ec7..abf9e72eb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx @@ -1,22 +1,99 @@ import { Box, Group, Table, Text } from "@mantine/core"; +import { AlertTriangle, Flame, Snowflake, Undo2 } from "lucide-react"; +import type { ReactNode } from "react"; -import type { Freight } from "@edr/types"; - +import type { + BookingContainerLineDetail, + BookingContainerUnitDetail, + BookingDetail, +} from "../booking-detail-types"; +import { fmtWeight, totalVgmTons } from "../utils"; import { CardTitle, SectionCard } from "./layout"; -/** - * Per-container-type breakdown for container bookings (count, type, VGM). - * Renders nothing for bulk bookings, which have no container lines. - */ -export function ContainersCard({ booking }: { booking: Freight.IBooking }) { - const containers = booking.containers ?? []; - if (booking.freightType === "BULK" || containers.length === 0) return null; +function containerTypeLabel(line: BookingContainerLineDetail): string { + const t = line.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`; + return t?.code ?? "Container"; +} - const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0); - const totalVgm = containers.reduce( - (sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0), - 0, +function Flag({ icon, label }: { icon: ReactNode; label: string }) { + return ( + + {icon} + {label} + ); +} + +function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) { + return ( + + + + {unit.containerNumber} + + + + + {unit.sealNumber || "—"} + + + + + {Number(unit.vgmTons || 0) ? `${Number(unit.vgmTons).toLocaleString()} t` : "—"} + + + + + {unit.isHazardous && ( + } label="Hazardous" /> + )} + {unit.isReefer && } label="Reefer" />} + {unit.isReturn && } label="Return" />} + {!unit.isHazardous && !unit.isReefer && !unit.isReturn && ( + + — + + )} + + + + + {unit.receivedToPort ? "Received" : "Pending"} + + + + ); +} + +/** + * Per-container breakdown for container bookings — real per-line data + * (`bookingContainers`, joined with per-unit numbers/seals/VGM) rather than + * the legacy `booking.containers` DTO shape, which the detail endpoint + * never populates. Renders nothing for bulk bookings. + */ +export function ContainersCard({ booking }: { booking: BookingDetail }) { + const lines = booking.bookingContainers ?? []; + if (booking.freightType === "BULK" || lines.length === 0) return null; + + const totalUnits = lines.reduce((sum, c) => sum + Number(c.quantity || 0), 0); + const totalVgm = totalVgmTons(booking); + const allUnits = lines.flatMap((l) => l.units ?? []); return ( @@ -27,7 +104,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) { - +
Type @@ -39,28 +116,39 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) { - {containers.map((c, i) => { - const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0); + {lines.map((c, i) => { + const lineVgm = Number(c.totalVgmTons || 0); return ( - + - - {c.type} - + + + {containerTypeLabel(c)} + + {c.isOverweight && ( + } label="Overweight" /> + )} + {!!c.hazardousQuantity && ( + } label={`${c.hazardousQuantity} hazardous`} /> + )} + {!!c.reeferQuantity && ( + } label={`${c.reeferQuantity} reefer`} /> + )} + - {c.qty} + {c.quantity} - {c.vgm ? `${c.vgm} t` : "—"} + {fmtWeight(Number(c.vgmPerUnitTons || 0))} - {lineVgm ? `${lineVgm.toLocaleString()} t` : "—"} + {fmtWeight(lineVgm)} @@ -69,6 +157,30 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
+ {allUnits.length > 0 && ( + + + Container numbers + + + + + Container no. + Seal no. + VGM + Flags + Port status + + + + {allUnits.map((u) => ( + + ))} + +
+
+ )} + - {totalVgm.toLocaleString()} t + {fmtWeight(totalVgm)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx new file mode 100644 index 000000000..f86925fc6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx @@ -0,0 +1,196 @@ +import { Anchor, Box, Divider, Group, Text } from "@mantine/core"; +import { FileText, Link2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import type { BookingDetail } from "../booking-detail-types"; +import { fmtDate } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +function Field({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +function Row({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Contract terms for this booking — validity window, financial terms, + * customs/currency, renewal chain — none of which the detail page surfaced + * before even though the booking always carries them. + */ +export function ContractInfoCard({ booking }: { booking: BookingDetail }) { + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; + const hasValidity = booking.contractValidFrom || booking.contractValidUntil; + + return ( + + + Contract Information + {booking.contractId && ( + + View full contract → + + )} + + + + + + {booking.contractReference ?? booking.reference} + + ) : ( + (booking.contractReference ?? booking.reference) + ) + } + /> + + + + + + + + + {hasValidity && ( + + + + + )} + + {isGeneralContract && (booking.startDate || booking.endDate) && ( + + + + + )} + + {isGeneralContract && booking.expiresAt && ( + + + + + )} + + {booking.customsClearingEnabled && ( + + + + + )} + + {booking.previousContractId && ( + + + + + Previous contract + + + } + /> + + + )} + + + {booking.financialTerms && ( + <> + + + Financial terms + + + {booking.financialTerms} + + + )} + + {booking.contractSummary && ( + <> + + + + + Contract summary + + + + {booking.contractSummary} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx index 7a6323a58..fc7935962 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -1,11 +1,10 @@ import { Box, Group, Text } from "@mantine/core"; import type { ReactNode } from "react"; -import type { Freight } from "@edr/types"; - import { bookingStatusLabel } from "@/pages/bookings/booking-display"; -import { fmtDate, isDraftLike, isNegative } from "../utils"; +import type { BookingDetail } from "../booking-detail-types"; +import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils"; import { CardTitle, SectionCard } from "./layout"; type Row = { label: string; value: ReactNode; muted?: boolean }; @@ -50,14 +49,11 @@ export function ScheduleCard({ title, consignment, }: { - booking: Freight.IBooking; + booking: BookingDetail; title: string; consignment?: boolean; }) { - const service = - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only"; + const service = serviceTypeLabel(booking); const equipmentReturn = booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"; const assignedTrain: Row = { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx index d15730541..8051e9986 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -1,12 +1,45 @@ import { Box, Group, Text } from "@mantine/core"; import { FileText } from "lucide-react"; -import type { Freight } from "@edr/types"; - -import { containerSummary, fmtDate, yardLabel } from "../utils"; +import type { BookingDetail } from "../booking-detail-types"; +import { + commodityLabel, + containerSummary, + fmtDate, + fmtWeight, + serviceTypeLabel, + shippingLineLabel, + totalVgmTons, + yardLabel, +} from "../utils"; import { CardTitle, SectionCard } from "./layout"; -export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) { +function Badge({ label, tone }: { label: string; tone: "amber" | "blue" }) { + const palette = + tone === "amber" + ? { bg: "#FFFBEB", border: "#FDE68A", color: "#92400E" } + : { bg: "#EAF1FE", border: "#CFDDFB", color: "#1E40AF" }; + return ( + + {label} + + ); +} + +export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) { + const weight = totalVgmTons(booking); const rows: [string, string][][] = [ [ ["Origin yard", yardLabel(booking.originYard)], @@ -14,22 +47,14 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) ], [ ["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"], - ["Commodity", booking.freightSubtype || "—"], + ["Commodity", commodityLabel(booking)], ], [ ["Containers / load", containerSummary(booking)], - [ - "Total weight (VGM)", - booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—", - ], + ["Total weight (VGM)", fmtWeight(weight)], ], [ - [ - "Service type", - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only", - ], + ["Service type", serviceTypeLabel(booking)], [ "Equipment return", booking.equipmentReturn === "WITH_RETURN" @@ -44,32 +69,49 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) ], ["Scheduled date", fmtDate(booking.scheduledDate)], ], - [["Assigned train", booking.trainId ?? "Not yet assigned"]], + [ + ["Shipping line", shippingLineLabel(booking)], + ["Assigned train", booking.trainId ?? "Not yet assigned"], + ], ]; + const badges: string[] = []; + if (booking.isHazardous) badges.push("Hazardous"); + if (booking.isRefrigerated) badges.push("Refrigerated"); + if (booking.customsClearingEnabled) badges.push("Customs clearance"); + return ( Shipment Details - - - {booking.contractType === "RENEWAL" - ? "Renewal contract" - : "New contract"} + + {badges.map((b) => ( + + ))} + + + {booking.contractType === "RENEWAL" + ? "Renewal contract" + : "New contract"} + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 432dcf8f5..924ee16a4 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -2,6 +2,8 @@ import { format } from "date-fns"; import type { Freight } from "@edr/types"; +import { rawServiceType, type BookingDetail } from "./booking-detail-types"; + export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; export const isDraftLike = (s: string) => s === "DRAFT" || s === "CHANGES_REQUESTED"; @@ -37,17 +39,105 @@ export function yardLabel(y?: Freight.IBooking["originYard"]) { return y?.label ?? y?.code ?? "—"; } -export function containerSummary(b: Freight.IBooking) { +function containerLineLabel(c: NonNullable[number]) { + const t = c.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""}`; + return t?.code ?? "Container"; +} + +/** + * The real per-line container data lives on `bookingContainers` (joined + * relation, with per-unit numbers + VGM) — `booking.containers` is a + * frontend-only DTO shape the create/update flows use that the detail + * endpoint never populates, so it's kept only as a last-resort fallback. + */ +export function containerSummary(b: BookingDetail) { + const lines = b.bookingContainers ?? []; + if (lines.length > 0) { + return lines.map((c) => `${c.quantity} × ${containerLineLabel(c)}`).join(", "); + } if (b.containers?.length) { return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", "); } return b.freightType === "BULK" ? "Bulk cargo" : "—"; } -export function bookingSubtitle(b: Freight.IBooking) { - const cargo = +/** + * Total shipped weight (VGM), in tons. Container bookings compute the real + * total from `bookingContainers[].totalVgmTons` (per-line quantity × VGM) + * because `cargoTotalWeightVgm` is often left at 0 for container freight — + * the VGM is captured per container, not as a single booking-level figure. + * Falls back to `cargoTotalWeightVgm` for bulk freight / legacy rows. + */ +export function totalVgmTons(b: BookingDetail): number { + const lines = b.bookingContainers ?? []; + if (lines.length > 0) { + const sum = lines.reduce((s, c) => s + Number(c.totalVgmTons || 0), 0); + if (sum > 0) return sum; + } + if (b.containers?.length) { + const sum = b.containers.reduce( + (s, c) => s + Number(c.vgm || 0) * Number(c.qty || 0), + 0, + ); + if (sum > 0) return sum; + } + return Number(b.cargoTotalWeightVgm || 0); +} + +export function fmtWeight(tons: number): string { + return tons > 0 ? `${tons.toLocaleString(undefined, { maximumFractionDigits: 3 })} t` : "—"; +} + +/** + * `booking.serviceType` is declared as the legacy "RAIL_ONLY" | + * "RAIL_AND_FORWARDING" string on the shared type, but the API sends the + * joined ServiceType relation object. Handle both shapes, with a fallback + * derived from the first/last-mile addresses when neither is present. + */ +export function serviceTypeLabel(b: BookingDetail): string { + const st = rawServiceType(b); + if (st && typeof st === "object") { + if (st.serviceName) return st.serviceName; + if (st.code) { + return st.code + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()); + } + } + if (typeof st === "string") { + return st === "RAIL_AND_FORWARDING" ? "Rail + Forwarding" : "Rail only"; + } + return b.firstMilePickupAddress || b.lastMileDeliveryAddress + ? "Rail + Forwarding" + : "Rail only"; +} + +/** Real commodity name from the joined cargo type, falling back to the + * free-text commodity entered at booking time. `freightSubtype` is a + * legacy field the API no longer sends. */ +export function commodityLabel(b: BookingDetail): string { + return ( + b.cargoType?.cargoTypeName || + b.cargoFreeText || b.freightSubtype || - (b.freightType === "BULK" ? "Bulk freight" : "Container freight"); + "—" + ); +} + +export function shippingLineLabel(b: BookingDetail): string { + return b.shippingLine?.label || b.shippingLine?.code || "—"; +} + +export function bookingSubtitle(b: BookingDetail) { + const cargo = + commodityLabel(b) !== "—" + ? commodityLabel(b) + : b.freightType === "BULK" + ? "Bulk freight" + : "Container freight"; const load = containerSummary(b); const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`; return [cargo, load, route].filter((p) => p && p !== "—").join(" · "); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/bookings-table.css b/apps/edr-freight-web/portal/src/pages/bookings/bookings-table.css index 043bc58fc..0ecc73e03 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/bookings-table.css +++ b/apps/edr-freight-web/portal/src/pages/bookings/bookings-table.css @@ -4,7 +4,7 @@ * content-sized columns with a 40px floor, horizontal scroll when the table * outgrows the card, and a sticky shadowed action column. */ -.edr-bookings-table { + .edr-bookings-table { overflow-x: auto; } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index 866104cda..1c7dfdc7a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -344,6 +344,11 @@ export function Step2ServiceType({ if (isIntercity && form.getValues("paymentCurrency") !== "ETB") { form.setValue("paymentCurrency", "ETB", { shouldValidate: true }); } + // The customs clearing agent field is hidden for intercity — drop any value + // carried over from a draft or an operation-type switch. + if (isIntercity && form.getValues("customsClearingAgent")) { + form.setValue("customsClearingAgent", "", { shouldDirty: true }); + } }, [isIntercity, form]); return ( @@ -538,7 +543,9 @@ export function Step2ServiceType({ )} - {includesCustoms ? ( + {/* Intercity (domestic) moves never cross a border, so no customs + clearing agent is collected. */} + {isIntercity ? null : includesCustoms ? ( { + private async resolveBookingTotal(booking: { + id: string; + totalMinor: number; + bookingType: string; + packageId?: string | null; + priceTierId?: string | null; + displayTotalMinor?: number | null; + }): Promise { if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') { return booking.totalMinor; } - // For package round-trip bookings, recompute from the tier price to handle - // bookings created before the server fix stored the full round-trip total. + // New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their + // totalMinor was already computed in ETB at creation time — no recomputation needed. + if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) { + return booking.totalMinor; + } + // Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier. const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } }); if (!tier) return booking.totalMinor; - // Count adults and children from booking seats const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } }); const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1; const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length; - const adultFareMinor = tier.priceMinor * 2; // round-trip = 2 legs + // tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is + // always in the same units as totalMinor (which is always the ETB canonical). + const rawFare = tier.priceMinor * 2; + const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB' + ? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any) + : rawFare; const childFareMinor = Math.round(adultFareMinor * 0.1); - const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor; - // If stored total already matches the correct round-trip total, use it as-is. - // If it's roughly half (single-leg), use the recomputed value. - return correctTotal; + return adultCount * adultFareMinor + childCount * childFareMinor; } async initiatePayment( diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 8dceea63a..7df394090 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -47,6 +47,23 @@ export class ReportsController { return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search }); } + @Get("payments") + @ApiOperation({ summary: "Payments collected for a schedule" }) + getPaymentsReport(@Query('scheduleId') scheduleId: string) { + return this.service.getPaymentsReport(scheduleId); + } + + @Get("payments/discrepancy") + @ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" }) + getPaymentDiscrepancyBySchedule( + @Query('scheduleId') scheduleId: string, + @Query('search') search?: string, + @Query('seatClass') seatClass?: string, + @Query('sort') sort?: string, + ) { + return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); + } + @Get(":reportId") @ApiOperation({ summary: "Get report by ID" }) getReport(@Param("reportId") reportId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 6236422bb..9460aa5a9 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -412,20 +412,27 @@ export class ReportsService { } async listSchedulesForPicker() { + const now = new Date(); const schedules = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: now } }, select: { id: true, departureAt: true, + isPackageOnly: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, }, - orderBy: { departureAt: "desc" }, + orderBy: { departureAt: 'asc' }, take: 200, }); return schedules.map((s) => ({ id: s.id, - label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`, + departureAt: s.departureAt, + isPackage: s.isPackageOnly, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${ + s.isPackageOnly ? ' (package)' : '' + }`, })); } @@ -684,10 +691,11 @@ export class ReportsService { const paidMinor = pi.amountMinor; const paidCurrency = pi.currency; - // b.totalMinor is always in ETB. Convert the paid amount to ETB for an - // apples-to-apples comparison regardless of which currency was used at checkout. + // b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount + // (the gateway receives major units — displayMinorToChargeMajor divides by 100 before + // sending). Multiply by 100 to convert back to minor before the ETB comparison. const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor, paidCurrency); + const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); const balanceMinor = owedEtb - paidEtb; const balanceCurrency = 'ETB'; @@ -795,7 +803,7 @@ export class ReportsService { const paidCurrency = pi?.currency ?? b.currency; const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor, paidCurrency); + const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); const balanceMinor = owedEtb - paidEtb; const balanceCurrency = 'ETB'; @@ -843,6 +851,160 @@ export class ReportsService { })); } + async getPaymentsReport(scheduleId: string) { + const bookings = await this.prisma.booking.findMany({ + where: { + scheduleId, + status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + include: { + paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } }, + seats: { + where: { leg: 1 }, + select: { + passengerName: true, + fareMinor: true, + passengerCategory: true, + seatLabelSnapshot: true, + seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + }, + }, + passenger: { select: { user: { select: { phone: true, fullName: true } } } }, + }, + }); + + const rows = bookings.map(b => { + const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); + const paidMinor = Math.round(b.paymentIntent!.amountMinor); + return { + bookingRef: b.bookingRef, + passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—', + phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', + method: b.paymentIntent!.method, + paidAt: b.paymentIntent!.paidAt, + actualMinor, + paidMinor, + currency: 'ETB', + passengerCount: b.seats.length, + }; + }); + + const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0); + const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0); + + const byMethod = rows.reduce((acc, r) => { + acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor; + return acc; + }, {} as Record); + + return { totalActualMinor, totalPaidMinor, byMethod, rows }; + } + + async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { + search?: string; + seatClass?: string; + sort?: string; + }) { + const bookings = await this.prisma.booking.findMany({ + where: { + scheduleId, + status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + include: { + paymentIntent: { select: { amountMinor: true, currency: true } }, + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + package: { select: { id: true } }, + seats: { + where: { leg: 1 }, + orderBy: [ + { seat: { coach: { number: 'asc' as const } } }, + { seat: { seatNumber: 'asc' as const } }, + ], + select: { + passengerName: true, + passengerCategory: true, + seatLabelSnapshot: true, + fareMinor: true, + seat: { + select: { + seatNumber: true, + bedPosition: true, + coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } }, + }, + }, + }, + }, + passenger: { select: { user: { select: { phone: true, fullName: true } } } }, + }, + }); + + const resolveSeatClass = (seat: any): string => { + const classes = seat?.coach?.coachType?.seatClasses ?? []; + const matched = seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? 'Unknown'; + }; + + let rows = bookings.map(b => { + const pi = b.paymentIntent!; + const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); + // pi.amountMinor is a Float in full currency units — convert to cents once + const paidMinorCents = Math.round(pi.amountMinor * 100); + + const isPackage = !!(b as any).package; + const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor; + const effectiveVarianceMinor = effectiveActualMinor - paidMinorCents; + + const breakdown = b.seats.map(s => ({ + passengerName: s.passengerName ?? '—', + seatClass: resolveSeatClass(s.seat), + coachNumber: s.seat?.coach?.number ?? null, + seatNumber: s.seat?.seatNumber ?? null, + fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0), + })); + + const firstSeat = b.seats[0]; + return { + bookingRef: b.bookingRef, + isPackage, + seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—', + coachNumber: firstSeat?.seat?.coach?.number ?? null, + seatNumber: firstSeat?.seat?.seatNumber ?? null, + origin: b.schedule.originStation.name, + destination: b.schedule.destinationStation.name, + phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', + actualMinor: effectiveActualMinor, + paidMinor: paidMinorCents, + varianceMinor: effectiveVarianceMinor, + breakdown, + }; + }).filter(r => r.varianceMinor > 0); + + if (params.search?.trim()) { + const q = params.search.trim().toUpperCase(); + rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q)); + } + if (params.seatClass?.trim()) { + const sc = params.seatClass.trim().toLowerCase(); + rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc))); + } + if (params.sort === 'asc') { + rows.sort((a, b) => a.varianceMinor - b.varianceMinor); + } else { + rows.sort((a, b) => b.varianceMinor - a.varianceMinor); + } + + return { total: rows.length, rows }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, 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 93470daeb..a028faa8c 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 @@ -76,6 +76,7 @@ export default function PassengersReportPage() { const [filterCoach, setFilterCoach] = useState(""); const [filterOrigin, setFilterOrigin] = useState(""); const [filterSeatClass, setFilterSeatClass] = useState(""); + const [filterCoachNumber, setFilterCoachNumber] = useState(""); const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery< ScheduleOption[] @@ -110,10 +111,12 @@ export default function PassengersReportPage() { const originOptions = [ ...new Set(passengerList.map((p) => p.origin).filter(Boolean)), ].sort() as string[]; + const coachNumberOptions = coachOptions; const filteredList = passengerList .filter((p) => { if (filterCoach && p.coachNumber !== filterCoach) return false; + if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false; if (filterOrigin && p.origin !== filterOrigin) return false; if (filterSeatClass && p.seatClassName !== filterSeatClass) return false; if (listSearch.trim()) { @@ -209,7 +212,9 @@ export default function PassengersReportPage() { setTab("occupancy"); setListSearch(""); setFilterCoach(""); + setFilterCoachNumber(""); setFilterOrigin(""); + setFilterSeatClass(""); }} disabled={loadingSchedules} > @@ -477,6 +482,18 @@ export default function PassengersReportPage() { ))} + { setScheduleId(e.target.value); setSeatClass(''); setSearch(''); }} + disabled={loadingSchedules} + > + + {schedules.map(s => )} + + + + + + {scheduleId ? ( + <> + {/* Schedule info banner */} +
+
+ +
+

{schedules.find(s => s.id === scheduleId)?.label ?? scheduleId}

+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value.toUpperCase())} + placeholder="Booking ref…" + className="input pl-8 pr-7 font-mono text-sm w-full" + /> + {search && ( + + )} +
+ + + {data && data.rows.length > 0 && ( + + )} +
+ + {isLoading && ( +
+ Loading… +
+ )} + {isError &&

Failed to load discrepancy data.

} + + {data && ( +
+
+

+ {data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found +

+
+ + + + + + + + + + + + + + {pg.slice.map(r => { + const isExpanded = expandedRef === r.bookingRef; + return ( + <> + setExpandedRef(isExpanded ? null : r.bookingRef)} + > + + + + + + + + + + + {/* Fare breakdown */} + {isExpanded && ( + + + + )} + + ); + })} + {data.rows.length === 0 && ( + + )} + +
+ Booking RefSeat Class · Coach · SeatRouteActualPaidVariancePhone
+ {isExpanded ? : } + + {r.bookingRef} + {r.isPackage && ( + + package + + )} + + {r.seatClass} + {r.coachNumber && · {r.coachNumber}} + {r.seatNumber && · #{r.seatNumber}} + + {r.origin} → {r.destination} + {fmtMinor(r.actualMinor)}{fmtPaid(r.paidMinor)} + + + {fmtMinor(r.varianceMinor)} + + {r.phone}
+

+ Fare breakdown +

+ + + + + + + + + + + + {r.breakdown.map((b, bi) => ( + + + + + + + + ))} + + + + + +
PassengerSeat ClassCoachSeatActual Fare
{b.passengerName}{b.seatClass}{b.coachNumber ?? '—'}{b.seatNumber ?? '—'}{fmtMinor(b.fareMinor)}
Total actual vs paid + {fmtMinor(r.actualMinor)} / {fmtPaid(r.paidMinor)} + (+{fmtMinor(r.varianceMinor)}) +
+
No discrepancies found.
+ +
+ )} + + ) : ( +
+ +

Select a schedule above to load the discrepancy report

+
+ )} + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 5fa655a5e..52b875433 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -787,14 +787,6 @@ export default function RoutesPage() { title="Check-in cutoff override (minutes) for this stop" /> -
- updateStop(index, 'plannedDepartureTime', v)} - placeholder="Dep time" - label="Planned Departure" - /> -
+
+ updateStop(index, 'plannedDepartureTime', v)} + placeholder="Dep time" + label="Planned Departure" + /> +