From be7d6b2092bdf6761b2fc500e4c6f82c431e2ae9 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 20 Jul 2026 16:44:25 +0300 Subject: [PATCH 01/15] Fix paid minor calculation --- .../app/reports/payment-discrepancy/page.tsx | 28 ++++--------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx index 80c4481ce..ed51c2d1b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx @@ -59,8 +59,8 @@ interface DiscrepancyReport { // ── Helpers ─────────────────────────────────────────────────────────────────── -function fmtMoney(minor: number, currency: string) { - return `${currency} ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`; +function fmtMoney(amount: number, currency: string) { + return `${currency} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`; } function exportCsv(rows: DiscrepancyRow[]) { @@ -77,9 +77,9 @@ function exportCsv(rows: DiscrepancyRow[]) { 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.actualCurrency} ${r.actualMinor.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 +275,6 @@ export default function PaymentDiscrepancyPage() { )} - {/* Summary — date-range mode, only when there are results */} - {data && !isSearchMode && !data.notFound && data.total > 0 && ( -
-
- {data.total} - Underpaid bookings -
- {data.totalBalanceEtbMinor > 0 && ( -
- - {fmtMoney(data.totalBalanceEtbMinor, 'ETB')} - - Total outstanding (ETB) -
- )} -
- )} - {/* Table */} {rows.length > 0 && (
From c71f4df6a11b77bd97f9d06f275a872acaafdb2b Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 20 Jul 2026 16:55:09 +0300 Subject: [PATCH 02/15] Fix payment discrepancy column --- .../src/app/reports/payment-discrepancy/page.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx index ed51c2d1b..84bd6cfc8 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx @@ -59,6 +59,10 @@ interface DiscrepancyReport { // ── Helpers ─────────────────────────────────────────────────────────────────── +function fmtStation(name: string) { + return name.replace(/\s+Station$/i, ''); +} + function fmtMoney(amount: number, currency: string) { return `${currency} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`; } @@ -73,7 +77,7 @@ 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 ?? '—', @@ -334,11 +338,11 @@ export default function PaymentDiscrepancyPage() { - {row.origin.city || row.origin.name} + {fmtStation(row.origin.name)} - {row.destination.city || row.destination.name} + {fmtStation(row.destination.name)} From 26f73cbe5fd2f6cc512098e7652818a07fbcd968 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 20 Jul 2026 13:55:26 +0000 Subject: [PATCH 03/15] chnages --- .../2430000000000-UniqueLocomotiveName.ts | 59 +++++++++++++++++++ .../locomotives/entities/locomotive.entity.ts | 7 +++ .../locomotives/locomotives.repository.ts | 19 ++++++ .../locomotives/locomotives.service.ts | 34 ++++++++++- .../wagons/WagonYardWorkspaceModal.tsx | 19 +++++- .../src/pages/fleet/config/resources.ts | 13 +++- .../TrainScheduleV2ListPage.tsx | 10 +++- .../new-contract-form/step2-service-type.tsx | 9 ++- 8 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts diff --git a/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts new file mode 100644 index 000000000..5468e2207 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2430000000000-UniqueLocomotiveName.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Locomotive names must be unique so staff can identify a unit by name alone + * (the card view leads with `name`, falling back to `code`). Uniqueness is: + * + * - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name; + * - scoped to live rows — a decommissioned (soft-deleted) locomotive must not + * hold its name hostage, matching how the fleet reuses yard codes; + * - skipped for blank names — `name` stays optional, and NULL/'' rows are + * excluded rather than colliding with each other. + * + * A partial expression index gives all three; a plain UNIQUE column cannot. + */ +export class UniqueLocomotiveName2430000000000 implements MigrationInterface { + name = 'UniqueLocomotiveName2430000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every + // copy after the oldest (…-2, …-3) so the index can build; the oldest row + // keeps the original name. Deterministic on created_at, then id. + await queryRunner.query(` + WITH ranked AS ( + SELECT + id, + name, + row_number() OVER ( + PARTITION BY lower(btrim(name)) + ORDER BY created_at, id + ) AS rn + FROM "freight"."locomotives" + WHERE deleted_at IS NULL + AND name IS NOT NULL + AND btrim(name) <> '' + ) + UPDATE "freight"."locomotives" AS l + SET name = btrim(ranked.name) || '-' || ranked.rn + FROM ranked + WHERE l.id = ranked.id + AND ranked.rn > 1 + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active" + ON "freight"."locomotives" (lower(btrim("name"))) + WHERE "deleted_at" IS NULL + AND "name" IS NOT NULL + AND btrim("name") <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`, + ); + // The de-duplicating renames are not reversed: the original names are no + // longer recoverable, and restoring them would re-introduce the conflict. + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts index d3214b6c4..d7a875c24 100644 --- a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -27,6 +27,13 @@ export class Locomotive extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; + /** + * Optional, but unique when set. Enforced in the DB by the partial expression + * index `UQ_locomotives_name_active` (see UniqueLocomotiveName2430000000000): + * case- and whitespace-insensitive, live rows only, blanks exempt. Not a + * `unique: true` column — that would be case-sensitive and would let a + * soft-deleted locomotive keep holding its name. + */ @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) name?: string | null; diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index af2a40f50..18a42205e 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository { ) { super(repository); } + + /** + * A live locomotive already holding this name, compared the same way the + * `UQ_locomotives_name_active` index compares: case- and whitespace- + * insensitive, soft-deleted rows excluded. `excludeId` skips the row being + * updated so it can keep its own name. + */ + findByName(name: string, excludeId?: string): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .where('lower(btrim(locomotive.name)) = lower(btrim(:name))', { name }); + + if (excludeId) { + qb.andWhere('locomotive.id != :excludeId', { excludeId }); + } + + // createQueryBuilder already filters soft-deleted rows (no withDeleted()). + return qb.getOne(); + } } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index 7c0e973e5..cbf9dfc0c 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -60,6 +60,23 @@ export class LocomotivesService { return `LOCO-${String(max + 1).padStart(3, '0')}`; } + /** + * Reject a name already worn by another live locomotive. Compared + * case-insensitively on the trimmed value so this matches the DB index + * `UQ_locomotives_name_active` — otherwise a clash the guard waved through + * would surface as a raw 500 from the index instead of a 409. `excludeId` + * lets an update keep its own name. + */ + private async assertNameAvailable(name: string, excludeId?: string): Promise { + const clash = await this.locomotivesRepository.findByName(name, excludeId); + + if (clash) { + throw new ConflictException( + `Locomotive name "${name.trim()}" is already used by ${clash.code}`, + ); + } + } + async create(dto: CreateLocomotiveDto): Promise { const code = dto.code?.trim() || (await this.generateCode()); @@ -68,9 +85,15 @@ export class LocomotivesService { throw new ConflictException(`Locomotive code ${code} already exists`); } + // Name stays optional; only a non-blank one has to be unique. + const name = dto.name?.trim() || null; + if (name) { + await this.assertNameAvailable(name); + } + return this.locomotivesRepository.create({ code, - name: dto.name?.trim() || null, + name, locomotiveType: dto.locomotiveType as LocomotiveType, status: dto.status as LocomotiveStatus, currentYardId: dto.currentYardId ?? null, @@ -107,6 +130,15 @@ export class LocomotivesService { } } + // Only when the caller actually sends a name — an omitted field keeps the + // current one, and clearing it to blank is allowed. + if (dto.name !== undefined) { + const nextName = dto.name?.trim() || null; + if (nextName) { + await this.assertNameAvailable(nextName, id); + } + } + // A locomotive coupled to a built train follows the train: its yard and // status are owned by the train-builder flow, not this generic PATCH. const link = await this.findTrainLink(id); diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index 67841e5f1..c10b1b033 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -205,6 +205,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; const otherCount = otherWagons.length; + // Split "Other" so a coupled wagon is visible as such. The Available/Assigned + // buckets deliberately count only UNCOUPLED wagons (see above), so a yard + // holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" — + // accurate for shunting, but unreadable unless the other 53 are named. + const onTrainCount = useMemo( + () => matching.filter((w) => w.trainId != null).length, + [matching], + ); const destinationYardOptions = useMemo( () => @@ -397,9 +405,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
- - - {otherCount > 0 ? : null} + + + {onTrainCount > 0 ? ( + + ) : null} + {otherCount - onTrainCount > 0 ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts index caa0ee6c4..3487cbed8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/resources.ts @@ -128,6 +128,8 @@ const LOCOMOTIVE_STATUS_OPTIONS = [ { label: "Out of service", value: "OUT_OF_SERVICE" }, ]; +// Every status a wagon can hold — for FILTERING the list. ASSIGNED belongs here: +// staff still need to search for assigned wagons. const WAGON_STATUS_OPTIONS = [ { label: "Available", value: Freight.WagonStatus.Available }, { label: "Assigned", value: Freight.WagonStatus.Assigned }, @@ -135,6 +137,15 @@ const WAGON_STATUS_OPTIONS = [ { label: "Detained", value: Freight.WagonStatus.Detained }, ]; +// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted +// on purpose: a wagon becomes ASSIGNED as a side effect of being built into a +// train, never by editing it directly. Setting it by hand produced wagons that +// claim to be assigned while coupled to nothing, which the yard workspace then +// counts as in-yard stock. +const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter( + (o) => o.value !== Freight.WagonStatus.Assigned, +); + @@ -333,7 +344,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, - { name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS }, + { name: "status", label: "Status", type: "select", required: true, options: WAGON_EDITABLE_STATUS_OPTIONS }, { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index aae182103..6fbc32cce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -20,7 +20,7 @@ import { useDebouncedValue } from "@mantine/hooks"; import { isAxiosError } from "axios"; import { ArrowRight, - Ban, + // Ban, — used only by the commented-out "Cancel schedule" row action CalendarClock, Clock, Eye, @@ -196,7 +196,7 @@ export default function TrainScheduleV2ListPage() { }), ); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); - const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); + // const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the // API rejects them, so keep them out of the picker entirely. @@ -464,6 +464,9 @@ export default function TrainScheduleV2ListPage() { Booking window settings ) : null} + {/* Cancel schedule — hidden for now (frontend only; the + cancelSchedule mutation is untouched). Restore by + uncommenting. {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( ) : null} + */} @@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() { }, }, ]; - }, [navigate, cancel.isPending, cancel, toast]); + }, [navigate, toast]); const handleCreate = async () => { if (!routeId || !scheduleDate || !trainId) { diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx index 866104cda..1c7dfdc7a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx @@ -344,6 +344,11 @@ export function Step2ServiceType({ if (isIntercity && form.getValues("paymentCurrency") !== "ETB") { form.setValue("paymentCurrency", "ETB", { shouldValidate: true }); } + // The customs clearing agent field is hidden for intercity — drop any value + // carried over from a draft or an operation-type switch. + if (isIntercity && form.getValues("customsClearingAgent")) { + form.setValue("customsClearingAgent", "", { shouldDirty: true }); + } }, [isIntercity, form]); return ( @@ -538,7 +543,9 @@ export function Step2ServiceType({ )} - {includesCustoms ? ( + {/* Intercity (domestic) moves never cross a border, so no customs + clearing agent is collected. */} + {isIntercity ? null : includesCustoms ? ( Date: Mon, 20 Jul 2026 17:03:31 +0300 Subject: [PATCH 04/15] Fix actual price minor --- .../backoffice/src/app/reports/payment-discrepancy/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx index 84bd6cfc8..c32b3f506 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx @@ -81,7 +81,7 @@ function exportCsv(rows: DiscrepancyRow[]) { new Date(r.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }), r.seatType, r.coachNumber ?? '—', - `${r.actualCurrency} ${r.actualMinor.toFixed(2)}`, + `${r.actualCurrency} ${(r.actualMinor / 100).toFixed(2)}`, `${r.paidCurrency} ${r.paidMinor.toFixed(2)}`, `${r.balanceCurrency} ${r.balanceMinor.toFixed(2)}`, ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')); @@ -359,7 +359,7 @@ export default function PaymentDiscrepancyPage() { )} - {fmtMoney(row.actualMinor, row.actualCurrency)} + {fmtMoney(row.actualMinor / 100, row.actualCurrency)} {fmtMoney(row.paidMinor, row.paidCurrency)} From 99eb35ff75a99f2c804f14119fc52a7658b68e01 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 21:50:34 +0300 Subject: [PATCH 05/15] Payment discrepancy report updates --- .../src/modules/reports/reports.controller.ts | 17 + .../src/modules/reports/reports.service.ts | 151 +++++ .../src/app/reports/passengers/page.tsx | 17 + .../app/reports/payment-discrepancy/page.tsx | 6 +- .../src/app/reports/payments/layout.tsx | 3 + .../src/app/reports/payments/page.tsx | 536 ++++++++++++++++++ .../src/components/layout/Sidebar.tsx | 10 +- 7 files changed, 733 insertions(+), 7 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/payments/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 8dceea63a..7df394090 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -47,6 +47,23 @@ export class ReportsController { return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search }); } + @Get("payments") + @ApiOperation({ summary: "Payments collected for a schedule" }) + getPaymentsReport(@Query('scheduleId') scheduleId: string) { + return this.service.getPaymentsReport(scheduleId); + } + + @Get("payments/discrepancy") + @ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" }) + getPaymentDiscrepancyBySchedule( + @Query('scheduleId') scheduleId: string, + @Query('search') search?: string, + @Query('seatClass') seatClass?: string, + @Query('sort') sort?: string, + ) { + return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort }); + } + @Get(":reportId") @ApiOperation({ summary: "Get report by ID" }) getReport(@Param("reportId") reportId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 6236422bb..9067ffe56 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -843,6 +843,157 @@ export class ReportsService { })); } + async getPaymentsReport(scheduleId: string) { + const bookings = await this.prisma.booking.findMany({ + where: { + scheduleId, + status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + include: { + paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } }, + seats: { + where: { leg: 1 }, + select: { + passengerName: true, + fareMinor: true, + passengerCategory: true, + seatLabelSnapshot: true, + seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } }, + }, + }, + passenger: { select: { user: { select: { phone: true, fullName: true } } } }, + }, + }); + + const rows = bookings.map(b => { + const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); + const paidMinor = Math.round(b.paymentIntent!.amountMinor); + return { + bookingRef: b.bookingRef, + passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—', + phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—', + method: b.paymentIntent!.method, + paidAt: b.paymentIntent!.paidAt, + actualMinor, + paidMinor, + currency: 'ETB', + passengerCount: b.seats.length, + }; + }); + + const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0); + const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0); + + const byMethod = rows.reduce((acc, r) => { + acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor; + return acc; + }, {} as Record); + + return { totalActualMinor, totalPaidMinor, byMethod, rows }; + } + + async getPaymentDiscrepancyBySchedule(scheduleId: string, params: { + search?: string; + seatClass?: string; + sort?: string; + }) { + const bookings = await this.prisma.booking.findMany({ + where: { + scheduleId, + status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any }, + paymentIntent: { status: 'SUCCEEDED' }, + }, + include: { + paymentIntent: { select: { amountMinor: true, currency: true } }, + schedule: { + include: { + originStation: { select: { name: true } }, + destinationStation: { select: { name: true } }, + }, + }, + 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, + coach: { select: { number: true, coachType: { select: { name: true } } } }, + }, + }, + }, + }, + passenger: { select: { user: { select: { phone: true, fullName: true } } } }, + }, + }); + + let rows = bookings.map(b => { + const pi = b.paymentIntent!; + const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0); + const paidMinor = Math.round(pi.amountMinor); + const varianceMinor = actualMinor - paidMinor; + + // Per-seat-class breakdown + const byClass = new Map(); + for (const s of b.seats) { + const key = s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? 'Unknown'; + if (!byClass.has(key)) byClass.set(key, []); + byClass.get(key)!.push({ + seatClass: key, + coachNumber: s.seat?.coach?.number ?? null, + seatNumber: s.seat?.seatNumber ?? null, + fareMinor: s.fareMinor ?? 0, + }); + } + + const breakdown = [...byClass.entries()].map(([seatClass, seats]) => ({ + seatClass, + seats: seats.map(s => ({ coachNumber: s.coachNumber, seatNumber: s.seatNumber })), + totalFareMinor: seats.reduce((s, x) => s + x.fareMinor, 0), + count: seats.length, + })); + + const firstSeat = b.seats[0]; + return { + bookingRef: b.bookingRef, + 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, + paidMinor, + varianceMinor, + breakdown, + }; + }).filter(r => r.varianceMinor > 0); + + if (params.search?.trim()) { + const q = params.search.trim().toUpperCase(); + rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q)); + } + if (params.seatClass?.trim()) { + const sc = params.seatClass.trim().toLowerCase(); + rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc))); + } + if (params.sort === 'asc') { + rows.sort((a, b) => a.varianceMinor - b.varianceMinor); + } else { + rows.sort((a, b) => b.varianceMinor - a.varianceMinor); + } + + return { total: rows.length, rows }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 93470daeb..a028faa8c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -76,6 +76,7 @@ export default function PassengersReportPage() { const [filterCoach, setFilterCoach] = useState(""); const [filterOrigin, setFilterOrigin] = useState(""); const [filterSeatClass, setFilterSeatClass] = useState(""); + const [filterCoachNumber, setFilterCoachNumber] = useState(""); const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery< ScheduleOption[] @@ -110,10 +111,12 @@ export default function PassengersReportPage() { const originOptions = [ ...new Set(passengerList.map((p) => p.origin).filter(Boolean)), ].sort() as string[]; + const coachNumberOptions = coachOptions; const filteredList = passengerList .filter((p) => { if (filterCoach && p.coachNumber !== filterCoach) return false; + if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false; if (filterOrigin && p.origin !== filterOrigin) return false; if (filterSeatClass && p.seatClassName !== filterSeatClass) return false; if (listSearch.trim()) { @@ -209,7 +212,9 @@ export default function PassengersReportPage() { setTab("occupancy"); setListSearch(""); setFilterCoach(""); + setFilterCoachNumber(""); setFilterOrigin(""); + setFilterSeatClass(""); }} disabled={loadingSchedules} > @@ -477,6 +482,18 @@ export default function PassengersReportPage() { ))} + setSearch(e.target.value.toUpperCase())} + placeholder="Booking ref…" + className="input pl-8 pr-7 font-mono text-sm w-full" + /> + {search && ( + + )} + + + + {data && data.rows.length > 0 && ( + + )} + + + {isLoading && ( +
+ Loading… +
+ )} + {isError &&

Failed to load discrepancy data.

} + + {data && ( +
+
+

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

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

+ Fare breakdown by seat class +

+ + + + + + + + + + + {r.breakdown.map((b, bi) => ( + + + + + + + ))} + + + + + +
Seat ClassSeatsCountTotal Fare
{b.seatClass} + {b.seats.map(s => [s.coachNumber, s.seatNumber ? `#${s.seatNumber}` : null].filter(Boolean).join(' ')).join(', ') || '—'} + {b.count}{fmt(b.totalFareMinor)}
Total actual vs paid + {fmt(r.actualMinor)} / {fmt(r.paidMinor)} + + (+{fmt(r.varianceMinor)}) + +
+
No discrepancies found.
+ +
+ )} + + ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +type Tab = 'payments' | 'discrepancy'; + +export default function PaymentsReportPage() { + const [scheduleId, setScheduleId] = useState(''); + const [tab, setTab] = useState('payments'); + + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), + }); + const schedules = schedulesRaw ?? []; + + const tabs: { key: Tab; label: string }[] = [ + { key: 'payments', label: 'Payments Collected' }, + { key: 'discrepancy', label: 'Discrepancy' }, + ]; + + return ( +
+
+

Payments Report

+

Payments collected and discrepancies for a schedule

+
+ + {/* Schedule selector */} +
+
+
+ + +
+
+
+ + {scheduleId ? ( + <> + {/* Schedule info banner */} +
+
+ +
+
+

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

+
+
+ + {/* Tabs */} +
+ {tabs.map(t => ( + + ))} +
+ + {tab === 'payments' && } + {tab === 'discrepancy' && } + + ) : ( +
+ +

Select a schedule above to load the payments report

+
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 14ff2f161..ca81b5514 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -121,9 +121,10 @@ 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: '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 ( From 43df24142cfb05845ef653a00d89fc15c3a8304f Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 20 Jul 2026 21:55:17 +0300 Subject: [PATCH 06/15] Add container and contract information cards to booking detail views - Integrated CompanyInfoCard, ContainersCard, and ContractInfoCard into ChangesRequestedView, DraftBookingView, and ReadonlyBookingView components. - Updated utils to handle new booking detail types and improve container summary calculations. - Enhanced ShipmentDetailsCard and ScheduleCard to utilize updated booking detail structure and display relevant information more effectively. --- .../ChangesRequestedView.tsx | 8 + .../BookingDetailPage/DraftBookingView.tsx | 8 + .../BookingDetailPage/ReadonlyBookingView.tsx | 5 + .../BookingDetailPage/booking-detail-types.ts | 93 +++++++++ .../components/CompanyInfoCard.tsx | 117 +++++++++++ .../components/ContainersCard.tsx | 160 +++++++++++--- .../components/ContractInfoCard.tsx | 196 ++++++++++++++++++ .../components/ScheduleCard.tsx | 12 +- .../components/ShipmentDetailsCard.tsx | 112 ++++++---- .../pages/bookings/BookingDetailPage/utils.ts | 98 ++++++++- 10 files changed, 738 insertions(+), 71 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx index 0599d685e..6b4c8927d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ChangesRequestedView.tsx @@ -21,6 +21,9 @@ import { useResubmitFlow } from "@/pages/bookings/resubmit/useResubmitFlow"; import { CardTitle, PageShell, SectionCard } from "./components/layout"; import { BodyGrid } from "./components/layout"; +import { CompanyInfoCard } from "./components/CompanyInfoCard"; +import { ContainersCard } from "./components/ContainersCard"; +import { ContractInfoCard } from "./components/ContractInfoCard"; import { ActionRequiredBanner, MutationErrors } from "./components/Notices"; import { PageHeader } from "./components/PageHeader"; import { EstimateCard } from "./components/pricing"; @@ -94,6 +97,10 @@ export function ChangesRequestedView({ <> + + + + Your documents @@ -143,6 +150,7 @@ export function ChangesRequestedView({ chip="Not invoiced" /> + setCancelDialogOpen(true)} /> } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx index d372f6e37..277bd1a24 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/DraftBookingView.tsx @@ -29,6 +29,9 @@ import type { Freight } from "@edr/types"; import { REQUIRED_DOC_FIELDS } from "./constants"; import { CardTitle, PageShell, SectionCard } from "./components/layout"; +import { CompanyInfoCard } from "./components/CompanyInfoCard"; +import { ContainersCard } from "./components/ContainersCard"; +import { ContractInfoCard } from "./components/ContractInfoCard"; import { CountChip, DocRow, IconSquare } from "./components/Documents"; import { EstimateCard } from "./components/pricing"; import { HeaderButton, PageHeader } from "./components/PageHeader"; @@ -241,6 +244,10 @@ export function DraftBookingView({ + + + + {/* Documents (uploadable) */} @@ -399,6 +406,7 @@ export function DraftBookingView({ chip="Not invoiced" /> + setCancelDialogOpen(true)} /> } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 244a38739..fc701b924 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -16,8 +16,10 @@ import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { DocumentsTab } from "./components/DocumentsTab"; +import { CompanyInfoCard } from "./components/CompanyInfoCard"; import { ContainersCard } from "./components/ContainersCard"; import { ContractCard } from "./components/ContractCard"; +import { ContractInfoCard } from "./components/ContractInfoCard"; import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard"; import { KeyFactsStrip } from "./components/KeyFactsStrip"; import { MileSummaryCard } from "./components/MileSummaryCard"; @@ -271,6 +273,8 @@ export function ReadonlyBookingView({ + + {canAssignCustomerTruck && ( @@ -300,6 +304,7 @@ export function ReadonlyBookingView({ title="Consignment & Schedule" consignment /> + } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts new file mode 100644 index 000000000..7479e758d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/booking-detail-types.ts @@ -0,0 +1,93 @@ +import type { Freight } from "@edr/types"; + +/** + * `GET /api/bookings/:id` serializes the raw TypeORM `Booking` entity with its + * relations attached (company, bookingContainers → units, shippingLine, + * cargoType…). That's a strict superset of the `Freight.IBooking` DTO, which + * doesn't declare these relations (and still lists a couple of fields — + * `freightSubtype`, the string-enum `serviceType` — that the API never + * actually sends). This augments the shared type with what the endpoint + * really returns so the detail page can render it without unsafe casts. + */ + +export interface BookingContainerUnitDetail { + id: string; + containerNumber: string; + sealNumber?: string | null; + vgmTons: number | string; + isHazardous?: boolean; + isReefer?: boolean; + isReturn?: boolean; + receivedToPort?: boolean; + receivedAt?: string | null; + grnNumber?: string | null; + sortOrder?: number; +} + +export interface BookingContainerLineDetail { + id: string; + quantity: number; + vgmPerUnitTons: number | string; + totalVgmTons: number | string; + hazardousQuantity?: number; + reeferQuantity?: number; + returnQuantity?: number; + isOverweight?: boolean; + overweightExcessTons?: number | string | null; + containerNumber?: string | null; + containerType?: { + code: string; + label?: string | null; + sizeFt?: number | null; + isReefer?: boolean | null; + } | null; + units?: BookingContainerUnitDetail[]; +} + +export interface BookingShippingLineDetail { + code: string; + label: string; + showExtraFeeNotice?: boolean; +} + +export interface BookingCargoTypeDetail { + code: string; + cargoTypeName: string; + unitOfMeasure?: string | null; +} + +/** The API's real (object) shape for the joined service-type relation. */ +export type BookingServiceTypeRef = NonNullable; + +export type BookingDetail = Freight.IBooking & { + /** Billed-to company relation, always joined on the detail endpoint. */ + company?: Freight.BookingRequestCompany | null; + isGovernment?: boolean; + governmentInstitution?: string | null; + /** Real container line-items (with per-unit numbers/seals/VGM). */ + bookingContainers?: BookingContainerLineDetail[] | null; + shippingLine?: BookingShippingLineDetail | null; + cargoType?: BookingCargoTypeDetail | null; + cargoFreeText?: string | null; + /** Joined paired-booking relation (consolidation partner), not just its id. */ + consolidationPartner?: { + id: string; + reference: string; + status: string; + } | null; +}; + +/** + * `booking.serviceType` is declared as the legacy `"RAIL_ONLY" | + * "RAIL_AND_FORWARDING"` string enum on `Freight.IBooking`, but the API + * actually sends the joined ServiceType relation object (`{ code, + * serviceName, includesFirstMile, includesLastMile, includesCustoms, … }`). + * Read it through this helper instead of comparing directly — see + * `serviceTypeLabel()` in `utils.ts`. + */ +export function rawServiceType( + booking: BookingDetail, +): string | BookingServiceTypeRef | null | undefined { + return (booking as unknown as { serviceType?: string | BookingServiceTypeRef | null }) + .serviceType; +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx new file mode 100644 index 000000000..bdcdb71fd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CompanyInfoCard.tsx @@ -0,0 +1,117 @@ +import { Box, Divider, Group, Text } from "@mantine/core"; +import { Building2, Landmark, Mail, MapPin, Phone, User } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { BookingDetail } from "../booking-detail-types"; +import { CardTitle, SectionCard } from "./layout"; + +function InfoRow({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( + + + {icon} + + + + {label} + + + {value} + + + + ); +} + +/** + * Customer/company information billed on this booking — the joined + * `company` relation the detail endpoint always returns (name, TIN, contact + * details), which the previous UI never surfaced at all. + */ +export function CompanyInfoCard({ booking }: { booking: BookingDetail }) { + const company = booking.company; + if (!company) return null; + + const contact = company.contactPersonName + ? company.contactPersonPhone + ? `${company.contactPersonName} · ${company.contactPersonPhone}` + : company.contactPersonName + : null; + + return ( + + + Customer Information + {booking.isGovernment && ( + + + Government + + )} + + + {company.name || "—"} + + {booking.governmentInstitution && ( + + {booking.governmentInstitution} + + )} + + + + + {company.tin && ( + } label="TIN" value={company.tin} /> + )} + {company.email && ( + } label="Email" value={company.email} /> + )} + {company.phone && ( + } label="Phone" value={company.phone} /> + )} + {company.address && ( + } label="Address" value={company.address} /> + )} + {contact && ( + } label="Contact person" value={contact} /> + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx index 75fe65ec7..abf9e72eb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContainersCard.tsx @@ -1,22 +1,99 @@ import { Box, Group, Table, Text } from "@mantine/core"; +import { AlertTriangle, Flame, Snowflake, Undo2 } from "lucide-react"; +import type { ReactNode } from "react"; -import type { Freight } from "@edr/types"; - +import type { + BookingContainerLineDetail, + BookingContainerUnitDetail, + BookingDetail, +} from "../booking-detail-types"; +import { fmtWeight, totalVgmTons } from "../utils"; import { CardTitle, SectionCard } from "./layout"; -/** - * Per-container-type breakdown for container bookings (count, type, VGM). - * Renders nothing for bulk bookings, which have no container lines. - */ -export function ContainersCard({ booking }: { booking: Freight.IBooking }) { - const containers = booking.containers ?? []; - if (booking.freightType === "BULK" || containers.length === 0) return null; +function containerTypeLabel(line: BookingContainerLineDetail): string { + const t = line.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""} container`; + return t?.code ?? "Container"; +} - const totalUnits = containers.reduce((sum, c) => sum + Number(c.qty || 0), 0); - const totalVgm = containers.reduce( - (sum, c) => sum + Number(c.vgm || 0) * Number(c.qty || 0), - 0, +function Flag({ icon, label }: { icon: ReactNode; label: string }) { + return ( + + {icon} + {label} + ); +} + +function UnitRow({ unit }: { unit: BookingContainerUnitDetail }) { + return ( + + + + {unit.containerNumber} + + + + + {unit.sealNumber || "—"} + + + + + {Number(unit.vgmTons || 0) ? `${Number(unit.vgmTons).toLocaleString()} t` : "—"} + + + + + {unit.isHazardous && ( + } label="Hazardous" /> + )} + {unit.isReefer && } label="Reefer" />} + {unit.isReturn && } label="Return" />} + {!unit.isHazardous && !unit.isReefer && !unit.isReturn && ( + + — + + )} + + + + + {unit.receivedToPort ? "Received" : "Pending"} + + + + ); +} + +/** + * Per-container breakdown for container bookings — real per-line data + * (`bookingContainers`, joined with per-unit numbers/seals/VGM) rather than + * the legacy `booking.containers` DTO shape, which the detail endpoint + * never populates. Renders nothing for bulk bookings. + */ +export function ContainersCard({ booking }: { booking: BookingDetail }) { + const lines = booking.bookingContainers ?? []; + if (booking.freightType === "BULK" || lines.length === 0) return null; + + const totalUnits = lines.reduce((sum, c) => sum + Number(c.quantity || 0), 0); + const totalVgm = totalVgmTons(booking); + const allUnits = lines.flatMap((l) => l.units ?? []); return ( @@ -27,7 +104,7 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) { - +
Type @@ -39,28 +116,39 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) { - {containers.map((c, i) => { - const lineVgm = Number(c.vgm || 0) * Number(c.qty || 0); + {lines.map((c, i) => { + const lineVgm = Number(c.totalVgmTons || 0); return ( - + - - {c.type} - + + + {containerTypeLabel(c)} + + {c.isOverweight && ( + } label="Overweight" /> + )} + {!!c.hazardousQuantity && ( + } label={`${c.hazardousQuantity} hazardous`} /> + )} + {!!c.reeferQuantity && ( + } label={`${c.reeferQuantity} reefer`} /> + )} + - {c.qty} + {c.quantity} - {c.vgm ? `${c.vgm} t` : "—"} + {fmtWeight(Number(c.vgmPerUnitTons || 0))} - {lineVgm ? `${lineVgm.toLocaleString()} t` : "—"} + {fmtWeight(lineVgm)} @@ -69,6 +157,30 @@ export function ContainersCard({ booking }: { booking: Freight.IBooking }) {
+ {allUnits.length > 0 && ( + + + Container numbers + + + + + Container no. + Seal no. + VGM + Flags + Port status + + + + {allUnits.map((u) => ( + + ))} + +
+
+ )} + - {totalVgm.toLocaleString()} t + {fmtWeight(totalVgm)}
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx new file mode 100644 index 000000000..f86925fc6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ContractInfoCard.tsx @@ -0,0 +1,196 @@ +import { Anchor, Box, Divider, Group, Text } from "@mantine/core"; +import { FileText, Link2 } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import type { BookingDetail } from "../booking-detail-types"; +import { fmtDate } from "../utils"; +import { CardTitle, SectionCard } from "./layout"; + +function Field({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +function Row({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * Contract terms for this booking — validity window, financial terms, + * customs/currency, renewal chain — none of which the detail page surfaced + * before even though the booking always carries them. + */ +export function ContractInfoCard({ booking }: { booking: BookingDetail }) { + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; + const hasValidity = booking.contractValidFrom || booking.contractValidUntil; + + return ( + + + Contract Information + {booking.contractId && ( + + View full contract → + + )} + + + + + + {booking.contractReference ?? booking.reference} + + ) : ( + (booking.contractReference ?? booking.reference) + ) + } + /> + + + + + + + + + {hasValidity && ( + + + + + )} + + {isGeneralContract && (booking.startDate || booking.endDate) && ( + + + + + )} + + {isGeneralContract && booking.expiresAt && ( + + + + + )} + + {booking.customsClearingEnabled && ( + + + + + )} + + {booking.previousContractId && ( + + + + + Previous contract + + + } + /> + + + )} + + + {booking.financialTerms && ( + <> + + + Financial terms + + + {booking.financialTerms} + + + )} + + {booking.contractSummary && ( + <> + + + + + Contract summary + + + + {booking.contractSummary} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx index 7a6323a58..fc7935962 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ScheduleCard.tsx @@ -1,11 +1,10 @@ import { Box, Group, Text } from "@mantine/core"; import type { ReactNode } from "react"; -import type { Freight } from "@edr/types"; - import { bookingStatusLabel } from "@/pages/bookings/booking-display"; -import { fmtDate, isDraftLike, isNegative } from "../utils"; +import type { BookingDetail } from "../booking-detail-types"; +import { fmtDate, isDraftLike, isNegative, serviceTypeLabel } from "../utils"; import { CardTitle, SectionCard } from "./layout"; type Row = { label: string; value: ReactNode; muted?: boolean }; @@ -50,14 +49,11 @@ export function ScheduleCard({ title, consignment, }: { - booking: Freight.IBooking; + booking: BookingDetail; title: string; consignment?: boolean; }) { - const service = - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only"; + const service = serviceTypeLabel(booking); const equipmentReturn = booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return"; const assignedTrain: Row = { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx index d15730541..8051e9986 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -1,12 +1,45 @@ import { Box, Group, Text } from "@mantine/core"; import { FileText } from "lucide-react"; -import type { Freight } from "@edr/types"; - -import { containerSummary, fmtDate, yardLabel } from "../utils"; +import type { BookingDetail } from "../booking-detail-types"; +import { + commodityLabel, + containerSummary, + fmtDate, + fmtWeight, + serviceTypeLabel, + shippingLineLabel, + totalVgmTons, + yardLabel, +} from "../utils"; import { CardTitle, SectionCard } from "./layout"; -export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) { +function Badge({ label, tone }: { label: string; tone: "amber" | "blue" }) { + const palette = + tone === "amber" + ? { bg: "#FFFBEB", border: "#FDE68A", color: "#92400E" } + : { bg: "#EAF1FE", border: "#CFDDFB", color: "#1E40AF" }; + return ( + + {label} + + ); +} + +export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) { + const weight = totalVgmTons(booking); const rows: [string, string][][] = [ [ ["Origin yard", yardLabel(booking.originYard)], @@ -14,22 +47,14 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) ], [ ["Freight type", booking.freightType === "BULK" ? "Bulk" : "Container"], - ["Commodity", booking.freightSubtype || "—"], + ["Commodity", commodityLabel(booking)], ], [ ["Containers / load", containerSummary(booking)], - [ - "Total weight (VGM)", - booking.cargoTotalWeightVgm ? `${booking.cargoTotalWeightVgm} t` : "—", - ], + ["Total weight (VGM)", fmtWeight(weight)], ], [ - [ - "Service type", - booking.serviceType === "RAIL_AND_FORWARDING" - ? "Rail + Forwarding" - : "Rail only", - ], + ["Service type", serviceTypeLabel(booking)], [ "Equipment return", booking.equipmentReturn === "WITH_RETURN" @@ -44,32 +69,49 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking }) ], ["Scheduled date", fmtDate(booking.scheduledDate)], ], - [["Assigned train", booking.trainId ?? "Not yet assigned"]], + [ + ["Shipping line", shippingLineLabel(booking)], + ["Assigned train", booking.trainId ?? "Not yet assigned"], + ], ]; + const badges: string[] = []; + if (booking.isHazardous) badges.push("Hazardous"); + if (booking.isRefrigerated) badges.push("Refrigerated"); + if (booking.customsClearingEnabled) badges.push("Customs clearance"); + return ( Shipment Details - - - {booking.contractType === "RENEWAL" - ? "Renewal contract" - : "New contract"} + + {badges.map((b) => ( + + ))} + + + {booking.contractType === "RENEWAL" + ? "Renewal contract" + : "New contract"} + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 432dcf8f5..924ee16a4 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -2,6 +2,8 @@ import { format } from "date-fns"; import type { Freight } from "@edr/types"; +import { rawServiceType, type BookingDetail } from "./booking-detail-types"; + export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; export const isDraftLike = (s: string) => s === "DRAFT" || s === "CHANGES_REQUESTED"; @@ -37,17 +39,105 @@ export function yardLabel(y?: Freight.IBooking["originYard"]) { return y?.label ?? y?.code ?? "—"; } -export function containerSummary(b: Freight.IBooking) { +function containerLineLabel(c: NonNullable[number]) { + const t = c.containerType; + if (t?.label) return t.label; + if (t?.sizeFt) return `${t.sizeFt}ft${t.isReefer ? " Reefer" : ""}`; + return t?.code ?? "Container"; +} + +/** + * The real per-line container data lives on `bookingContainers` (joined + * relation, with per-unit numbers + VGM) — `booking.containers` is a + * frontend-only DTO shape the create/update flows use that the detail + * endpoint never populates, so it's kept only as a last-resort fallback. + */ +export function containerSummary(b: BookingDetail) { + const lines = b.bookingContainers ?? []; + if (lines.length > 0) { + return lines.map((c) => `${c.quantity} × ${containerLineLabel(c)}`).join(", "); + } if (b.containers?.length) { return b.containers.map((c) => `${c.qty} × ${c.type}`).join(", "); } return b.freightType === "BULK" ? "Bulk cargo" : "—"; } -export function bookingSubtitle(b: Freight.IBooking) { - const cargo = +/** + * Total shipped weight (VGM), in tons. Container bookings compute the real + * total from `bookingContainers[].totalVgmTons` (per-line quantity × VGM) + * because `cargoTotalWeightVgm` is often left at 0 for container freight — + * the VGM is captured per container, not as a single booking-level figure. + * Falls back to `cargoTotalWeightVgm` for bulk freight / legacy rows. + */ +export function totalVgmTons(b: BookingDetail): number { + const lines = b.bookingContainers ?? []; + if (lines.length > 0) { + const sum = lines.reduce((s, c) => s + Number(c.totalVgmTons || 0), 0); + if (sum > 0) return sum; + } + if (b.containers?.length) { + const sum = b.containers.reduce( + (s, c) => s + Number(c.vgm || 0) * Number(c.qty || 0), + 0, + ); + if (sum > 0) return sum; + } + return Number(b.cargoTotalWeightVgm || 0); +} + +export function fmtWeight(tons: number): string { + return tons > 0 ? `${tons.toLocaleString(undefined, { maximumFractionDigits: 3 })} t` : "—"; +} + +/** + * `booking.serviceType` is declared as the legacy "RAIL_ONLY" | + * "RAIL_AND_FORWARDING" string on the shared type, but the API sends the + * joined ServiceType relation object. Handle both shapes, with a fallback + * derived from the first/last-mile addresses when neither is present. + */ +export function serviceTypeLabel(b: BookingDetail): string { + const st = rawServiceType(b); + if (st && typeof st === "object") { + if (st.serviceName) return st.serviceName; + if (st.code) { + return st.code + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (m) => m.toUpperCase()); + } + } + if (typeof st === "string") { + return st === "RAIL_AND_FORWARDING" ? "Rail + Forwarding" : "Rail only"; + } + return b.firstMilePickupAddress || b.lastMileDeliveryAddress + ? "Rail + Forwarding" + : "Rail only"; +} + +/** Real commodity name from the joined cargo type, falling back to the + * free-text commodity entered at booking time. `freightSubtype` is a + * legacy field the API no longer sends. */ +export function commodityLabel(b: BookingDetail): string { + return ( + b.cargoType?.cargoTypeName || + b.cargoFreeText || b.freightSubtype || - (b.freightType === "BULK" ? "Bulk freight" : "Container freight"); + "—" + ); +} + +export function shippingLineLabel(b: BookingDetail): string { + return b.shippingLine?.label || b.shippingLine?.code || "—"; +} + +export function bookingSubtitle(b: BookingDetail) { + const cargo = + commodityLabel(b) !== "—" + ? commodityLabel(b) + : b.freightType === "BULK" + ? "Bulk freight" + : "Container freight"; const load = containerSummary(b); const route = `${yardLabel(b.originYard)} → ${yardLabel(b.destinationYard)}`; return [cargo, load, route].filter((p) => p && p !== "—").join(" · "); From 8aaccf3f367cfe72041d543a7eae394b4d0e1f94 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 20 Jul 2026 22:10:48 +0300 Subject: [PATCH 07/15] Enhance BookingRequestsPage layout and styling --- .../pages/bookings/BookingRequestsPage.tsx | 67 ++++++++------- .../pages/bookings/booking-requests-table.css | 84 +++++++++++++++++++ 2 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 8ca532819..1345d2855 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -58,6 +58,8 @@ import { type ColumnDef, } from "@edr/ui-common"; +import "./booking-requests-table.css"; + /** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */ type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT"; @@ -282,6 +284,8 @@ export default function BookingRequestsPage() { const columns: ColumnDef[] = [ { id: "booking", + size: 60, + minSize: 40, header: () => Booking, cell: ({ row }) => { const b = row.original; @@ -303,6 +307,8 @@ export default function BookingRequestsPage() { }, { id: "contract", + size: 60, + minSize: 40, header: () => Contract, cell: ({ row }) => { const ref = row.original.contractReference; @@ -329,6 +335,8 @@ export default function BookingRequestsPage() { }, { id: "bookingKind", + size: 60, + minSize: 40, header: () => Type, cell: ({ row }) => { const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT"; @@ -346,6 +354,8 @@ export default function BookingRequestsPage() { }, { id: "route", + size: 60, + minSize: 40, header: () => Route, cell: ({ row }) => { const b = row.original; @@ -376,8 +386,8 @@ export default function BookingRequestsPage() { }, { id: "status", - size: 200, - minSize: 180, + size: 60, + minSize: 40, header: () => Status, cell: ({ row }) => (
@@ -388,13 +398,11 @@ export default function BookingRequestsPage() { />
), - meta: { - headerClassName: "min-w-[11rem]", - cellClassName: "min-w-[11rem]", - }, }, { id: "scheduled", + size: 60, + minSize: 40, header: () => Scheduled, cell: ({ row }) => ( @@ -405,6 +413,8 @@ export default function BookingRequestsPage() { }, { id: "priority", + size: 60, + minSize: 40, header: () => Priority, cell: ({ row }) => ( @@ -412,7 +422,8 @@ export default function BookingRequestsPage() { }, { id: "actions", - size: 140, + size: 60, + minSize: 40, cell: ({ row }) => (
) : ( - - - + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css new file mode 100644 index 000000000..d5bb8df5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests-table.css @@ -0,0 +1,84 @@ +/* + * Scoped to .edr-booking-requests-table — the DataTable container div on the + * backoffice booking-requests list only; no other DataTable is affected. + * Mirrors the portal's /bookings table (bookings-table.css): horizontal + * scroll on the container, a sticky header row, and a sticky/shadowed + * action column — with a compact 40–60px column width band (content + * beyond that is clipped with an ellipsis) instead of the portal's + * content-sized columns. + */ +.edr-booking-requests-table { + overflow-x: auto; +} + +/* + * width: max-content — the table is exactly as wide as its columns need, + * never squeezed to fit the viewport; the container scrolls instead. + * min-width: 100% keeps it filling the card when content is narrow. + */ +.edr-booking-requests-table table { + table-layout: auto; + width: max-content; + min-width: 100%; +} + +/* Compact column band: 40px floor, 60px ceiling, ellipsis past that. */ +.edr-booking-requests-table th, +.edr-booking-requests-table td:not([colspan]) { + min-width: 40px; + max-width: 60px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* + * Full-width rows (loading skeleton / error / empty state) span every + * column via colspan — leave their sizing and wrapping alone. + */ +.edr-booking-requests-table td[colspan] { + max-width: none; + white-space: normal; +} + +/* Sticky header row. */ +.edr-booking-requests-table thead th { + position: sticky; + top: 0; + z-index: 1; +} + +/* + * Fixed, sticky action column. Overrides the inline width DataTable stamps + * from tanstack's column size (`size: 140` on the actions column) — hence + * !important. `:not([colspan])` keeps the full-width error/empty rows out. + */ +.edr-booking-requests-table th:last-child, +.edr-booking-requests-table td:last-child:not([colspan]) { + width: 60px !important; + min-width: 60px; + max-width: 60px; + position: sticky; + right: 0; + box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3); +} + +/* + * Sticky cells sit above the scrolling ones, so they need their own opaque + * background or the columns underneath show through. + */ +.edr-booking-requests-table td:last-child:not([colspan]) { + background: #f5f8fb; + z-index: 2; +} + +/* Row hover uses the tailwind `hover:bg-accent` class on the . */ +.edr-booking-requests-table tbody tr:hover td:last-child:not([colspan]) { + background: var(--accent, #f4fbf8); +} + +/* Header cell is sticky on both axes — it must outrank the body's sticky column. */ +.edr-booking-requests-table th:last-child { + background: #f4f7fa; + z-index: 3; +} From 19fe977f405025a0084a59199cb5b0645db326cc Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 22:15:32 +0300 Subject: [PATCH 08/15] Payment discrepancy report updates --- .../src/modules/reports/reports.service.ts | 42 +- .../src/app/reports/payments/page.tsx | 548 ++++++------------ 2 files changed, 203 insertions(+), 387 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 9067ffe56..5a2692f44 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -412,7 +412,9 @@ 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, @@ -420,12 +422,13 @@ export class ReportsService { 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, + label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`, })); } @@ -926,7 +929,8 @@ export class ReportsService { seat: { select: { seatNumber: true, - coach: { select: { number: true, coachType: { select: { name: true } } } }, + bedPosition: true, + coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } }, }, }, }, @@ -935,30 +939,26 @@ export class ReportsService { }, }); + 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); const paidMinor = Math.round(pi.amountMinor); const varianceMinor = actualMinor - paidMinor; - // Per-seat-class breakdown - const byClass = new Map(); - for (const s of b.seats) { - const key = s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? 'Unknown'; - if (!byClass.has(key)) byClass.set(key, []); - byClass.get(key)!.push({ - seatClass: key, - coachNumber: s.seat?.coach?.number ?? null, - seatNumber: s.seat?.seatNumber ?? null, - fareMinor: s.fareMinor ?? 0, - }); - } - - const breakdown = [...byClass.entries()].map(([seatClass, seats]) => ({ - seatClass, - seats: seats.map(s => ({ coachNumber: s.coachNumber, seatNumber: s.seatNumber })), - totalFareMinor: seats.reduce((s, x) => s + x.fareMinor, 0), - count: seats.length, + 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: s.fareMinor ?? 0, })); const firstSeat = b.seats[0]; diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx index cb19f5fec..4fe1a17a0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx @@ -3,40 +3,21 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { - CreditCard, AlertTriangle, Train, Download, Search, X, + AlertTriangle, Train, Download, Search, X, ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight, } from 'lucide-react'; import { apiClient } from '@/lib/api-client'; -import { formatDateTime } from '@/lib/utils'; // ── Types ───────────────────────────────────────────────────────────────────── -interface ScheduleOption { id: string; label: string; } +interface ScheduleOption { id: string; label: string; departureAt: string; } -interface PaymentRow { - bookingRef: string; +interface PassengerBreakdown { passengerName: string; - phone: string; - method: string; - paidAt: string | null; - actualMinor: number; - paidMinor: number; - currency: string; - passengerCount: number; -} - -interface PaymentsReport { - totalActualMinor: number; - totalPaidMinor: number; - byMethod: Record; - rows: PaymentRow[]; -} - -interface BreakdownEntry { seatClass: string; - seats: { coachNumber: string | null; seatNumber: string | null }[]; - totalFareMinor: number; - count: number; + coachNumber: string | null; + seatNumber: string | null; + fareMinor: number; } interface DiscrepancyRow { @@ -50,7 +31,7 @@ interface DiscrepancyRow { actualMinor: number; paidMinor: number; varianceMinor: number; - breakdown: BreakdownEntry[]; + breakdown: PassengerBreakdown[]; } interface DiscrepancyReport { total: number; rows: DiscrepancyRow[]; } @@ -61,7 +42,6 @@ const PAGE_SIZE = 20; function usePagination(items: T[], resetKey?: unknown) { const [page, setPage] = useState(1); - // reset to page 1 whenever resetKey changes (e.g. new data loaded) 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); @@ -105,10 +85,6 @@ function fmt(minor: number) { return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`; } -function methodLabel(m: string) { - return m.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -} - function downloadCsv(csv: string, filename: string) { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); @@ -117,134 +93,25 @@ function downloadCsv(csv: string, filename: string) { URL.revokeObjectURL(url); } -// ── Payments tab ────────────────────────────────────────────────────────────── +// ── Discrepancy page ────────────────────────────────────────────────────────── -function PaymentsTab({ scheduleId }: { scheduleId: string }) { - const { data, isLoading, isError } = useQuery({ - queryKey: ['payments-report', scheduleId], - queryFn: () => apiClient.get(`/reports/payments?scheduleId=${scheduleId}`), - enabled: !!scheduleId, - }); - - const pg = usePagination(data?.rows ?? [], scheduleId); - - const doExport = () => { - if (!data) return; - const headers = ['Booking Ref', 'Passenger', 'Phone', 'Method', 'Paid At', 'Actual (ETB)', 'Paid (ETB)', 'Passengers']; - const rows = data.rows.map(r => [ - r.bookingRef, - r.passengerName, - r.phone, - methodLabel(r.method), - r.paidAt ? new Date(r.paidAt).toLocaleString('en-GB') : '—', - (r.actualMinor / 100).toFixed(2), - (r.paidMinor / 100).toFixed(2), - String(r.passengerCount), - ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')); - downloadCsv([headers.join(','), ...rows].join('\n'), `payments-${scheduleId}.csv`); - }; - - if (!scheduleId) return null; - if (isLoading) return
Loading…
; - if (isError) return

