Merge remote-tracking branch 'origin/dev' into tests

Merging dev to my local test branch
This commit is contained in:
Muluhabt
2026-07-21 00:08:49 +03:00
33 changed files with 1645 additions and 159 deletions

View File

@@ -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.
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 4060px 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;
}

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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)} />
</>
}

View File

@@ -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)} />
</>
}

View File

@@ -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 />
</>
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 = {

View File

@@ -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>

View File

@@ -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(" · ");

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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<string, number>);
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 },

View File

@@ -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() {
</option>
))}
</select>
<select
className="input w-36"
value={filterCoachNumber}
onChange={(e) => setFilterCoachNumber(e.target.value)}
>
<option value="">All coaches</option>
{coachNumberOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="input w-36"
value={filterOrigin}

View File

@@ -59,8 +59,12 @@ interface DiscrepancyReport {
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtMoney(minor: number, currency: string) {
return `${currency} ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
function fmtStation(name: string) {
return name.replace(/\s+Station$/i, '');
}
function fmtMoney(amount: number, currency: string) {
return `${currency} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
function exportCsv(rows: DiscrepancyRow[]) {
@@ -73,13 +77,13 @@ function exportCsv(rows: DiscrepancyRow[]) {
r.passengerName,
r.phone,
new Date(r.bookingDate).toLocaleDateString('en-GB'),
`${r.origin.city || r.origin.name}${r.destination.city || r.destination.name}`,
`${fmtStation(r.origin.name)}${fmtStation(r.destination.name)}`,
new Date(r.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }),
r.seatType,
r.coachNumber ?? '—',
`${r.actualCurrency} ${(r.actualMinor / 100).toFixed(2)}`,
`${r.paidCurrency} ${(r.paidMinor / 100).toFixed(2)}`,
`${r.balanceCurrency} ${(r.balanceMinor / 100).toFixed(2)}`,
`${r.paidCurrency} ${r.paidMinor.toFixed(2)}`,
`${r.balanceCurrency} ${r.balanceMinor.toFixed(2)}`,
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
const csv = [headers.join(','), ...lines].join('\n');
@@ -275,24 +279,6 @@ export default function PaymentDiscrepancyPage() {
</div>
)}
{/* Summary — date-range mode, only when there are results */}
{data && !isSearchMode && !data.notFound && data.total > 0 && (
<div className="flex flex-wrap gap-6 rounded-xl border px-5 py-4 bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800">
<div className="flex flex-col">
<span className="text-2xl font-bold text-red-700 dark:text-red-300">{data.total}</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Underpaid bookings</span>
</div>
{data.totalBalanceEtbMinor > 0 && (
<div className="flex flex-col">
<span className="text-2xl font-bold text-red-700 dark:text-red-300">
{fmtMoney(data.totalBalanceEtbMinor, 'ETB')}
</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total outstanding (ETB)</span>
</div>
)}
</div>
)}
{/* Table */}
{rows.length > 0 && (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
@@ -330,7 +316,7 @@ export default function PaymentDiscrepancyPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row, i) => {
{rows.filter(row => row.balanceMinor > 0).map((row, i) => {
const isExpanded = expandedPnr === row.pnr;
const hasMultiple = row.passengerCount > 1;
return (
@@ -352,11 +338,11 @@ export default function PaymentDiscrepancyPage() {
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-gray-900 dark:text-white font-medium">
{row.origin.city || row.origin.name}
{fmtStation(row.origin.name)}
</span>
<span className="text-gray-400 dark:text-gray-500 mx-1"></span>
<span className="text-gray-900 dark:text-white font-medium">
{row.destination.city || row.destination.name}
{fmtStation(row.destination.name)}
</span>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">
@@ -373,12 +359,12 @@ export default function PaymentDiscrepancyPage() {
)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap font-medium">
{fmtMoney(row.actualMinor, row.actualCurrency)}
{fmtMoney(row.actualMinor / 100, row.actualCurrency)}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{fmtMoney(row.paidMinor, row.paidCurrency)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<td className="px-4 py-3 whitespace-nowrap text-red-600 dark:text-red-400 font-semibold">
<BalanceBadge row={row} />
</td>
{/* Passengers column */}
@@ -462,7 +448,7 @@ export default function PaymentDiscrepancyPage() {
</div>
{!isSearchMode && (
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400 dark:text-gray-500">
{rows.length} record{rows.length !== 1 ? 's' : ''} click a phone number to call directly, or export CSV for bulk follow-up
{rows.filter(r => r.balanceMinor > 0).length} record{rows.filter(r => r.balanceMinor > 0).length !== 1 ? 's' : ''} click a phone number to call directly, or export CSV for bulk follow-up
</div>
)}
</div>

View File

@@ -0,0 +1,3 @@
export default function PaymentsReportLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,357 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle, Train, Download, Search, X,
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
// ── Types ─────────────────────────────────────────────────────────────────────
interface ScheduleOption { id: string; label: string; departureAt: string; isPackage: boolean; }
interface PassengerBreakdown {
passengerName: string;
seatClass: string;
coachNumber: string | null;
seatNumber: string | null;
fareMinor: number;
}
interface DiscrepancyRow {
bookingRef: string;
isPackage: boolean;
seatClass: string;
coachNumber: string | null;
seatNumber: string | null;
origin: string;
destination: string;
phone: string;
actualMinor: number;
paidMinor: number;
varianceMinor: number;
breakdown: PassengerBreakdown[];
}
interface DiscrepancyReport { total: number; rows: DiscrepancyRow[]; }
// ── Pagination ────────────────────────────────────────────────────────────────
const PAGE_SIZE = 20;
function usePagination<T>(items: T[], resetKey?: unknown) {
const [page, setPage] = useState(1);
useMemo(() => { setPage(1); }, [resetKey]); // eslint-disable-line react-hooks/exhaustive-deps
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const slice = items.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
return { page: safePage, setPage, totalPages, slice };
}
function Pagination({ page, totalPages, setPage, total }: {
page: number; totalPages: number; setPage: (p: number) => void; total: number;
}) {
if (totalPages <= 1) return null;
const from = (page - 1) * PAGE_SIZE + 1;
const to = Math.min(page * PAGE_SIZE, total);
return (
<div className="flex items-center justify-between px-1 pt-3 border-t border-border text-xs text-muted-foreground">
<span>{from}{to} of {total}</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-2">Page {page} of {totalPages}</span>
<button
onClick={() => setPage(page + 1)}
disabled={page === totalPages}
className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtMinor(minor: number) {
return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
// paidMinor from PaymentIntent.amountMinor is converted to cents server-side before being returned
function fmtPaid(amount: number) {
return `ETB ${(amount / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
function downloadCsv(csv: string, filename: string) {
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// ── Discrepancy page ──────────────────────────────────────────────────────────
export default function PaymentsReportPage() {
const [scheduleId, setScheduleId] = useState('');
const [search, setSearch] = useState('');
const [seatClass, setSeatClass] = useState('');
const [sort, setSort] = useState<'desc' | 'asc'>('desc');
const [expandedRef, setExpandedRef] = useState<string | null>(null);
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ['report-schedules'],
queryFn: () => apiClient.get('/reports/schedules'),
});
const now = new Date();
const schedules = (schedulesRaw ?? []).filter(
s => new Date(s.departureAt) >= now,
);
const { data, isLoading, isError } = useQuery<DiscrepancyReport>({
queryKey: ['payments-discrepancy', scheduleId, search, seatClass, sort],
queryFn: () => apiClient.get('/reports/payments/discrepancy', {
params: { scheduleId, search: search || undefined, seatClass: seatClass || undefined, sort },
}),
enabled: !!scheduleId,
});
const pg = usePagination(data?.rows ?? [], `${scheduleId}-${search}-${seatClass}-${sort}`);
const seatClassOptions = useMemo(() => {
if (!data) return [];
return [...new Set(data.rows.flatMap(r => r.breakdown.map(b => b.seatClass)))].sort();
}, [data]);
const doExport = () => {
if (!data) return;
const headers = ['Booking Ref', 'Route', 'Seat Class', 'Coach', 'Seat', 'Phone', 'Actual (ETB)', 'Paid (ETB)', 'Variance (ETB)'];
const rows = data.rows.map(r => [
r.bookingRef,
`${r.origin}${r.destination}`,
r.seatClass,
r.coachNumber ?? '—',
r.seatNumber ?? '—',
r.phone,
(r.actualMinor / 100).toFixed(2),
(r.paidMinor / 100).toFixed(2),
(r.varianceMinor / 100).toFixed(2),
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
downloadCsv([headers.join(','), ...rows].join('\n'), `discrepancy-${scheduleId}.csv`);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Payments Report</h1>
<p className="text-muted-foreground mt-1">Payment discrepancies for upcoming schedules</p>
</div>
{/* Schedule selector */}
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={e => { setScheduleId(e.target.value); setSeatClass(''); setSearch(''); }}
disabled={loadingSchedules}
>
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
{schedules.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
</div>
</div>
{scheduleId ? (
<>
{/* Schedule info banner */}
<div className="card flex items-center gap-4">
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<p className="font-semibold">{schedules.find(s => s.id === scheduleId)?.label ?? scheduleId}</p>
</div>
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value.toUpperCase())}
placeholder="Booking ref…"
className="input pl-8 pr-7 font-mono text-sm w-full"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<select
value={seatClass}
onChange={e => setSeatClass(e.target.value)}
className="input w-48 text-sm"
>
<option value="">All seat classes</option>
{seatClassOptions.map(sc => <option key={sc} value={sc}>{sc}</option>)}
</select>
<select
value={sort}
onChange={e => setSort(e.target.value as 'desc' | 'asc')}
className="input w-48 text-sm"
>
<option value="desc">Variance: High Low</option>
<option value="asc">Variance: Low High</option>
</select>
{data && data.rows.length > 0 && (
<button
onClick={doExport}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
)}
</div>
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8">
<Loader2 className="w-4 h-4 animate-spin" />Loading
</div>
)}
{isError && <p className="text-sm text-red-500 py-4">Failed to load discrepancy data.</p>}
{data && (
<div className="card overflow-x-auto">
<div className="mb-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found
</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4 w-4" />
<th className="pb-2 pr-4 whitespace-nowrap">Booking Ref</th>
<th className="pb-2 pr-4 whitespace-nowrap">Seat Class · Coach · Seat</th>
<th className="pb-2 pr-4 whitespace-nowrap">Route</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Actual</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Paid</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Variance</th>
<th className="pb-2 whitespace-nowrap">Phone</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pg.slice.map(r => {
const isExpanded = expandedRef === r.bookingRef;
return (
<>
<tr
key={r.bookingRef}
className="hover:bg-muted/30 cursor-pointer"
onClick={() => setExpandedRef(isExpanded ? null : r.bookingRef)}
>
<td className="py-2 pr-2 text-muted-foreground">
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</td>
<td className="py-2 pr-4 font-mono text-xs font-semibold">
{r.bookingRef}
{r.isPackage && (
<span className="ml-1.5 text-[10px] font-semibold bg-purple-100 dark:bg-purple-950/40 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800 px-1.5 py-0.5 rounded">
package
</span>
)}
</td>
<td className="py-2 pr-4 text-xs">
<span className="font-medium">{r.seatClass}</span>
{r.coachNumber && <span className="text-muted-foreground"> · {r.coachNumber}</span>}
{r.seatNumber && <span className="text-muted-foreground"> · #{r.seatNumber}</span>}
</td>
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
{r.origin} {r.destination}
</td>
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmtMinor(r.actualMinor)}</td>
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmtPaid(r.paidMinor)}</td>
<td className="py-2 pr-4 text-right">
<span className="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-md bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
<AlertTriangle className="w-3 h-3" />
{fmtMinor(r.varianceMinor)}
</span>
</td>
<td className="py-2 text-xs text-muted-foreground">{r.phone}</td>
</tr>
{/* Fare breakdown */}
{isExpanded && (
<tr key={`${r.bookingRef}-breakdown`} className="bg-muted/20">
<td colSpan={8} className="px-6 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Fare breakdown
</p>
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted-foreground border-b border-border/50">
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Passenger</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat Class</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Coach</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat</th>
<th className="pb-1.5 font-semibold uppercase tracking-wide text-right">Actual Fare</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{r.breakdown.map((b, bi) => (
<tr key={bi} className="text-foreground">
<td className="py-1.5 pr-4 font-medium whitespace-nowrap">{b.passengerName}</td>
<td className="py-1.5 pr-4">{b.seatClass}</td>
<td className="py-1.5 pr-4 font-mono">{b.coachNumber ?? '—'}</td>
<td className="py-1.5 pr-4 font-mono">{b.seatNumber ?? '—'}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{fmtMinor(b.fareMinor)}</td>
</tr>
))}
<tr className="border-t border-border font-semibold">
<td colSpan={4} className="pt-2 text-muted-foreground">Total actual vs paid</td>
<td className="pt-2 text-right tabular-nums">
{fmtMinor(r.actualMinor)} / {fmtPaid(r.paidMinor)}
<span className="ml-2 text-red-500">(+{fmtMinor(r.varianceMinor)})</span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
)}
</>
);
})}
{data.rows.length === 0 && (
<tr><td colSpan={8} className="py-8 text-center text-sm text-muted-foreground">No discrepancies found.</td></tr>
)}
</tbody>
</table>
<Pagination page={pg.page} totalPages={pg.totalPages} setPage={pg.setPage} total={data.rows.length} />
</div>
)}
</>
) : (
<div className="card py-16 text-center text-muted-foreground">
<AlertTriangle className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a schedule above to load the discrepancy report</p>
</div>
)}
</div>
);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -66,7 +66,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
// { name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
]
},
{
@@ -121,10 +121,11 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Analytics & Reports',
items: [
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
{ name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},
@@ -221,11 +222,12 @@ export default function Sidebar() {
// Special handling for Settings to avoid conflict with User Management
let isActive;
if (item.href === '/settings') {
// Settings is active only for exact match or non-users sub-routes
isActive = pathname === '/settings' ||
isActive = pathname === '/settings' ||
(pathname?.startsWith('/settings/') && !pathname.startsWith('/settings/users'));
} else if (item.href === '/payments') {
// Exact match only — avoid colliding with /reports/payments
isActive = pathname === '/payments' || pathname?.startsWith('/payments/');
} else {
// Standard matching for other items
isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
}
return (

View File

@@ -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,

View File

@@ -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];

View File

@@ -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,