mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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<void> {
|
||||
// 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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
) {
|
||||
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<Locomotive | null> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<Locomotive> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="lg" wrap="wrap">
|
||||
<LegendDot color="teal" label="Available" value={availableCount} />
|
||||
<LegendDot color="blue" label="Assigned" value={assignedCount} />
|
||||
{otherCount > 0 ? <LegendDot color="gray" label="Other" value={otherCount} /> : null}
|
||||
<LegendDot color="teal" label="Available in yard" value={availableCount} />
|
||||
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
|
||||
{onTrainCount > 0 ? (
|
||||
<LegendDot color="gray" label="On train" value={onTrainCount} />
|
||||
) : null}
|
||||
{otherCount - onTrainCount > 0 ? (
|
||||
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -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 <tr>. */
|
||||
.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;
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{/* Cancel schedule — hidden for now (frontend only; the
|
||||
cancelSchedule mutation is untouched). Restore by
|
||||
uncommenting.
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
@@ -487,6 +490,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
Cancel schedule
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
*/}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
@@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [navigate, cancel.isPending, cancel, toast]);
|
||||
}, [navigate, toast]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!routeId || !scheduleDate || !trainId) {
|
||||
|
||||
@@ -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({
|
||||
<>
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<CardTitle>Your documents</CardTitle>
|
||||
@@ -143,6 +150,7 @@ export function ChangesRequestedView({
|
||||
chip="Not invoiced"
|
||||
/>
|
||||
<ScheduleCard booking={booking} title="Schedule & Service" />
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
<ShipmentDetailsCard booking={booking} />
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
{/* Documents (uploadable) */}
|
||||
<SectionCard ref={documentsRef}>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
@@ -399,6 +406,7 @@ export function DraftBookingView({
|
||||
chip="Not invoiced"
|
||||
/>
|
||||
<ScheduleCard booking={booking} title="Schedule & Service" />
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard onCancel={() => setCancelDialogOpen(true)} />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
<ContainersCard booking={booking} />
|
||||
|
||||
<ContractInfoCard booking={booking} />
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
@@ -300,6 +304,7 @@ export function ReadonlyBookingView({
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard />
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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<Freight.IContract["serviceType"]>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Group gap={10} align="flex-start" wrap="nowrap" py={8}>
|
||||
<Box
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#F1F4F7",
|
||||
color: "#475569",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Box miw={0} flex={1}>
|
||||
<Text fz="10.5px" fw={600} c="#9AA8B5" tt="uppercase" style={{ letterSpacing: "0.04em" }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={2} fz="13.5px" fw={700} c="#10202F" style={{ wordBreak: "break-word" }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb={4}>
|
||||
<CardTitle>Customer Information</CardTitle>
|
||||
{booking.isGovernment && (
|
||||
<Group
|
||||
component="span"
|
||||
gap={5}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#EAF1FE",
|
||||
border: "1px solid #CFDDFB",
|
||||
padding: "3px 10px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: "#1E40AF",
|
||||
}}
|
||||
>
|
||||
<Landmark size={12} />
|
||||
Government
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="16px" fw={800} c="#10202F" mt={6}>
|
||||
{company.name || "—"}
|
||||
</Text>
|
||||
{booking.governmentInstitution && (
|
||||
<Text fz="12.5px" c="#6B7C8E" mt={2}>
|
||||
{booking.governmentInstitution}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider my={10} color="#F2F5F8" />
|
||||
|
||||
<Box>
|
||||
{company.tin && (
|
||||
<InfoRow icon={<Building2 size={15} />} label="TIN" value={company.tin} />
|
||||
)}
|
||||
{company.email && (
|
||||
<InfoRow icon={<Mail size={15} />} label="Email" value={company.email} />
|
||||
)}
|
||||
{company.phone && (
|
||||
<InfoRow icon={<Phone size={15} />} label="Phone" value={company.phone} />
|
||||
)}
|
||||
{company.address && (
|
||||
<InfoRow icon={<MapPin size={15} />} label="Address" value={company.address} />
|
||||
)}
|
||||
{contact && (
|
||||
<InfoRow icon={<User size={15} />} label="Contact person" value={contact} />
|
||||
)}
|
||||
</Box>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Group
|
||||
component="span"
|
||||
gap={4}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#F1F4F7",
|
||||
padding: "2px 8px",
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Text fz={13} fw={700} c="#10202F">
|
||||
{unit.containerNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{unit.sealNumber || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={13} c="#475569">
|
||||
{Number(unit.vgmTons || 0) ? `${Number(unit.vgmTons).toLocaleString()} t` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="wrap">
|
||||
{unit.isHazardous && (
|
||||
<Flag icon={<AlertTriangle size={11} />} label="Hazardous" />
|
||||
)}
|
||||
{unit.isReefer && <Flag icon={<Snowflake size={11} />} label="Reefer" />}
|
||||
{unit.isReturn && <Flag icon={<Undo2 size={11} />} label="Return" />}
|
||||
{!unit.isHazardous && !unit.isReefer && !unit.isReturn && (
|
||||
<Text fz={12} c="#9AA8B5">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} fw={600} c={unit.receivedToPort ? "#0A6F4D" : "#9AA8B5"}>
|
||||
{unit.receivedToPort ? "Received" : "Pending"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SectionCard>
|
||||
@@ -27,7 +104,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Table verticalSpacing="sm" horizontalSpacing={0}>
|
||||
<Table verticalSpacing="sm" horizontalSpacing={0} mb={allUnits.length ? "lg" : 0}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11.5 }}>Type</Table.Th>
|
||||
@@ -39,28 +116,39 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{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 (
|
||||
<Table.Tr key={`${c.type}-${i}`}>
|
||||
<Table.Tr key={c.id ?? i}>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{c.type}
|
||||
</Text>
|
||||
<Group gap={8} wrap="wrap">
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
{containerTypeLabel(c)}
|
||||
</Text>
|
||||
{c.isOverweight && (
|
||||
<Flag icon={<AlertTriangle size={11} />} label="Overweight" />
|
||||
)}
|
||||
{!!c.hazardousQuantity && (
|
||||
<Flag icon={<Flame size={11} />} label={`${c.hazardousQuantity} hazardous`} />
|
||||
)}
|
||||
{!!c.reeferQuantity && (
|
||||
<Flag icon={<Snowflake size={11} />} label={`${c.reeferQuantity} reefer`} />
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} c="#10202F">
|
||||
{c.qty}
|
||||
{c.quantity}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} c="#475569">
|
||||
{c.vgm ? `${c.vgm} t` : "—"}
|
||||
{fmtWeight(Number(c.vgmPerUnitTons || 0))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={14} fw={700} c="#10202F" ta="right">
|
||||
{lineVgm ? `${lineVgm.toLocaleString()} t` : "—"}
|
||||
{fmtWeight(lineVgm)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -69,6 +157,30 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{allUnits.length > 0 && (
|
||||
<Box>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5" mb={8}>
|
||||
Container numbers
|
||||
</Text>
|
||||
<Table verticalSpacing="xs" horizontalSpacing={0}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Container no.</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Seal no.</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>VGM</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Flags</Table.Th>
|
||||
<Table.Th style={{ color: "#9AA8B5", fontSize: 11 }}>Port status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{allUnits.map((u) => (
|
||||
<UnitRow key={u.id} unit={u} />
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
mt="sm"
|
||||
pt="sm"
|
||||
@@ -78,7 +190,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
|
||||
Total weight (VGM)
|
||||
</Text>
|
||||
<Text fz={14} fw={800} c="#0A6F4D">
|
||||
{totalVgm.toLocaleString()} t
|
||||
{fmtWeight(totalVgm)}
|
||||
</Text>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
@@ -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 (
|
||||
<Box miw={0} flex={1}>
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
{label}
|
||||
</Text>
|
||||
<Text mt={4} fz="14px" fw={700} c="#10202F">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Group
|
||||
gap={24}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
py={13}
|
||||
style={{ borderBottom: "1px solid #F2F5F8" }}
|
||||
>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb={4}>
|
||||
<CardTitle>Contract Information</CardTitle>
|
||||
{booking.contractId && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.contractId}`}
|
||||
fz="12.5px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
View full contract →
|
||||
</Anchor>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box>
|
||||
<Row>
|
||||
<Field
|
||||
label="Contract reference"
|
||||
value={
|
||||
booking.contractId ? (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.contractId}`}
|
||||
fz="14px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
{booking.contractReference ?? booking.reference}
|
||||
</Anchor>
|
||||
) : (
|
||||
(booking.contractReference ?? booking.reference)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Contract type"
|
||||
value={booking.contractType === "RENEWAL" ? "Renewal" : "New"}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Field
|
||||
label="Order type"
|
||||
value={isGeneralContract ? "General Contract" : "One-Time Booking"}
|
||||
/>
|
||||
<Field label="Payment currency" value={booking.paymentCurrency || "—"} />
|
||||
</Row>
|
||||
|
||||
{hasValidity && (
|
||||
<Row>
|
||||
<Field label="Valid from" value={fmtDate(booking.contractValidFrom)} />
|
||||
<Field
|
||||
label={
|
||||
booking.contractValidityDays
|
||||
? `Valid until (${booking.contractValidityDays} days)`
|
||||
: "Valid until"
|
||||
}
|
||||
value={fmtDate(booking.contractValidUntil)}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{isGeneralContract && (booking.startDate || booking.endDate) && (
|
||||
<Row>
|
||||
<Field label="Service start" value={fmtDate(booking.startDate)} />
|
||||
<Field label="Service end" value={fmtDate(booking.endDate)} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{isGeneralContract && booking.expiresAt && (
|
||||
<Row>
|
||||
<Field label="Ordering window closes" value={fmtDate(booking.expiresAt)} />
|
||||
<Field label="Version" value={`v${booking.versionNumber ?? 1}`} />
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{booking.customsClearingEnabled && (
|
||||
<Row>
|
||||
<Field label="Customs clearance" value="Enabled" />
|
||||
<Field
|
||||
label="Clearing agent"
|
||||
value={booking.customsClearingAgent || "Assigned by Global Logistics"}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{booking.previousContractId && (
|
||||
<Row>
|
||||
<Field
|
||||
label="Renewed from"
|
||||
value={
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/contracts/${booking.previousContractId}`}
|
||||
fz="14px"
|
||||
fw={700}
|
||||
underline="hover"
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Link2 size={13} />
|
||||
Previous contract
|
||||
</Group>
|
||||
</Anchor>
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="Consolidation"
|
||||
value={
|
||||
booking.consolidationPartner
|
||||
? `Paired with ${booking.consolidationPartner.reference}`
|
||||
: booking.consolidationPartnerId
|
||||
? "Paired"
|
||||
: "Not consolidated"
|
||||
}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{booking.financialTerms && (
|
||||
<>
|
||||
<Divider my={10} color="#F2F5F8" />
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5" mb={6}>
|
||||
Financial terms
|
||||
</Text>
|
||||
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{booking.financialTerms}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{booking.contractSummary && (
|
||||
<>
|
||||
<Divider my={10} color="#F2F5F8" />
|
||||
<Group gap={6} align="center" mb={6}>
|
||||
<FileText size={13} color="#9AA8B5" />
|
||||
<Text fz="11.5px" fw={600} c="#9AA8B5">
|
||||
Contract summary
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{booking.contractSummary}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: palette.bg,
|
||||
border: `1px solid ${palette.border}`,
|
||||
padding: "3px 10px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: palette.color,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" pb={3}>
|
||||
<CardTitle>Shipment Details</CardTitle>
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#F1F4F7",
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
<FileText size={13} />
|
||||
{booking.contractType === "RENEWAL"
|
||||
? "Renewal contract"
|
||||
: "New contract"}
|
||||
<Group gap={6} wrap="wrap" justify="flex-end">
|
||||
{badges.map((b) => (
|
||||
<Badge
|
||||
key={b}
|
||||
label={b}
|
||||
tone={b === "Customs clearance" ? "blue" : "amber"}
|
||||
/>
|
||||
))}
|
||||
<Group
|
||||
component="span"
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#F1F4F7",
|
||||
padding: "5px 11px",
|
||||
fontSize: 11.5,
|
||||
fontWeight: 700,
|
||||
color: "#475569",
|
||||
}}
|
||||
>
|
||||
<FileText size={13} />
|
||||
{booking.contractType === "RENEWAL"
|
||||
? "Renewal contract"
|
||||
: "New contract"}
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
<Box>
|
||||
|
||||
@@ -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<BookingDetail["bookingContainers"]>[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(" · ");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<Box
|
||||
px={16}
|
||||
py={14}
|
||||
|
||||
@@ -162,29 +162,39 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct totalMinor for a booking, accounting for package round-trip bookings
|
||||
* where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||
* A package round-trip booking has packageId set, bookingType ROUND_TRIP, and
|
||||
* totalMinor equal to a single-leg fare (i.e. seats split evenly across 2 legs).
|
||||
* Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
|
||||
* bookings where totalMinor may have been stored as a single-leg amount before the server fix.
|
||||
*/
|
||||
private async resolveBookingTotal(booking: { id: string; totalMinor: number; bookingType: string; packageId?: string | null; priceTierId?: string | null }): Promise<number> {
|
||||
private async resolveBookingTotal(booking: {
|
||||
id: string;
|
||||
totalMinor: number;
|
||||
bookingType: string;
|
||||
packageId?: string | null;
|
||||
priceTierId?: string | null;
|
||||
displayTotalMinor?: number | null;
|
||||
}): Promise<number> {
|
||||
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(
|
||||
|
||||
@@ -691,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';
|
||||
|
||||
@@ -802,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';
|
||||
|
||||
|
||||
@@ -787,14 +787,6 @@ export default function RoutesPage() {
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedDepartureTime ?? ''}
|
||||
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
|
||||
placeholder="Dep time"
|
||||
label="Planned Departure"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedArrivalTime ?? ''}
|
||||
@@ -803,6 +795,14 @@ export default function RoutesPage() {
|
||||
label="Planned Arrival"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<DateTimePicker
|
||||
value={stop.plannedDepartureTime ?? ''}
|
||||
onChange={(v) => updateStop(index, 'plannedDepartureTime', v)}
|
||||
placeholder="Dep time"
|
||||
label="Planned Departure"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStop(index)}
|
||||
@@ -854,7 +854,6 @@ export default function RoutesPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-44" />
|
||||
<div className="w-44">
|
||||
{destinationStationId && (
|
||||
<DateTimePicker
|
||||
@@ -865,6 +864,7 @@ export default function RoutesPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-44" />
|
||||
<div className="w-24">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
|
||||
@@ -342,6 +342,9 @@ export default function SchedulesPage() {
|
||||
(s: any) => s.plannedArrivalTime || s.plannedDepartureTime,
|
||||
);
|
||||
if (!hasRouteTimes) return;
|
||||
// If the schedule already has saved stop times, keep them — don't overwrite
|
||||
// with route template times. The user can use "Auto-fill" if they want to reset.
|
||||
if (editingSchedule.stopTimes && editingSchedule.stopTimes.length > 0) return;
|
||||
const eatDateStr = editForm.departureAt
|
||||
? editForm.departureAt.slice(0, 10)
|
||||
: null;
|
||||
|
||||
@@ -249,6 +249,10 @@ export default function ConfirmationPage() {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// For settled amounts, free children (getEtbFare returns 0) should show 0 —
|
||||
// split the total only among passengers who actually paid.
|
||||
const paidPassengerCount = passengers.filter((_, j) => getEtbFare(j) > 0).length || passengers.length;
|
||||
|
||||
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout)
|
||||
// between them — a setTimeout delay would push later saves outside the click's
|
||||
// synchronous user-activation window and risk iOS Safari silently blocking them.
|
||||
@@ -278,7 +282,7 @@ export default function ConfirmationPage() {
|
||||
outboundSchedule: outbound,
|
||||
inboundSchedule: inbound,
|
||||
isRoundTrip,
|
||||
fareMinor: hasSettledAmount ? settledAmountMinor! : getEtbFare(i),
|
||||
fareMinor: hasSettledAmount ? (getEtbFare(i) === 0 ? 0 : Math.round(settledAmountMinor! / paidPassengerCount)) : getEtbFare(i),
|
||||
currency: voucherCurrency,
|
||||
fareIsMajorUnits: hasSettledAmount,
|
||||
createdAt,
|
||||
|
||||
@@ -157,7 +157,8 @@ export default function ReviewPage() {
|
||||
const isPackageBooking = packageTierPriceMinor !== null || !!packageName;
|
||||
|
||||
const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length;
|
||||
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * 2 : 0;
|
||||
const pkgPriceMultiplier = isRoundTrip ? 2 : 1;
|
||||
const pkgAdultFare = isPackageBooking && packageTierPriceMinor != null ? packageTierPriceMinor * pkgPriceMultiplier : 0;
|
||||
const pkgChildFare = pkgAdultFare;
|
||||
|
||||
const isPackageChild = (index: number) =>
|
||||
@@ -194,7 +195,7 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
// it is exactly what was shown to the user. Use the fare-breakdown API only as fallback
|
||||
// for cases where seatFareMinor was not captured (e.g. auto-assign without seat map).
|
||||
if (p.seatFareMinor != null) {
|
||||
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
|
||||
return (isPackageBooking && isRoundTrip) ? p.seatFareMinor * 2 : p.seatFareMinor;
|
||||
}
|
||||
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
|
||||
const line = fareBreakdown.passengers[index];
|
||||
|
||||
@@ -355,7 +355,7 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
|
||||
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'F');
|
||||
|
||||
const padX = 7;
|
||||
label(doc, 'Total fare paid', margin + padX, y + 8, { color: BODY });
|
||||
label(doc, 'Fare paid', margin + padX, y + 8, { color: BODY });
|
||||
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...SUCCESS);
|
||||
doc.text('✓ PAID', margin + padX, y + 15);
|
||||
|
||||
@@ -456,7 +456,7 @@ interface VoucherData {
|
||||
// outbound, leg 2 = return), each with that leg's own seat — see bookings.service.ts's
|
||||
// getByRef(). dateOfBirth is included purely to disambiguate same-name passengers when
|
||||
// grouping leg rows back into one passenger below.
|
||||
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; seat?: { number: string; coach: string; seatClass: string } }>;
|
||||
passengers: Array<{ fullName: string; dateOfBirth?: string; category: string; leg?: number; fareMinor?: number; seat?: { number: string; coach: string; seatClass: string } }>;
|
||||
schedule: VoucherSchedule;
|
||||
returnSchedule?: VoucherSchedule | null;
|
||||
totalMinor: number;
|
||||
@@ -475,37 +475,44 @@ interface VoucherData {
|
||||
}
|
||||
|
||||
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
|
||||
// The amount shown is always a single raw field straight from the API — the settled
|
||||
// payment amount when available, otherwise the booking total — never a derived value
|
||||
// (previously this fell back to Math.round(totalMinor / passengers.length), which
|
||||
// doesn't correspond to any real field and could disagree with what was actually
|
||||
// charged). Same value on every passenger's voucher; no /100, no per-passenger split.
|
||||
const settledAmountMinor = booking.payment?.amountMinor;
|
||||
const settledCurrency = booking.payment?.currency;
|
||||
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
|
||||
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
|
||||
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
|
||||
// Use displayTotalMinor when available so the voucher shows the passenger's currency amount.
|
||||
const voucherFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||
// Display total in the booking's display currency (minor units for ETB, major for settled).
|
||||
const totalFareMinor = useSettledAmount ? settledAmountMinor! : (booking.displayTotalMinor ?? booking.totalMinor);
|
||||
|
||||
const isRoundTrip = booking.bookingType === 'ROUND_TRIP' && !!booking.returnSchedule;
|
||||
|
||||
// Group leg rows back into one entry per real passenger — without this, a round trip
|
||||
// produced two half-passenger vouchers (one per leg, each showing only its own leg's
|
||||
// seat) instead of one voucher per passenger covering both legs.
|
||||
// Also accumulate the per-leg ETB fareMinor from the API so we can split the display
|
||||
// total proportionally (adults vs children pay different rates).
|
||||
type SeatInfo = VoucherData['passengers'][number]['seat'];
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo }
|
||||
{ fullName: string; category: string; outboundSeat?: SeatInfo; returnSeat?: SeatInfo; etbFareMinor: number }
|
||||
>();
|
||||
booking.passengers.forEach((p) => {
|
||||
const key = `${p.fullName}|${p.dateOfBirth}|${p.category}`;
|
||||
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined };
|
||||
const entry = grouped.get(key) || { fullName: p.fullName, category: p.category, outboundSeat: undefined, returnSeat: undefined, etbFareMinor: 0 };
|
||||
if (p.leg === 2) entry.returnSeat = p.seat;
|
||||
else entry.outboundSeat = p.seat;
|
||||
entry.etbFareMinor += p.fareMinor ?? 0;
|
||||
grouped.set(key, entry);
|
||||
});
|
||||
|
||||
const passengerCount = grouped.size || 1;
|
||||
// Sum of all per-seat ETB fares — used as denominator for proportional splitting.
|
||||
const totalEtbFareMinor = [...grouped.values()].reduce((sum, p) => sum + p.etbFareMinor, 0);
|
||||
// When ETB fare data is present, free children have etbFareMinor === 0 — exclude them
|
||||
// from the denominator so the settled amount is split only among paying passengers.
|
||||
const paidPassengerCount = totalEtbFareMinor > 0
|
||||
? ([...grouped.values()].filter(p => p.etbFareMinor > 0).length || passengerCount)
|
||||
: passengerCount;
|
||||
|
||||
// Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between
|
||||
// them — a setTimeout delay here would push later saves outside the click's synchronous
|
||||
// user-activation window and risk iOS Safari silently blocking them. The awaited work
|
||||
@@ -522,6 +529,20 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
// if it doesn't match what's actually on file.
|
||||
const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued';
|
||||
|
||||
// Per-passenger fare:
|
||||
// • Free children (etbFareMinor === 0 when ETB data exists) always show 0.
|
||||
// • Settled amounts: split evenly among PAID passengers (no per-seat currency breakdown).
|
||||
// • Booking totals: proportional ETB share; falls back to even split only when no
|
||||
// seat fare data is available at all (older bookings before fareMinor was stored).
|
||||
const isFreeChild = totalEtbFareMinor > 0 && p.etbFareMinor === 0;
|
||||
const perPassengerFare = isFreeChild
|
||||
? 0
|
||||
: useSettledAmount
|
||||
? Math.round(totalFareMinor / paidPassengerCount)
|
||||
: totalEtbFareMinor > 0
|
||||
? Math.round(totalFareMinor * p.etbFareMinor / totalEtbFareMinor)
|
||||
: Math.round(totalFareMinor / passengerCount);
|
||||
|
||||
await generatePassengerVoucherPDF({
|
||||
bookingRef: booking.bookingRef,
|
||||
ticketNumber,
|
||||
@@ -536,7 +557,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
|
||||
outboundCoachNumber: isRoundTrip ? p.outboundSeat?.coach : undefined,
|
||||
inboundSeatNumber: isRoundTrip ? p.returnSeat?.number : undefined,
|
||||
inboundCoachNumber: isRoundTrip ? p.returnSeat?.coach : undefined,
|
||||
fareMinor: voucherFareMinor,
|
||||
fareMinor: perPassengerFare,
|
||||
currency: voucherCurrency,
|
||||
fareIsMajorUnits: useSettledAmount,
|
||||
createdAt: booking.createdAt,
|
||||
|
||||
Reference in New Issue
Block a user