Failed to load payments data.

; - if (!data) return null; - - const totalVarianceMinor = data.totalActualMinor - data.totalPaidMinor; - - return ( -
- {/* Summary cards */} -
- {[ - { label: 'Total Fare (Actual)', value: fmt(data.totalActualMinor), color: 'blue' }, - { label: 'Total Collected', value: fmt(data.totalPaidMinor), color: 'emerald' }, - { - label: 'Total Variance', - value: fmt(Math.abs(totalVarianceMinor)), - color: totalVarianceMinor === 0 ? 'emerald' : 'red', - sub: totalVarianceMinor === 0 ? 'Fully collected' : totalVarianceMinor > 0 ? 'Under-collected' : 'Over-collected', - }, - ].map(({ label, value, color, sub }) => ( -
-

{label}

-

{value}

- {sub &&

{sub}

} -
- ))} -
- - {/* By method */} - {Object.keys(data.byMethod).length > 0 && ( -
-

By Payment Method

-
- {Object.entries(data.byMethod).sort(([, a], [, b]) => b - a).map(([method, minor]) => ( -
- {methodLabel(method)} - {fmt(minor)} -
- ))} -
-
- )} - - {/* Rows table */} -
-
-

- Transactions ({data.rows.length}) -

- {data.rows.length > 0 && ( - - )} -
- - - - {['Booking Ref', 'Passenger', 'Phone', 'Method', 'Paid At', 'Actual', 'Paid', 'Pax'].map(h => ( - - ))} - - - - {pg.slice.map(r => ( - - - - - - - - - - - ))} - {data.rows.length === 0 && ( - - )} - -
{h}
{r.bookingRef}{r.passengerName}{r.phone}{methodLabel(r.method)} - {r.paidAt ? formatDateTime(r.paidAt) : '—'} - {fmt(r.actualMinor)}{fmt(r.paidMinor)}{r.passengerCount}
No payments found for this schedule.
- -
-
- ); -} - -// ── Discrepancy tab ─────────────────────────────────────────────────────────── - -function DiscrepancyTab({ scheduleId }: { scheduleId: string }) { +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(null); + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + 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({ queryKey: ['payments-discrepancy', scheduleId, search, seatClass, sort], queryFn: () => apiClient.get('/reports/payments/discrepancy', { @@ -277,201 +144,11 @@ function DiscrepancyTab({ scheduleId }: { scheduleId: string }) { downloadCsv([headers.join(','), ...rows].join('\n'), `discrepancy-${scheduleId}.csv`); }; - if (!scheduleId) return null; - - return ( -
- {/* Filters */} -
-
- - setSearch(e.target.value.toUpperCase())} - placeholder="Booking ref…" - className="input pl-8 pr-7 font-mono text-sm w-full" - /> - {search && ( - - )} -
- - - {data && data.rows.length > 0 && ( - - )} -
- - {isLoading && ( -
- Loading… -
- )} - {isError &&

Failed to load discrepancy data.

} - - {data && ( -
-
-

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

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

- Fare breakdown by seat class -

- - - - - - - - - - - {r.breakdown.map((b, bi) => ( - - - - - - - ))} - - - - - -
Seat ClassSeatsCountTotal Fare
{b.seatClass} - {b.seats.map(s => [s.coachNumber, s.seatNumber ? `#${s.seatNumber}` : null].filter(Boolean).join(' ')).join(', ') || '—'} - {b.count}{fmt(b.totalFareMinor)}
Total actual vs paid - {fmt(r.actualMinor)} / {fmt(r.paidMinor)} - - (+{fmt(r.varianceMinor)}) - -
-
No discrepancies found.
- -
- )} -
- ); -} - -// ── Main page ───────────────────────────────────────────────────────────────── - -type Tab = 'payments' | 'discrepancy'; - -export default function PaymentsReportPage() { - const [scheduleId, setScheduleId] = useState(''); - const [tab, setTab] = useState('payments'); - - const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ - queryKey: ['report-schedules'], - queryFn: () => apiClient.get('/reports/schedules'), - }); - const schedules = schedulesRaw ?? []; - - const tabs: { key: Tab; label: string }[] = [ - { key: 'payments', label: 'Payments Collected' }, - { key: 'discrepancy', label: 'Discrepancy' }, - ]; - return (

Payments Report

-

Payments collected and discrepancies for a schedule

+

Payment discrepancies for upcoming schedules

{/* Schedule selector */} @@ -482,7 +159,7 @@ export default function PaymentsReportPage() { setSearch(e.target.value.toUpperCase())} + placeholder="Booking ref…" + className="input pl-8 pr-7 font-mono text-sm w-full" + /> + {search && ( + + )}
- - - {/* Tabs */} -
- {tabs.map(t => ( + + + {data && data.rows.length > 0 && ( - ))} + )}
- {tab === 'payments' && } - {tab === 'discrepancy' && } + {isLoading && ( +
+ Loading… +
+ )} + {isError &&

Failed to load discrepancy data.

} + + {data && ( +
+
+

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

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

+ Fare breakdown +

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

Select a schedule above to load the payments report

+ +

Select a schedule above to load the discrepancy report

)} From 8e74604a07db647dbb0de2a3b027c4d1e054dacf Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Mon, 20 Jul 2026 22:35:01 +0300 Subject: [PATCH 09/15] Fix CSS formatting for bookings table class to ensure proper styling and layout. --- .../pages/bookings/BookingRequestsPage.tsx | 143 ++++++++---------- .../src/pages/bookings/bookings-table.css | 2 +- 2 files changed, 65 insertions(+), 80 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 1345d2855..3eb775e95 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -7,6 +7,7 @@ import { MultiSelect, Select, Stack, + Tabs, Text, TextInput, } from "@mantine/core"; @@ -32,6 +33,7 @@ import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; +import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -58,12 +60,10 @@ import { type ColumnDef, } from "@edr/ui-common"; -import "./booking-requests-table.css"; +/** The two booking-kind tabs: one-time vs general-contract bookings. */ +type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT"; -/** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */ -type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT"; - -const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [ +const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [ { value: "ONE_TIME", label: "One-time booking" }, { value: "GENERAL_CONTRACT", label: "General booking" }, ]; @@ -128,9 +128,9 @@ export default function BookingRequestsPage() { const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - // Booking kind is a filter now — one list holds both kinds (null = "all"). - const [kindFilter, setKindFilter] = useState(null); - // Filter controls (empty/null = "all"). + // Booking-kind tabs (one-time vs general contract) replace the old status tabs. + const [kindTab, setKindTab] = useState("ONE_TIME"); + // Per-tab filter controls (empty/null = "all"). const [statusFilter, setStatusFilter] = useState([]); const [directionFilter, setDirectionFilter] = useState(null); const [freightTypeFilter, setFreightTypeFilter] = useState(null); @@ -158,9 +158,9 @@ export default function BookingRequestsPage() { pageSize: pagination.pageSize, sortBy: "createdAt", sortOrder: "DESC", - // React Query cache key per kind selection ("ALL" when unfiltered). - tab: kindFilter ?? "ALL", - ...(kindFilter ? { bookingType: kindFilter } : {}), + // React Query cache key per kind tab. + tab: kindTab, + bookingType: kindTab, // Server-side free-text search (booking ref, customer, contract ref). ...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}), ...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}), @@ -182,7 +182,7 @@ export default function BookingRequestsPage() { }, [ pagination.pageIndex, pagination.pageSize, - kindFilter, + kindTab, debouncedQuery, statusFilter, directionFilter, @@ -226,7 +226,6 @@ export default function BookingRequestsPage() { }, [setPagination, pagination.pageSize]); const activeFilterCount = - (kindFilter ? 1 : 0) + (statusFilter.length ? 1 : 0) + (directionFilter ? 1 : 0) + (freightTypeFilter ? 1 : 0) + @@ -238,7 +237,6 @@ export default function BookingRequestsPage() { (scheduledFrom || scheduledTo ? 1 : 0); const clearFilters = useCallback(() => { - setKindFilter(null); setStatusFilter([]); setDirectionFilter(null); setFreightTypeFilter(null); @@ -284,8 +282,6 @@ export default function BookingRequestsPage() { const columns: ColumnDef[] = [ { id: "booking", - size: 60, - minSize: 40, header: () => Booking, cell: ({ row }) => { const b = row.original; @@ -307,8 +303,6 @@ export default function BookingRequestsPage() { }, { id: "contract", - size: 60, - minSize: 40, header: () => Contract, cell: ({ row }) => { const ref = row.original.contractReference; @@ -333,29 +327,8 @@ export default function BookingRequestsPage() { ); }, }, - { - id: "bookingKind", - size: 60, - minSize: 40, - header: () => Type, - cell: ({ row }) => { - const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT"; - return ( -
- - {isGeneral ? "General" : "One-time"} - -
- ); - }, - }, { id: "route", - size: 60, - minSize: 40, header: () => Route, cell: ({ row }) => { const b = row.original; @@ -386,8 +359,8 @@ export default function BookingRequestsPage() { }, { id: "status", - size: 60, - minSize: 40, + size: 200, + minSize: 180, header: () => Status, cell: ({ row }) => (
@@ -398,11 +371,20 @@ export default function BookingRequestsPage() { />
), + meta: { + headerClassName: "min-w-[11rem]", + cellClassName: "min-w-[11rem]", + }, + }, + { + id: "approval", + header: () => ( + Approval + ), + cell: ({ row }) => , }, { id: "scheduled", - size: 60, - minSize: 40, header: () => Scheduled, cell: ({ row }) => ( @@ -413,8 +395,6 @@ export default function BookingRequestsPage() { }, { id: "priority", - size: 60, - minSize: 40, header: () => Priority, cell: ({ row }) => ( @@ -422,8 +402,7 @@ export default function BookingRequestsPage() { }, { id: "actions", - size: 60, - minSize: 40, + size: 140, cell: ({ row }) => ( */} + { + setKindTab((value as BookingKindTab) ?? "ONE_TIME"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {BOOKING_KIND_TABS.map((t) => ( + + {t.label} + + ))} + + + @@ -540,18 +535,6 @@ export default function BookingRequestsPage() {
- { + setKindFilter((v as BookingKind | null) ?? null); + resetPage(); + }} + clearable + radius="lg" + style={{ minWidth: 190 }} + /> Date: Mon, 20 Jul 2026 22:48:50 +0300 Subject: [PATCH 11/15] Payment discrepancy report updates --- .../src/modules/reports/reports.service.ts | 20 ++++++--- .../src/app/reports/payments/page.tsx | 41 +++++++++++-------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 5a2692f44..23f89124a 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -418,6 +418,7 @@ export class ReportsService { select: { id: true, departureAt: true, + isPackageOnly: true, train: { select: { number: true } }, originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, @@ -428,7 +429,10 @@ export class ReportsService { return schedules.map((s) => ({ id: s.id, departureAt: s.departureAt, - label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`, + 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)' : '' + }`, })); } @@ -915,6 +919,7 @@ export class ReportsService { destinationStation: { select: { name: true } }, }, }, + package: { select: { id: true } }, seats: { where: { leg: 1 }, orderBy: [ @@ -953,29 +958,34 @@ export class ReportsService { const paidMinor = Math.round(pi.amountMinor); const varianceMinor = actualMinor - paidMinor; + const isPackage = !!(b as any).package; + const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor; + const effectiveVarianceMinor = effectiveActualMinor - paidMinor; + 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: s.fareMinor ?? 0, + 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, + actualMinor: effectiveActualMinor, paidMinor, - varianceMinor, + varianceMinor: effectiveVarianceMinor, breakdown, }; - }).filter(r => r.varianceMinor > 0); + }).filter(r => r.varianceMinor > 0 && r.paidMinor > 0); if (params.search?.trim()) { const q = params.search.trim().toUpperCase(); diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx index 4fe1a17a0..7ff4e5247 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payments/page.tsx @@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client'; // ── Types ───────────────────────────────────────────────────────────────────── -interface ScheduleOption { id: string; label: string; departureAt: string; } +interface ScheduleOption { id: string; label: string; departureAt: string; isPackage: boolean; } interface PassengerBreakdown { passengerName: string; @@ -22,6 +22,7 @@ interface PassengerBreakdown { interface DiscrepancyRow { bookingRef: string; + isPackage: boolean; seatClass: string; coachNumber: string | null; seatNumber: string | null; @@ -81,10 +82,15 @@ function Pagination({ page, totalPages, setPage, total }: { // ── Helpers ─────────────────────────────────────────────────────────────────── -function fmt(minor: number) { +function fmtMinor(minor: number) { return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`; } +// paidMinor from PaymentIntent.amountMinor is a Float stored as full units (not cents) +function fmtPaid(amount: number) { + return `ETB ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`; +} + function downloadCsv(csv: string, filename: string) { const blob = new Blob([csv], { type: 'text/csv' }); const url = URL.createObjectURL(blob); @@ -231,18 +237,10 @@ export default function PaymentsReportPage() { {data && (
-
+

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

- {data.rows.length > 0 && ( - - )}
@@ -270,7 +268,14 @@ export default function PaymentsReportPage() { - + - - + + @@ -314,14 +319,14 @@ export default function PaymentsReportPage() { - + ))} From 226f839ad0c4b046c9dbd7b9639116ab336569e2 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 22:52:17 +0300 Subject: [PATCH 12/15] Hide previous payment discrepancy link --- .../backoffice/src/components/layout/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index ca81b5514..98f76cc18 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -125,7 +125,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view }, { name: 'Passengers', href: '/reports/passengers', icon: Users, 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: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view }, // { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view }, ] }, From 296e3af7ddb5e2f973b8d1fd98b51929b31d8a1b Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 20 Jul 2026 23:08:32 +0300 Subject: [PATCH 13/15] Fix package price --- .../src/modules/payments/payments.service.ts | 36 +++++++++------ .../src/modules/reports/reports.service.ts | 9 ++-- .../backoffice/src/app/routes/page.tsx | 18 ++++---- .../backoffice/src/app/schedules/page.tsx | 3 ++ .../src/app/booking/confirmation/page.tsx | 6 ++- .../portal/src/app/booking/review/page.tsx | 5 ++- .../portal/src/lib/generate-voucher.ts | 45 ++++++++++++++----- 7 files changed, 81 insertions(+), 41 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index e57d94643..ac8c7ef66 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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 { + private async resolveBookingTotal(booking: { + id: string; + totalMinor: number; + bookingType: string; + packageId?: string | null; + priceTierId?: string | null; + displayTotalMinor?: number | null; + }): Promise { if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') { return booking.totalMinor; } - // For package round-trip bookings, recompute from the tier price to handle - // bookings created before the server fix stored the full round-trip total. + // New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their + // totalMinor was already computed in ETB at creation time — no recomputation needed. + if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) { + return booking.totalMinor; + } + // Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier. const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } }); if (!tier) return booking.totalMinor; - // Count adults and children from booking seats const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } }); const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1; const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length; - const adultFareMinor = tier.priceMinor * 2; // round-trip = 2 legs + // tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is + // always in the same units as totalMinor (which is always the ETB canonical). + const rawFare = tier.priceMinor * 2; + const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB' + ? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any) + : rawFare; const childFareMinor = Math.round(adultFareMinor * 0.1); - const correctTotal = adultCount * adultFareMinor + childCount * childFareMinor; - // If stored total already matches the correct round-trip total, use it as-is. - // If it's roughly half (single-leg), use the recomputed value. - return correctTotal; + return adultCount * adultFareMinor + childCount * childFareMinor; } async initiatePayment( diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 9067ffe56..078748929 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -684,10 +684,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 +796,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'; diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 5fa655a5e..52b875433 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -787,14 +787,6 @@ export default function RoutesPage() { title="Check-in cutoff override (minutes) for this stop" /> -
- updateStop(index, 'plannedDepartureTime', v)} - placeholder="Dep time" - label="Planned Departure" - /> -
+
+ updateStop(index, 'plannedDepartureTime', v)} + placeholder="Dep time" + label="Planned Departure" + /> +
{isExpanded ? : } {r.bookingRef} + {r.bookingRef} + {r.isPackage && ( + + package + + )} + {r.seatClass} {r.coachNumber && · {r.coachNumber}} @@ -279,12 +284,12 @@ export default function PaymentsReportPage() { {r.origin} → {r.destination} {fmt(r.actualMinor)}{fmt(r.paidMinor)}{fmtMinor(r.actualMinor)}{fmtPaid(r.paidMinor)} - {fmt(r.varianceMinor)} + {fmtMinor(r.varianceMinor)} {r.phone}{b.seatClass} {b.coachNumber ?? '—'} {b.seatNumber ?? '—'}{fmt(b.fareMinor)}{fmtMinor(b.fareMinor)}
Total actual vs paid - {fmt(r.actualMinor)} / {fmt(r.paidMinor)} - (+{fmt(r.varianceMinor)}) + {fmtMinor(r.actualMinor)} / {fmtPaid(r.paidMinor)} + (+{fmtMinor(r.varianceMinor)})