From 3e94aa08f15b0b9da95a194a4ef4b1653c6d5f04 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 04:22:03 +0000 Subject: [PATCH 01/73] update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles --- .../new-booking-form/step5-cargo-details.tsx | 142 ++++++++++++------ 1 file changed, 98 insertions(+), 44 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx index a02138373..ac336c47c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx @@ -26,6 +26,70 @@ type BookingForm = UseFormReturn< BookingFormValues >; +/** + * One numbered toggle per container unit in the line — tap units to mark how + * many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection + * fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N + * keeps 1..N-1 — the count is always derived, never free-typed, so it can't + * exceed the line quantity. + */ +function UnitCountToggles({ + total, + value, + onChange, + label, + activeBg, + activeBorder, + activeColor, +}: { + total: number; + value: string; + onChange: (v: string) => void; + label: string; + activeBg: string; + activeBorder: string; + activeColor: string; +}) { + const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0))); + + return ( +
+ + {label} · {count}/{total} selected + +
+ {Array.from({ length: total }, (_, i) => { + const selected = i < count; + return ( + + ); + })} +
+
+ ); +} + export function Step5CargoDetails({ form, referenceData, @@ -144,16 +208,6 @@ export function Step5CargoDetails({ const lineQtyOf = (index: number) => Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1); const lineMax = (index: number) => lineQtyOf(index); - // When a flag is switched on, default its count to the whole line. - const defaultLineQty = (index: number) => lineQtyOf(index).toString(); - // Clamp a typed value into 1..lineQty (empty stays empty so the field can be - // cleared; the schema flags an empty value as required while the switch is on). - const clampToLine = (raw: string, index: number) => { - if (raw === "") return ""; - const n = Number(raw); - if (Number.isNaN(n)) return raw; - return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString(); - }; // After the line quantity changes, pull any active count back within bounds. const clampDependentQty = (index: number, newLineQty: number) => { const max = Math.max(1, newLineQty); @@ -636,7 +690,7 @@ export function Step5CargoDetails({ hazField.onChange(v); form.setValue( `containers.${index}.hazardousQty`, - v ? defaultLineQty(index) : "0", + v ? "1" : "0", { shouldDirty: true, shouldValidate: true }, ); }} @@ -645,22 +699,22 @@ export function Step5CargoDetails({ name={`containers.${index}.hazardousQty`} control={form.control} render={({ field: hq, fieldState }) => ( - - hq.onChange( - clampToLine(e.currentTarget.value, index), - ) - } - onBlur={hq.onBlur} - error={fieldState.error?.message} - radius="md" - /> +
+ + {fieldState.error?.message ? ( + + {fieldState.error.message} + + ) : null} +
)} /> @@ -681,7 +735,7 @@ export function Step5CargoDetails({ reeField.onChange(v); form.setValue( `containers.${index}.reeferQty`, - v ? defaultLineQty(index) : "0", + v ? "1" : "0", { shouldDirty: true, shouldValidate: true }, ); }} @@ -690,22 +744,22 @@ export function Step5CargoDetails({ name={`containers.${index}.reeferQty`} control={form.control} render={({ field: rq, fieldState }) => ( - - rq.onChange( - clampToLine(e.currentTarget.value, index), - ) - } - onBlur={rq.onBlur} - error={fieldState.error?.message} - radius="md" - /> +
+ + {fieldState.error?.message ? ( + + {fieldState.error.message} + + ) : null} +
)} /> From 2c0e115c14f03a906b383826838ab20cfe2c1a84 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 04:23:10 +0000 Subject: [PATCH 02/73] update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles --- .../modules/bookings/bookings.repository.ts | 7 +- .../contracts/contract-booking.service.ts | 34 +++++ .../contracts/GlCreateBookingForm.tsx | 121 ++++++++++++------ 3 files changed, 120 insertions(+), 42 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 4803f988c..580150eb0 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -588,8 +589,10 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') // Contract reference for the list column + search (no entity relation on - // Booking → contract, so join by id and select just the reference). - .leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id') + // Booking → contract, so join the entity by id and select just the + // reference — a schema-qualified table string is parsed as alias.relation + // by TypeORM and crashes). + .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .addSelect('contract.reference', 'contract_reference') .where('booking.deleted_at IS NULL'); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c1eb4b4f8..125cf5f3d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -138,6 +138,11 @@ export class ContractBookingService { // no override. Checked before any row is written. if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); + // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ + // ≤ the cap, and drawdown bookings never pass through submit — so this is + // their only chance to hard-block an unbalanceable set. Entry order is + // irrelevant (the check sorts by weight before pairing). + await this.assert20ftPairableAtCreate(dto); } // Denormalize route/direction/freight onto the booking for the scheduling engine. @@ -761,6 +766,35 @@ export class ContractBookingService { } } + /** + * Hard-block booking creation when the 20ft container weights cannot be + * balanced onto wagons (pair diff over the global cap). Same rule the + * shipment-form preview reports as `pairingErrors`, enforced server-side. + */ + private async assert20ftPairableAtCreate( + dto: CreateBookingUnderContractDto, + ): Promise { + const twentyFtUnits = (dto.containers ?? []) + .filter((line) => (line.containerSize ?? '').includes('20')) + .flatMap((line, lineIdx) => + (line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + if (twentyFtUnits.length < 2) return; + + const maxDiff = await this.max20ftPairDiffTons(); + const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff); + if (violations.length) { + throw new BadRequestException( + `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index d967ffc3f..6f52d287f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -7,6 +7,7 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, + Badge, Box, Button, Center, @@ -18,6 +19,7 @@ import { Paper, Select, Stack, + Switch, Text, Textarea, TextInput, @@ -82,12 +84,13 @@ interface UnitDraft { containerNumber: string; sealNumber: string; vgmTons: number | string; + /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */ + hazardous: boolean; + reefer: boolean; } interface ContainerLineDraft { containerSize: string; - hazardousQuantity: number | string; - reeferQuantity: number | string; units: UnitDraft[]; } @@ -100,7 +103,13 @@ interface BulkLineDraft { } function emptyUnit(): UnitDraft { - return { containerNumber: "", sealNumber: "", vgmTons: "" }; + return { + containerNumber: "", + sealNumber: "", + vgmTons: "", + hazardous: false, + reefer: false, + }; } function bulkUnitOfMeasure( @@ -199,11 +208,13 @@ export default function GlCreateBookingForm() { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, - hazardousQuantity: c.hazardousQuantity ?? "0", - reeferQuantity: c.reeferQuantity ?? "", - units: Array.from({ length: Math.max(1, c.quantity) }, () => - emptyUnit(), - ), + // The request carries counts; pre-toggle the first N units so GL sees + // the customer's declared hazardous/reefer split and can adjust it. + units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({ + ...emptyUnit(), + hazardous: i < Number(c.hazardousQuantity ?? 0), + reefer: i < Number(c.reeferQuantity ?? 0), + })), })), ); } else if (lines.bulk) { @@ -229,8 +240,6 @@ export default function GlCreateBookingForm() { setContainerLines( containerSizes.map((size) => ({ containerSize: size, - hazardousQuantity: "0", - reeferQuantity: "0", units: [emptyUnit()], })), ); @@ -261,8 +270,8 @@ export default function GlCreateBookingForm() { containers: containerLines.map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - hazardousQuantity: Number(l.hazardousQuantity || 0), - reeferQuantity: Number(l.reeferQuantity || 0), + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, })), bulkQuantity: bulkLines.reduce( (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), @@ -384,12 +393,10 @@ export default function GlCreateBookingForm() { .map((l) => ({ containerSize: l.containerSize, quantity: l.units.length, - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), + // Counts are derived from the per-unit toggles — they can never + // exceed the line quantity. + hazardousQuantity: l.units.filter((u) => u.hazardous).length, + reeferQuantity: l.units.filter((u) => u.reefer).length, units: l.units.map((u) => ({ containerNumber: u.containerNumber, ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), @@ -652,7 +659,7 @@ export default function GlCreateBookingForm() { {line.containerSize} containers - + syncUnits(lineIdx, Number(v) || 0)} radius={10} styles={fieldStyles} + w={160} /> {contract.isHazardous ? ( - - patchLine(lineIdx, { hazardousQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.hazardous).length} hazardous + ) : null} {contract.isReefer ? ( - - patchLine(lineIdx, { reeferQuantity: v }) - } - radius={10} - styles={fieldStyles} - /> + + {line.units.filter((u) => u.reefer).length} refrigerated + ) : null} Per-container details {line.units.map((unit, unitIdx) => ( - + + {/* Per-unit flags: toggle exactly the containers that are + hazardous / refrigerated; line counts derive from these. */} + {contract.isHazardous ? ( + + {unitIdx === 0 ? ( + + Hazardous + + ) : null} + + patchUnit(lineIdx, unitIdx, { + hazardous: e.currentTarget.checked, + }) + } + /> + + ) : null} + {contract.isReefer ? ( + + {unitIdx === 0 ? ( + + Reefer + + ) : null} + + patchUnit(lineIdx, unitIdx, { + reefer: e.currentTarget.checked, + }) + } + /> + + ) : null} ))} From c650a2dcbd18563b36d8ddb679e400466526f752 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 04:26:14 +0000 Subject: [PATCH 03/73] update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles --- .../contracts/ContractRequestDetailPage.tsx | 67 ++++++++++++++----- packages/types/src/freight/contracts.ts | 7 ++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 6a451ba56..c81f5b919 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() { No cargo scope lines. ) : ( - - {(contract.cargoScope ?? []).map((s) => ( - - - - {s.containerSize ?? - s.cargoFreeText ?? - s.cargoTypeId ?? - "Cargo"} - - - ))} + + {(contract.cargoScope ?? []).map((s) => { + const isContainer = Boolean(s.containerSize); + // Bulk lines carry their commodity detail (name + unit); + // container lines carry the size (20ft / 40ft). + const title = isContainer + ? `${s.containerSize} container` + : (s.cargoType?.cargoTypeName ?? + s.cargoFreeText ?? + s.cargoType?.code ?? + "Bulk cargo"); + // quantityCap unit: containers for a size line, else the + // cargo type's unit of measure (tons / items / …), default tons. + const capUnit = isContainer + ? "containers" + : (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons"); + return ( + + +
+ + {title} + + + + {isContainer ? "Container" : "Bulk"} + + {s.cargoType?.code ? ( + + Code: {s.cargoType.code} + + ) : null} + + {s.quantityCap != null + ? `Cap: ${s.quantityCap} ${capUnit}` + : "Cap: uncapped"} + + +
+
+ ); + })}
)} diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index e62b410ba..fe653573d 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -132,6 +132,13 @@ export interface IContractCargoScope { /** "20ft" | "40ft"; null for bulk. */ containerSize?: string | null; cargoTypeId?: string | null; + /** Bulk cargo type detail (name + unit), loaded on the contract detail. */ + cargoType?: { + id: string; + code?: string | null; + cargoTypeName?: string | null; + unitOfMeasure?: string | null; + } | null; cargoFreeText?: string | null; /** * GENERAL contracts: total quantity bookable across all shipments on this line From f37078d51d294805d0583aeb7145daf470428a22 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 04:59:51 +0000 Subject: [PATCH 04/73] update joins in repository services to use entity classes and enhance booking form with hazardous/reefer toggles --- ...-AddWagonTypeFkToCargoAndContainerTypes.ts | 133 ++++++++++++++++++ .../modules/bookings/bookings.repository.ts | 6 +- .../rule-engine/dto/create-cargo-type.dto.ts | 8 ++ .../dto/create-container-type.dto.ts | 8 ++ .../rule-engine/entities/cargo-type.entity.ts | 15 ++ .../entities/container-type.entity.ts | 17 ++- .../repositories/cargo-types.repository.ts | 2 +- .../container-types.repository.ts | 2 +- .../repositories/rates.repository.ts | 2 +- .../weight-limit-rules.repository.ts | 2 +- .../services/cargo-types.service.ts | 1 + .../services/container-types.service.ts | 1 + .../train-scheduling.service.ts | 117 +++++++++++---- .../wagon-type-resolver.util.ts | 49 ------- .../backoffice/src/auth/types.ts | 2 + .../src/hooks/rule-engine/useRuleEngine.ts | 17 +++ .../backoffice/src/lib/permissions.ts | 10 +- .../src/pages/ruleEngine/CargoTypesPage.tsx | 35 ++++- .../ruleEngine/RuleEngineResourcePage.tsx | 18 ++- .../src/pages/ruleEngine/config/resources.ts | 8 ++ .../pages/contracts/ContractDetailPage.tsx | 111 +++++++++++++++ 21 files changed, 478 insertions(+), 86 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts delete mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts diff --git a/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts new file mode 100644 index 000000000..c7ab60577 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1940000000000-AddWagonTypeFkToCargoAndContainerTypes.ts @@ -0,0 +1,133 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Replace load-type string matching with a real wagon-type foreign key. + * + * Before this migration, train scheduling picked a wagon type by matching + * strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …) + * and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on + * `cargo_types` and `container_types` so scheduling resolves the wagon type + * through the relation instead. + * + * The columns are NULLABLE: cargo grouping rows and container/legacy cargo that + * never ship in bulk have no wagon type, and forcing one onto them is + * meaningless. Scheduling enforces the requirement at run time (it throws when a + * scheduled bulk cargo type or a container type in the batch has no wagon type). + * + * Backfill reproduces the old hardcoded resolution one final time so existing + * bulk cargo + container rows are not left unset. After this, the runtime map is + * removed — the FK is the single source of truth. + */ +export class AddWagonTypeFkToCargoAndContainerTypes1940000000000 + implements MigrationInterface +{ + name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // ── Columns + FKs ──────────────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS wagon_type_id uuid; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD COLUMN IF NOT EXISTS wagon_type_id uuid; + `); + + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD CONSTRAINT fk_cargo_types_wagon_type + FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ON DELETE RESTRICT; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + ADD CONSTRAINT fk_container_types_wagon_type + FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ON DELETE RESTRICT; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id + ON freight.cargo_types (wagon_type_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id + ON freight.container_types (wagon_type_id); + `); + + // ── Backfill: old cargo-code → wagon-code map (one last time) ───────────── + // COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2, + // COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default). + const cargoCodeToWagon: Record = { + COFFEE: "KW2", + GRAIN: "KW2", + WHEAT: "KW2", + SORGHUM: "KW2", + CORN: "KW2", + FERTILIZER: "PW2", + SUGAR: "PW2", + COAL: "KW3", + STEEL: "CW3", + ORE: "CW3", + }; + + for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) { + await queryRunner.query( + ` + UPDATE freight.cargo_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = $1 + AND UPPER(TRIM(ct.code)) = $2 + AND ct.wagon_type_id IS NULL; + `, + [wagonCode, cargoCode], + ); + } + + // Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3. + await queryRunner.query(` + UPDATE freight.cargo_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = 'CW3' + AND ct.wagon_type_id IS NULL + AND ct.unit_of_measure = 'PER_TON'; + `); + + // All container types → the old container default wagon NW5. + await queryRunner.query(` + UPDATE freight.container_types ct + SET wagon_type_id = wt.id + FROM freight.wagon_types wt + WHERE wt.code = 'NW5' + AND ct.wagon_type_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types + DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type; + `); + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 580150eb0..6dfe2b8b9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1082,8 +1082,10 @@ export class BookingsRepository extends BaseRepository { destinationYard: true, // units carry the real per-container numbers entered at booking time — // the wagon plan shows those instead of generated placeholders. - bookingContainers: { containerType: true, units: true }, - cargoType: true, + // containerType.wagonType + cargoType.wagonType drive wagon-type + // resolution during scheduling (FK, not the old load-type string map). + bookingContainers: { containerType: { wagonType: true }, units: true }, + cargoType: { wagonType: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index f30ebd501..76db7fa78 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -21,6 +21,14 @@ export class CreateCargoTypeDto { @IsUUID() parentGroupId?: string; + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.', + }) + @IsOptional() + @IsUUID('4') + wagonTypeId?: string | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index 52cfe274b..e0baf7251 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -30,6 +30,14 @@ export class CreateContainerTypeDto { @IsBoolean() isOpenTop?: boolean; + @ApiPropertyOptional({ + description: + 'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.', + }) + @IsOptional() + @IsUUID('4') + wagonTypeId?: string | null; + @ApiPropertyOptional({ default: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index c8ac35f25..ac8a2ea24 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,11 +1,13 @@ import { BaseEntity } from '@edr/api-common'; import { CargoUnitOfMeasure } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'cargo_types' }) @Index(['isActive']) @Index(['displayOrder']) @Index(['parentGroupId']) +@Index(['wagonTypeId']) @Index(['code']) export class CargoType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) @@ -25,6 +27,19 @@ export class CargoType extends BaseEntity { @Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true }) unitOfMeasure?: CargoUnitOfMeasure | null; + /** + * Wagon type that carries this (bulk) cargo. Replaces the former hardcoded + * cargo-code → wagon-code map: train scheduling resolves the bulk wagon type + * through this FK. Nullable — grouping rows and container/legacy cargo never + * carry it; scheduling throws if a scheduled bulk cargo type leaves it unset. + */ + @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) + wagonTypeId?: string | null; + + @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index e03078c19..f7cbeed99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -1,10 +1,12 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { WeightLimitRule } from './weight-limit-rule.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'container_types' }) @Index(['code']) @Index(['isActive']) +@Index(['wagonTypeId']) export class ContainerType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) code!: string; @@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity { @Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true }) isOpenTop!: boolean; + /** + * Wagon type that carries this container. Replaces the former hardcoded + * container wagon-code default (NW5): train scheduling resolves the container + * wagon type through this FK. Nullable; scheduling throws if a scheduled + * container type leaves it unset. + */ + @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) + wagonTypeId?: string | null; + + @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 496c2ce7b..5fba70fe1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index fe0a8f41e..0e4fb2716 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index a7260f7f1..a7b8e69ab 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -74,7 +74,7 @@ export class RatesRepository implements IRatesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 87d2febba..7432dfc34 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -65,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data); + await this.repo.update(id, data as never); return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index f80ada585..5470094a5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -82,6 +82,7 @@ export class CargoTypesService { requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeId: dto.wagonTypeId ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 38407f36a..629bf3023 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -64,6 +64,7 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, + wagonTypeId: dto.wagonTypeId ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index a10847c17..32a046356 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -15,7 +15,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, Not } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -38,6 +38,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -87,10 +89,6 @@ import { type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; -import { - getDefaultContainerWagonTypeCode, - pickBulkWagonType, -} from './wagon-type-resolver.util'; import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { @@ -2725,28 +2723,98 @@ export class TrainSchedulingService { return violations; } + /** + * Resolve the wagon type for a batch through the cargo-type / container-type + * `wagon_type_id` FK (replaces the former load-type string matching). Throws + * when the relevant type has no wagon type configured — scheduling is blocked + * until an admin assigns one on the cargo-type / container-type config screen. + */ private async resolveWagonType( freightType: 'CONTAINER' | 'BULK', bookingIds: string[], ): Promise { + const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + if (freightType === 'CONTAINER') { - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { code: getDefaultContainerWagonTypeCode(), isActive: true }, - }); - if (!wagonType) { - throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`); + // First container type present on the batch drives the container wagon + // type (matches the prior single-wagon-type-per-consist behavior). + const containerType = bookings + .flatMap((b) => b.bookingContainers ?? []) + .map((line) => line.containerType) + .find((ct): ct is NonNullable => Boolean(ct)); + if (!containerType) { + throw new BadRequestException('No container type found on the container booking(s)'); } + const wagonType = await this.loadWagonTypeForType( + containerType.wagonTypeId ?? null, + `Container type "${containerType.label ?? containerType.code}"`, + ); return wagonType; } - const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); - const cargoCode = bookings[0]?.cargoType?.code ?? null; - const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } }); - const picked = pickBulkWagonType(wagonTypes, cargoCode); - if (!picked) { - throw new NotFoundException('No suitable bulk wagon type found'); + const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct)); + if (!cargoType) { + throw new BadRequestException('No cargo type found on the bulk booking(s)'); } - return picked; + return this.loadWagonTypeForType( + cargoType.wagonTypeId ?? null, + `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`, + ); + } + + /** + * Load an active wagon type by FK id, throwing a clear error when the id is + * unset (type not configured) or points at a missing/inactive wagon type. + */ + private async loadWagonTypeForType( + wagonTypeId: string | null, + typeLabel: string, + ): Promise { + if (!wagonTypeId) { + throw new BadRequestException( + `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`, + ); + } + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { id: wagonTypeId, isActive: true }, + }); + if (!wagonType) { + throw new NotFoundException( + `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`, + ); + } + return wagonType; + } + + /** + * Soft wagon-type resolution for the customer-facing availability preview + * (getAvailableDaysForCargo). Reads the configured FK by cargo/container type; + * returns null (→ "no days") instead of throwing when nothing is configured, + * since this only estimates which days have wagons and creates no booking. + */ + private async resolveWagonTypeForPreview( + freightType: 'CONTAINER' | 'BULK', + cargoTypeCode: string | null, + ): Promise { + if (freightType === 'BULK') { + if (!cargoTypeCode) return null; + const cargoType = await this.dataSource.getRepository(CargoType).findOne({ + where: { code: cargoTypeCode }, + relations: { wagonType: true }, + }); + return cargoType?.wagonType?.isActive ? cargoType.wagonType : null; + } + + // Container preview: the input carries no specific container type, so use the + // wagon type of the first configured (active) container type. + const containerType = await this.dataSource + .getRepository(ContainerType) + .findOne({ + where: { isActive: true, wagonTypeId: Not(IsNull()) }, + relations: { wagonType: true }, + order: { displayOrder: 'ASC' }, + }); + return containerType?.wagonType?.isActive ? containerType.wagonType : null; } private async persistTrainSetWagons( @@ -3419,15 +3487,12 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; - const wagonTypes = await this.dataSource.getRepository(WagonType).find(); - - // Resolve the wagon type this cargo needs. - const requiredType = - input.freightType === 'BULK' - ? pickBulkWagonType(wagonTypes, input.cargoTypeCode) - : wagonTypes.find( - (wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive, - ); + // Resolve the wagon type this cargo needs via the cargo/container-type FK. + // Soft (customer availability preview): no days if unresolved, never throws. + const requiredType = await this.resolveWagonTypeForPreview( + input.freightType, + input.cargoTypeCode ?? null, + ); if (!requiredType) return { days: [] }; // How many wagons of that type the cargo needs. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts deleted file mode 100644 index bac0330f2..000000000 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-type-resolver.util.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; - -const CARGO_CODE_TO_WAGON_TYPE: Record = { - COFFEE: 'KW2', - GRAIN: 'KW2', - WHEAT: 'KW2', - SORGHUM: 'KW2', - CORN: 'KW2', - FERTILIZER: 'PW2', - SUGAR: 'PW2', - COAL: 'KW3', - STEEL: 'CW3', - ORE: 'CW3', -}; - -const DEFAULT_BULK_WAGON_TYPE = 'CW3'; -const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; - -/** - * Resolve wagon type code from cargo type code for bulk freight. - */ -export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { - if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; - const normalized = cargoTypeCode.trim().toUpperCase(); - return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; -} - -/** - * Pick the best matching wagon type entity for bulk cargo. - */ -export function pickBulkWagonType( - wagonTypes: WagonType[], - cargoTypeCode?: string | null, -): WagonType | undefined { - const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); - const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); - if (direct) return direct; - - return wagonTypes.find( - (wt) => - wt.isActive && - !wt.supportsContainer && - wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, - ); -} - -export function getDefaultContainerWagonTypeCode(): string { - return DEFAULT_CONTAINER_WAGON_TYPE; -} diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 41742039c..6279c8491 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -21,6 +21,8 @@ interface AuthEmployeePosition { isDelegate?: boolean; parentPositionId?: string | null; permissions?: AuthPermission[]; + /** Some IAM payloads nest the position record instead of flattening its key. */ + position?: { id?: string; key?: string; name?: LocaleText }; } interface AuthEmployeeRecord { diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index ad16feac3..dcc8b21ed 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -174,6 +174,23 @@ export const useContainerTypeOptions = ( buildContainerTypeSelectOptions(result.data ?? [], includeNone), }); +/** + * Active wagon-type options for the cargo-type / container-type "Wagon type" + * picker. The FK the selection sets drives train-scheduling wagon resolution. + */ +export const useWagonTypeOptions = (enabled = true) => + useQuery({ + ...api.wagonTypes.list.queryOptions(), + enabled, + select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) => + rows + .filter((wt) => wt.isActive !== false) + .map((wt) => ({ + label: wt.name ? `${wt.name} (${wt.code})` : wt.code, + value: wt.id, + })), + }); + const LIVE_RATE_PAGE_SIZE = 500; export const useLiveRateOptions = (enabled = true) => diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 9fe564312..0abb91e99 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] { return [...keys]; } -/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */ +/** + * Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). + * Tolerates IAM payload shape variants: the key flat on the employee position, + * nested under `position.key`, or the GL modeled as a role instead. + */ export function getPositionKeys(user: AuthUser | null | undefined): string[] { if (!user) return []; const keys = new Set(); for (const emp of user.employee ?? []) { for (const pos of emp.positions ?? []) { if (pos.key) keys.add(pos.key); + if (pos.position?.key) keys.add(pos.position.key); } } + for (const role of user.roles ?? []) { + if (role.key) keys.add(role.key); + } return [...keys]; } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index 2568c76f7..c511c214e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -41,6 +41,7 @@ import { import { useRuleEngineList, useRuleEngineMutations, + useWagonTypeOptions, } from "@/hooks/rule-engine/useRuleEngine"; import type { RuleEngineRecord } from "@/types/rule-engine"; @@ -54,6 +55,8 @@ interface CargoNode extends RuleEngineRecord { requiresDirectorApproval?: boolean; /** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */ unitOfMeasure?: string | null; + /** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */ + wagonTypeId?: string | null; isActive?: boolean; displayOrder?: number; } @@ -79,6 +82,18 @@ const FORM_FIELDS: FormFieldDef[] = [ { label: "Per item (break-bulk)", value: "PER_ITEM" }, ], }, + { + // Wagon type that carries this (bulk) commodity — drives train-scheduling + // wagon resolution. Optional: leave "None" for grouping categories and + // container/legacy cargo; set it on scheduled bulk commodities. + // Options injected at render from useWagonTypeOptions. + name: "wagonTypeId", + label: "Wagon type", + type: "select", + optional: true, + placeholder: "Select wagon type (bulk cargo)", + options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }], + }, { name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ]; @@ -105,6 +120,24 @@ const CargoTypesPage = () => { const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG); + // Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK). + const { data: wagonTypeOptions } = useWagonTypeOptions(canManage); + const formFields = useMemo( + () => + FORM_FIELDS.map((field) => + field.name === "wagonTypeId" + ? { + ...field, + options: [ + { label: "None", value: RULE_ENGINE_SELECT_NONE }, + ...(wagonTypeOptions ?? []), + ], + } + : field, + ), + [wagonTypeOptions], + ); + const [search, setSearch] = useState(""); const [formMode, setFormMode] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -350,7 +383,7 @@ const CargoTypesPage = () => { ? "Create a top-level cargo category." : "Create a cargo type inside this category. It's attached here automatically." } - fields={FORM_FIELDS} + fields={formFields} initialRecord={formMode?.kind === "edit" ? formMode.record : null} isSubmitting={create.isPending || update.isPending} onSubmit={handleSubmit} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 297d67050..253cdffcc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -33,6 +33,7 @@ import { useCargoTypeParentOptions, useContainerTypeOptions, useLiveRateOptions, + useWagonTypeOptions, useRateWorkflow, useRuleEngineList, useRuleEngineMutations, @@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => { const usesLiveRateField = Boolean( config?.formFields.some((f) => f.name === "rateId"), ); + const usesWagonTypeField = Boolean( + config?.formFields.some((f) => f.name === "wagonTypeId"), + ); const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } = useCargoTypeParentOptions(editingId, config?.slug === "cargo-types"); @@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => { useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField); const { data: liveRateOptions, isLoading: liveRateOptionsLoading } = useLiveRateOptions(usesLiveRateField); + const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = + useWagonTypeOptions(usesWagonTypeField); const formFields = useMemo(() => { if (!config) return []; @@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => { options: liveRateOptions ?? [], }; } + if (field.name === "wagonTypeId") { + return { + ...field, + type: "select" as const, + options: wagonTypeOptions ?? [], + }; + } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]); const rows = data?.data ?? []; const meta = data?.meta; @@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => { (config.slug === "cargo-types" && cargoParentOptionsLoading) || (usesContainerTypeField && containerTypeOptionsLoading) || (usesCargoTypeField && cargoLeafOptionsLoading) || - (usesLiveRateField && liveRateOptionsLoading) + (usesLiveRateField && liveRateOptionsLoading) || + (usesWagonTypeField && wagonTypeOptionsLoading) } positionOptions={!editing ? createPositionOptions : undefined} positionLoading={createPositionLoading} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index faa26036b..2014f9c65 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ formFields: [ { name: "label", label: "Label", type: "text", required: true }, { name: "sizeFt", label: "Size (ft)", type: "number", required: true }, + // Options injected at render from useWagonTypeOptions (RuleEngineResourcePage). + { + name: "wagonTypeId", + label: "Wagon type", + type: "select", + required: true, + description: "Wagon type used to carry this container during train scheduling.", + }, { name: "isOpenTop", label: "Open top", type: "boolean" }, { name: "isActive", label: "Active", type: "boolean" }, ], diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 97b7afb8b..268e8627a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -16,6 +16,8 @@ import { Group, Loader, Paper, + Progress, + RingProgress, SimpleGrid, Stack, Tabs, @@ -211,6 +213,17 @@ export default function ContractDetailPage() { }); const bookingWindowOpen = hasOpenWindow(bookingWindows); + // Draw-down capacity per cargo line (GENERAL contracts only). The backend + // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships + // releases its share and the tracker fills back up. Refetched on window focus so + // it reflects newly created / cancelled shipments. + const { data: capacityLines = [] } = useQuery({ + queryKey: ["contract-capacity", id], + queryFn: () => contractsService.getCapacity(id!), + enabled: !!id && contract?.contractKind === "GENERAL", + refetchOnWindowFocus: true, + }); + const contractBookings = useMemo( () => (bookingsPage?.items ?? []).filter( @@ -882,6 +895,88 @@ export default function ContractDetailPage() { + {/* Draw-down capacity — GENERAL contracts with a per-line quantity cap. + Fills as shipments consume capacity; empties again when a shipment is + cancelled/rejected/expired (backend releases it). */} + {isGeneral && capacityLines.length > 0 && ( + + Contract capacity + + {capacityLines.map((line, i) => { + const cap = line.cap ?? 0; + const booked = line.booked ?? 0; + const remaining = line.remaining ?? Math.max(0, cap - booked); + const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0; + const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0; + const unit = capacityUnitLabel(contract, line); + const label = isContainer + ? `${line.containerSize ?? "Containers"}` + : (contract.cargoScope ?? []).find( + (s) => s.cargoTypeId === line.cargoTypeId, + )?.cargoType?.cargoTypeName ?? + (contract.cargoScope ?? [])[0]?.cargoFreeText ?? + "Bulk commodity"; + return ( + + + {remainingPct}% + + } + /> + + + + {isContainer ? ( + + ) : ( + + )} + + {label} + + + + {booked} / {cap} {unit} booked + + + + + {remaining} {unit} remaining + + + + ); + })} + + + )} + {/* Signatures */} {(contract.signatures ?? []).length > 0 && ( s.cargoTypeId === line.cargoTypeId, + ) ?? (contract.cargoScope ?? [])[0]; + return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons"; +} + /** * One document row in the Documents tab: the file's kind (passport, business * license, contract, …) derived from its `code` as the primary label, the From 73ed58955e0bb2467c3d72ae7876f882f7b77eea Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 3 Jul 2026 14:00:54 +0000 Subject: [PATCH 05/73] fix: refetch on focus --- apps/edr-freight-web/backoffice/src/lib/queryClient.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts index 32b94f325..781ffba74 100644 --- a/apps/edr-freight-web/backoffice/src/lib/queryClient.ts +++ b/apps/edr-freight-web/backoffice/src/lib/queryClient.ts @@ -27,7 +27,6 @@ export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, - refetchOnWindowFocus: false, staleTime: 30_000, }, }, From 70589229878bc888430459f69fb6fc107b280c41 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 06:19:35 +0000 Subject: [PATCH 06/73] changes --- .../src/modules/billing/billing.service.ts | 15 +++++++++++++++ .../src/modules/payment/payment.service.ts | 5 ++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 536129122..71398896f 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -953,6 +953,21 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // DEMO: manually fire the gateway `payment.succeeded` callback here, without + // waiting for real gateway settlement. Runs AFTER the paymentId link above so + // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: + // remove — real settlement flips this via the `${source}.invoice.paid` handler. + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } + if (result.immediateSuccess) { await this.settleByPaymentId( result.intentId, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d773ebe1f..5b8a3ddca 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -479,7 +479,7 @@ export class PaymentService { alreadyFinalized?: boolean; reason?: string; }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); + this.logger.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, @@ -490,13 +490,12 @@ export class PaymentService { reason: `No local intent for reference ${event.referenceId}`, }; } - console.log(`Processing payment succeeded event for intent: }`, intent); const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); - console.log( + this.logger.log( `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); From 18f47481d70a3ece7fc6dbc40f57506e350c6b60 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 06:31:23 +0000 Subject: [PATCH 07/73] Handover customer sign --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 2 + .../seed-paid-import-export-mile-demo.ts | 28 ++ .../paid-import-export-mile-demo.seeder.ts | 299 ++++++++++++++++++ .../portal/src/constants/URLS.ts | 1 + .../components/WarehousePaymentsSection.tsx | 82 ++++- .../services/warehouse-invoices.service.ts | 28 ++ 7 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts create mode 100644 apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index a0a119861..ef0b211d5 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,6 +22,7 @@ "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f20184e59..b44a942ea 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -63,6 +63,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; @@ -165,6 +166,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts new file mode 100644 index 000000000..6b2a422e8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(PaidImportExportMileDemoSeeder); + await seeder.run(); + console.log('Paid import/export mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Paid import/export mile demo booking seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts new file mode 100644 index 000000000..708afb86b --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -0,0 +1,299 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; +const COMPANY_TIN = 'PAIDMILE001'; +const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +/** + * Six paid, approved container bookings that mirror the real trucking legs: + * - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal). + * - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery). + * Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit. + */ +const DEMO_BOOKINGS = [ + // ── IMPORT: last mile only ───────────────────────────────────────────── + { + reference: 'PAID-IMP-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'PAID-IMP-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'PAID-IMP-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + // ── EXPORT: first mile only ──────────────────────────────────────────── + { + reference: 'PAID-EXP-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + }, + { + reference: 'PAID-EXP-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + }, + { + reference: 'PAID-EXP-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + }, +] as const; + +@Injectable() +export class PaidImportExportMileDemoSeeder { + private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with Paid First/Last Mile', + description: 'Demo service type for paid import/export bookings with a single mile leg', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 11, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'Paid Import/Export Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'PMD0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000202', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'Paid Mile Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000202', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const isImport = demoBooking.tradeDirection === 'IMPORT'; + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + // Only the leg that matches the trade direction carries an address. + firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress, + firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat, + firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null, + lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null, + lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + + // Reset any existing legs for idempotency, then create the single paid leg. + await manager.getRepository(FirstMile).delete({ bookingId: booking.id }); + await manager.getRepository(LastMile).delete({ bookingId: booking.id }); + + const paidAmount = demoBooking.totalWeightTons * 25; + + if (isImport) { + await manager.getRepository(LastMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + }); + } else { + await manager.getRepository(FirstMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + }); + } + } + }); + + this.logger.log( + 'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).', + ); + } +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ce2c35c1a..4e20106a6 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -170,5 +170,6 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`, DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`, RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`, + PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`, }, }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 146e31c1e..acd4dbb25 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,16 +1,24 @@ -import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { Download, Receipt } from "lucide-react"; +import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CreditCard, Download, Receipt } from "lucide-react"; +import { useState } from "react"; import toast from "react-hot-toast"; +import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { warehouseInvoicesService, type PortalWarehouseInvoice, } from "@/services/warehouse-invoices.service"; import { saveBlob } from "@/utils/download"; +import { PaymentMethodModal } from "./PaymentMethodModal"; import { CardTitle, SectionCard } from "./layout"; +/** Warehouse fee invoices the customer can still settle online. */ +const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]); +const isPayable = (inv: PortalWarehouseInvoice) => + PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0; + const money = (amount: number | string | null | undefined, currency: string) => `${Number(amount ?? 0).toLocaleString()} ${currency}`; @@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) { } /** - * Warehouse fee invoices linked to this booking — display + PDF download only. - * Paying them online is tracked separately (in-system demurrage/storage - * payment). Renders nothing when the booking has no warehouse fees. Carries - * `id="warehouse-payments"` so the invoice detail page can deep-link here. + * Warehouse fee invoices linked to this booking. Customers can pay outstanding + * demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the + * delivery handover; paid invoices expose the receipt PDF. The backoffice cash + * `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking + * has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail + * page can deep-link here. */ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { const { data: invoices = [] } = useQuery({ @@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { queryFn: () => warehouseInvoicesService.listForBooking(bookingId), }); + const [payInvoice, setPayInvoice] = useState(null); + + const payMutation = useMutation({ + mutationFn: (method: PaymentMethod) => { + if (!payInvoice) throw new Error("No invoice selected for payment."); + return warehouseInvoicesService.payOnline(payInvoice.id, { + method, + platform: "web", + }); + }, + onSuccess: (data, method) => { + if (!payInvoice) return; + // Redirect to the provider (or the fallback checkout page) — same as the + // booking "Pay now" flow, so behaviour is identical everywhere. + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method }); + window.location.href = redirectUrl; + }, + }); + + const payError = payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null; + + const closePayModal = () => { + if (!payMutation.isPending) { + setPayInvoice(null); + payMutation.reset(); + } + }; + if (invoices.length === 0) return null; const download = async (inv: PortalWarehouseInvoice) => { @@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + {isPayable(inv) && ( + + )} + + payMutation.mutate(method)} + processing={payMutation.isPending} + error={payError} + /> ); } diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts index f1fa4e841..91cd6a0d2 100644 --- a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -1,5 +1,10 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import { client } from "../utils/api"; +import type { + InitiateResponse, + PaymentMethod, + PaymentPlatform, +} from "./payments.service"; const W = URL_CONSTANTS.WAREHOUSE_INVOICES; @@ -51,4 +56,27 @@ export const warehouseInvoicesService = { const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); return data; }, + + /** + * Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage + * invoice. Returns the payment intent + `clientAction` to redirect the browser + * to the provider (mirrors the booking `/pay` flow). The backoffice cash + * `/pay` (record-a-payment) path is unaffected. + */ + payOnline: async ( + id: string, + payload: { + method: PaymentMethod; + platform?: PaymentPlatform; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + }, + ): Promise => { + const { data } = await client.post(W.PAY_ONLINE(id), { + platform: "web", + ...payload, + }); + return data.data ?? data; + }, }; From 2f6f06b7c4870d168ddea43b7017a24224558c10 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 06:47:20 +0000 Subject: [PATCH 08/73] feat: add check validity to the user signup --- .../auth/check-availability.controller.ts | 22 +++++++++ .../auth/check-availability.service.ts | 47 +++++++++++++++++++ .../src/modules/auth/freight-auth.module.ts | 10 +++- .../portal/src/constants/URLS.ts | 1 + .../portal/src/pages/accounts/SignupPage.tsx | 22 ++++++++- .../portal/src/services/api.ts | 7 +++ .../portal/src/services/auth.service.ts | 34 +++++++++----- apps/edr-freight-web/portal/src/types/auth.ts | 10 ++++ 8 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/auth/check-availability.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/check-availability.service.ts diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts new file mode 100644 index 000000000..13084d1c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; + +import { CheckAvailabilityService } from "./check-availability.service"; + +@ApiTags("auth") +@Controller("auth") +@Public() +export class CheckAvailabilityController { + constructor( + private readonly checkAvailabilityService: CheckAvailabilityService, + ) {} + + @Get("check-availability") + @ApiOperation({ + summary: "Check whether an email and/or phone number is already registered", + }) + check(@Query("email") email?: string, @Query("phone") phone?: string) { + return this.checkAvailabilityService.check({ email, phone }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/check-availability.service.ts b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts new file mode 100644 index 000000000..c9ce84b72 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/check-availability.service.ts @@ -0,0 +1,47 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +export interface CheckAvailabilityQuery { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResult { + emailTaken: boolean; + phoneTaken: boolean; +} + +@Injectable() +export class CheckAvailabilityService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async check({ + email, + phone, + }: CheckAvailabilityQuery): Promise { + if (!email && !phone) { + throw new BadRequestException("email or phone is required"); + } + + const matches = await this.userRepository.find({ + where: [ + ...(email ? [{ email }] : []), + ...(phone ? [{ phoneNumber: phone }] : []), + ], + select: { id: true, email: true, phoneNumber: true }, + }); + + return { + emailTaken: email ? matches.some((user) => user.email === email) : false, + phoneTaken: phone + ? matches.some((user) => user.phoneNumber === phone) + : false, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index a689ba24e..16fbeffda 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; + +import { CheckAvailabilityController } from './check-availability.controller'; +import { CheckAvailabilityService } from './check-availability.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; @Module({ - controllers: [FreightMeController], - providers: [FreightMeService], + imports: [TypeOrmModule.forFeature([User])], + controllers: [FreightMeController, CheckAvailabilityController], + providers: [FreightMeService, CheckAvailabilityService], }) export class FreightAuthModule {} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a4e20b029..a8cafc0d1 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -14,6 +14,7 @@ export const URL_CONSTANTS = { SET_PASSWORD: "/api/auth/set-password", ME: "/api/auth/me", GENERATE_VERIFICATION_CODE: "/users/generate-verification-code", + CHECK_AVAILABILITY: "/api/auth/check-availability", }, OTP: { diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 50320f699..05fa411b5 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -132,12 +132,30 @@ export default function SignupPage() { const passwordValue = watch("password") ?? ""; - // Step 1 — form is valid: send a fresh code to the chosen channel, then - // move to the OTP challenge. + // Step 1 — form is valid: make sure the email/phone aren't already + // registered, then send a fresh code to the chosen channel and move to + // the OTP challenge. const requestOtp = async (data: FormData) => { setError(null); setSending(true); try { + const availability = await api.auth.checkAvailability.call({ + email: data.email, + phone: data.phone, + }); + if (availability.emailTaken && availability.phoneTaken) { + setError("An account with this email and phone number already exists."); + return; + } + if (availability.emailTaken) { + setError("An account with this email already exists."); + return; + } + if (availability.phoneTaken) { + setError("An account with this phone number already exists."); + return; + } + await api.auth.sendOTP.call( channel === "email" ? { email: data.email } : { phone: data.phone }, ); diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 81a2488a9..932f6fc46 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -66,6 +66,8 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + CheckAvailabilityPayload, + CheckAvailabilityResponse, GenerateVerificationCodePayload, LoginPayload, LoginResponse, @@ -107,6 +109,11 @@ export const api = { "setPassword", authService.setPassword, ), + checkAvailability: endpoint( + "auth", + "checkAvailability", + authService.checkAvailability, + ), sendOTP: endpoint( "auth", "sendOTP", diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index e1de1889a..58b81c5ba 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,14 +1,16 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { - AuthUser, - GenerateVerificationCodePayload, - LoginPayload, - LoginResponse, - OtpPayload, - OtpResponse, - SetPasswordPayload, - SignupPayload, - SignupResponse, + AuthUser, + CheckAvailabilityPayload, + CheckAvailabilityResponse, + GenerateVerificationCodePayload, + LoginPayload, + LoginResponse, + OtpPayload, + OtpResponse, + SetPasswordPayload, + SignupPayload, + SignupResponse, } from "@/types/auth"; import { client } from "@/utils/api"; import { ApiResponse } from "@edr/types"; @@ -23,7 +25,7 @@ export const authService = { }, createUser: async (body: SignupPayload) => { - const res = await client.post> ( + const res = await client.post>( URL_CONSTANTS.USERS.SIGN_UP, body, ); @@ -31,9 +33,7 @@ export const authService = { }, getMyInfo: async () => { - const res = await client.get( - URL_CONSTANTS.USERS.ME, - ); + const res = await client.get(URL_CONSTANTS.USERS.ME); return res.data; }, @@ -53,6 +53,14 @@ export const authService = { return res.data.data; }, + checkAvailability: async (params: CheckAvailabilityPayload) => { + const res = await client.get>( + URL_CONSTANTS.USERS.CHECK_AVAILABILITY, + { params }, + ); + return res.data; + }, + sendOTP: async (body: OtpPayload) => { const res = await client.post>( URL_CONSTANTS.OTP.SEND, diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 7b58f573b..04357a9ca 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,16 @@ export interface OtpResponse { message: string; } +export interface CheckAvailabilityPayload { + email?: string; + phone?: string; +} + +export interface CheckAvailabilityResponse { + emailTaken: boolean; + phoneTaken: boolean; +} + export interface SetPasswordPayload { newPassword: string; confirmPassword: string; From 1c625cfb827aaf4d6894c50cc54a45935691e0a8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 4 Jul 2026 10:10:26 +0300 Subject: [PATCH 09/73] feat: ( iam ) OTP-gate registration via IAM signup + set-password --- .../src/modules/auth/auth.controller.ts | 20 +- .../src/modules/auth/auth.dto.ts | 17 +- .../modules/auth/passenger-auth.service.ts | 101 +++++++- .../modules/bookings/guest-booking.service.ts | 5 +- .../portal/src/app/register/page.tsx | 60 ++--- .../portal/src/app/verify-account/page.tsx | 216 ++++++++++++++++++ .../portal/src/lib/api/auth.ts | 4 + .../portal/src/lib/auth-store.ts | 31 +-- 8 files changed, 370 insertions(+), 84 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/verify-account/page.tsx diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index d53ef40d7..a6f596bf6 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PassengerAuthService } from './passenger-auth.service'; -import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; +import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Passenger Auth') @@ -14,14 +14,28 @@ export class AuthController { @Post('register') @IsPublic() - @ApiOperation({ summary: 'Register new passenger account' }) - @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' }) + @ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' }) + @ApiResponse({ + status: 201, + description: + 'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.', + }) @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiBody({ type: RegisterDto }) register(@Request() req: any, @Body() dto: RegisterDto) { return this.passengerAuthService.register(dto, req); } + @Post('register/resend-code') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resend the registration verification code for a pending account' }) + @ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' }) + @ApiBody({ type: ResendRegistrationCodeDto }) + resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) { + return this.passengerAuthService.resendRegistrationCode(dto, req); + } + @Post('login') @IsPublic() @HttpCode(HttpStatus.OK) diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index 6f43e7212..d0a691b0f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,6 +1,6 @@ -import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; +import { IsEmail, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; export class NameDto { @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) @@ -29,15 +29,16 @@ export class RegisterDto { @ValidateNested() @Type(() => NameDto) name: NameDto; +} - @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) - @IsString() - @MinLength(8) - password: string; +export class ResendRegistrationCodeDto { + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() + email: string; - @ApiProperty({ example: 'SecurePass123', format: 'password' }) + @ApiProperty({ example: '+251912345678' }) @IsString() - confirmPassword: string; + phoneNumber: string; } export class LoginDto { diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 0213bb8f6..1784261e5 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -50,14 +50,17 @@ export class PassengerAuthService { const iamAuthService = await this.resolveIamAuthService(req); - const { token, refreshToken } = await iamAuthService.signupWithPassword({ + // IAM `signup` creates the user as PENDING/isActive=false with NO credential and + // SMS-sends a 6-digit verification code. The account cannot log in until the code is + // redeemed via PATCH /v1/auth/set-password. We intentionally discard the session + // token `signup` returns — the account is not verified yet, so it must never reach + // the client. + await iamAuthService.signup({ email: dto.email, username: dto.username, phoneNumber: dto.phoneNumber, userType: EUserType.INDIVIDUAL, name: dto.name, - password: dto.password, - confirmPassword: dto.confirmPassword, }); const iamRows = await this.dataSource.query( @@ -70,20 +73,98 @@ export class PassengerAuthService { } const iamUserId = iamRows[0].id; - let passengerId: string; + // The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned + // here — `login()` lazy-provisions it on first successful login, so satellites exist + // only for verified users who complete set-password and sign in. + return { + iamUserId, + email: dto.email, + phoneNumber: dto.phoneNumber, + requiresPasswordSetup: true, + }; + } + + /** + * Immediate-activation account creation used by the payment-gated guest-checkout + * "create account" path only. Unlike the public `register()` (OTP-gated), this creates a + * ready-to-use account from the password entered at checkout and provisions the passenger + * satellite synchronously so the booking can attach to it. Do NOT wire this to the public + * registration form — that flow must stay behind SMS verification. + */ + async registerWithPassword( + dto: { + email: string; + username: string; + phoneNumber: string; + name: { en: string; am: string }; + password: string; + }, + req: any, + ): Promise<{ iamUserId: string; passengerId: string }> { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (existing.length) throw new ConflictException('Email or phone already registered'); + + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.signupWithPassword({ + email: dto.email, + username: dto.username, + phoneNumber: dto.phoneNumber, + userType: EUserType.INDIVIDUAL, + name: dto.name, + password: dto.password, + confirmPassword: dto.password, + }); + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + if (!iamRows.length) { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + const iamUserId = iamRows[0].id; + try { const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); - passengerId = result.passengerId; + return { iamUserId, passengerId: result.passengerId }; } catch { await this.compensateIamSignup(dto.email); throw new InternalServerErrorException('Account creation failed. Please try again.'); } + } - return { - token, - refreshToken, - user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId }, - }; + async resendRegistrationCode( + dto: { email: string; phoneNumber: string }, + req: any, + ): Promise<{ sent: boolean }> { + // Only regenerate for accounts still pending password setup. A fully-registered user + // should use forgot-password instead. Always return { sent: true } to avoid leaking + // whether the email/phone maps to a pending account (enumeration guard). + const users = await this.dataSource.query<{ email: string; phone_number: string }[]>( + `SELECT email, phone_number FROM iam.users + WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (!users.length) return { sent: true }; + + const iamAuthService = await this.resolveIamAuthService(req); + try { + await iamAuthService.generateVerificationCode({ + email: users[0].email, + phoneNumber: users[0].phone_number, + type: EOtpType.VERIFY_PHONE_NUMBER, + }); + } catch (err) { + this.logger.error( + `[PassengerAuthService] resend registration code failed for ${dto.email}`, + (err as Error).message, + ); + } + return { sent: true }; } async login(dto: LoginDto, req: any) { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..d2c65db42 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -886,18 +886,17 @@ export class GuestBookingService { ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { if (dto.createAccount && firstPassenger.email && dto.password) { const guestName = firstPassenger.passengerName ?? 'Guest'; - const result = await this.passengerAuthService.register( + const result = await this.passengerAuthService.registerWithPassword( { email: firstPassenger.email, username: firstPassenger.email, phoneNumber: firstPassenger.phone || `+251900000000`, name: { en: guestName, am: guestName }, password: dto.password, - confirmPassword: dto.password, }, req, ); - return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true }; + return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true }; } // Create guest passenger with basic profile diff --git a/apps/edr-passenger-web/portal/src/app/register/page.tsx b/apps/edr-passenger-web/portal/src/app/register/page.tsx index c335d9e98..a39810be1 100644 --- a/apps/edr-passenger-web/portal/src/app/register/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/register/page.tsx @@ -9,18 +9,11 @@ import { useAuthStore } from '@/lib/auth-store'; import { useState } from 'react'; import { Train, ShieldCheck } from 'lucide-react'; -const registerSchema = z - .object({ - fullName: z.string().min(2, 'Full name is required'), - email: z.string().email('Invalid email address'), - phone: z.string().min(9, 'Phone number is required'), - password: z.string().min(8, 'Password must be at least 8 characters'), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); +const registerSchema = z.object({ + fullName: z.string().min(2, 'Full name is required'), + email: z.string().email('Invalid email address'), + phone: z.string().min(9, 'Phone number is required'), +}); type RegisterForm = z.infer; @@ -38,14 +31,17 @@ export default function RegisterPage() { setLoading(true); setError(''); try { - await registerUser({ + const result = await registerUser({ fullName: data.fullName, email: data.email, phone: data.phone, - password: data.password, - confirmPassword: data.confirmPassword, }); - router.push('/booking/search'); + const params = new URLSearchParams({ + email: result.email, + userId: result.iamUserId, + phone: result.phoneNumber, + }); + router.push(`/verify-account?${params.toString()}`); } catch (err: any) { if (err.response?.status === 409) { setError('An account with this email or phone number already exists.'); @@ -67,7 +63,7 @@ export default function RegisterPage() {

Create account

-

Book faster and manage your trips

+

We'll text you a code to verify your phone

@@ -120,36 +116,8 @@ export default function RegisterPage() { )}
-
- - - {errors.password && ( -

{errors.password.message}

- )} -
- -
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
- diff --git a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx new file mode 100644 index 000000000..f9f0b61c9 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ArrowLeft, ShieldCheck } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; +import { useAuthStore } from '@/lib/auth-store'; + +// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults): +// min length 8, with lower- and upper-case letters, a number, and a symbol. +function isStrongPassword(pw: string): boolean { + return ( + pw.length >= 8 && + /[a-z]/.test(pw) && + /[A-Z]/.test(pw) && + /[0-9]/.test(pw) && + /[^A-Za-z0-9]/.test(pw) + ); +} + +function VerifyAccountContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const login = useAuthStore((s) => s.login); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const phone = searchParams.get('phone') || ''; + const linkValid = Boolean(email && userId); + + const [verificationCode, setVerificationCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [resending, setResending] = useState(false); + const [resent, setResent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!verificationCode.trim()) { + setError('Enter the verification code sent to your phone.'); + return; + } + if (!isStrongPassword(newPassword)) { + setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + // Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates + // the account and sets the password. + await iamAuthApi.resetPassword({ + userId, + email, + verificationCode: verificationCode.trim(), + newPassword, + confirmPassword, + }); + // Auto-login with the freshly-set password; login lazy-provisions the passenger record. + await login(email, newPassword); + router.push('/booking/search'); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Could not verify your account. Check the code and try again, or resend it.'); + setLoading(false); + } + }; + + const handleResend = async () => { + setError(''); + setResent(false); + setResending(true); + try { + await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone }); + setResent(true); + } catch { + setError('Could not resend the code. Please try again in a moment.'); + } finally { + setResending(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Verify your account

+ {linkValid && ( +

+ Enter the code we sent to your phone and choose a password for{' '} + {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This verification link is invalid or incomplete. Please start registration again. +
+ + Back to registration + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + {resent && !error && ( +
+ +

A new code has been sent to your phone.

+
+ )} + +
+ + { setVerificationCode(e.target.value); setError(''); }} + className="input-field tracking-widest" + placeholder="123456" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +

+ At least 8 characters with upper & lower case, a number, and a symbol. +

+
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +
+ + + + + + + + Back to registration + + + )} +
+
+
+ ); +} + +export default function VerifyAccountPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index aa5272173..14e9a16a1 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -11,6 +11,10 @@ export const iamAuthApi = { forgotPassword: (email: string) => axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + // Re-sends the registration verification code for a still-pending account. + resendRegistrationCode: (data: { email: string; phoneNumber: string }) => + axios.post(`${API_URL}/auth/register/resend-code`, data), + // Completes the forgot-password flow using the link sent via SMS: // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. resetPassword: (data: { diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 2157cfc0a..0d6e46fd9 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -31,7 +31,7 @@ interface AuthState { isAuthenticated: boolean; isInitialized: boolean; login: (email: string, password: string) => Promise; - register: (data: RegisterData) => Promise; + register: (data: RegisterData) => Promise; logout: () => Promise; setUser: (user: User, token: string) => void; updateUser: (userData: Partial) => void; @@ -43,8 +43,12 @@ interface RegisterData { fullName: string; email: string; phone: string; - password: string; - confirmPassword: string; +} + +interface RegisterResult { + iamUserId: string; + email: string; + phoneNumber: string; } export const useAuthStore = create((set, get) => ({ @@ -118,25 +122,24 @@ export const useAuthStore = create((set, get) => ({ set({ user, token, isAuthenticated: true }); }, - register: async (data: RegisterData) => { + register: async (data: RegisterData): Promise => { // Shape required by the passenger-api RegisterDto; username = email by convention. + // Registration no longer takes a password — the account is created as pending and + // an SMS verification code is sent. The user completes signup on the verify-account + // page (set-password). No token is issued here; the user is NOT logged in yet. const payload = { email: data.email, username: data.email, phoneNumber: data.phone, name: { en: data.fullName, am: data.fullName }, - password: data.password, - confirmPassword: data.confirmPassword, }; const response: any = await apiClient.post('/auth/register', payload); - const { token, user } = response.data || response; - - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_user', JSON.stringify(user)); - } - - set({ user, token, isAuthenticated: true }); + const result = response.data || response; + return { + iamUserId: result.iamUserId, + email: result.email, + phoneNumber: result.phoneNumber, + }; }, logout: async () => { From 74118165c6530cb3f9c58f8221e376e325dd4e76 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:16:23 +0000 Subject: [PATCH 10/73] chages --- .../src/modules/train-scheduling/booking-batch.service.ts | 5 ++++- .../src/modules/train-scheduling/train-scheduling.service.ts | 4 +++- .../src/components/contracts/GlUpcomingWindowsSection.tsx | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 46175c7ef..0b239990b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit { private async openRouteDayGroups(): Promise { const open = ( await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, + where: [ + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, + ], }) ).filter((s) => s.windowPhase == null); const groups = new Map(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..3b36fdc9a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2049,7 +2049,9 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - {}, + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, manager, ); if (schedule.trainSetId) { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index e5e998721..dc95729c1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -235,6 +235,8 @@ export function GlUpcomingWindowsSection() { const rows = (data ?? []).filter( (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), ); + // Canceled schedules are retired to windowPhase='DONE' server-side, so the + // guard above already excludes them; they never reach the upcoming list. // Open lanes first, then by opening time. return rows.sort((a, b) => { const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); From 619ffa5419de62addd46a3f9c5b439a2c61787d1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 07:17:05 +0000 Subject: [PATCH 11/73] style(WIP): auth ui clean up. --- .../src/components/auth/AuthShell.tsx | 139 +++++++ .../backoffice/src/pages/auth/LoginPage.tsx | 392 +++++------------- .../portal/src/pages/accounts/LoginPage.tsx | 81 ++-- 3 files changed, 274 insertions(+), 338 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx new file mode 100644 index 000000000..ecf06c7c9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/AuthShell.tsx @@ -0,0 +1,139 @@ +import type { ReactNode } from "react"; +import { ArrowUpRight, ChevronDown, Globe } from "lucide-react"; + +const LOGIN_IMAGE = "/assets/login.png"; +const EDR_LOGO = "/assets/logo.svg"; + +const LeftPanelDecor = () => ( +
+ + {[0, 1, 2, 3, 4, 5].map((ring) => ( + + ))} + +
+
+); + +const RightPanelDecor = () => ( +
+
+
+ + + + + + + + +
+); + +export interface AuthShellProps { + children: ReactNode; + /** Tagline shown in the highlighted card over the left image panel. */ + tagline?: string; + taglineBody?: string; +} + +const LeftPanel = ({ + tagline, + taglineBody, +}: Pick) => ( +
+ Ethio Djibouti Railway +
+ + +
+ EDR Freight +
+ +
+
+
+
+ + {tagline ?? "Empower Your Freight Operations"} + +
+

+ {taglineBody ?? + "Sign in to manage bookings, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."} +

+
+
+
+); + +const LanguageSelector = () => ( +
+ + Eng + +
+); + +export default function AuthShell({ + children, + tagline, + taglineBody, +}: AuthShellProps) { + return ( +
+
+ + +
+ + +
+ +
+ +
+
+
+ {children} +
+
+
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 89e8557c3..849049bc2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -1,162 +1,40 @@ import { type FormEvent, useState } from "react"; import { - Eye, - EyeOff, - ArrowUpRight, - Globe, - ChevronDown, -} from "lucide-react"; + Alert, + Box, + Button, + Center, + Group, + Image, + PasswordInput, + PinInput, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { AlertCircle, ArrowLeft } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { useAuth } from "@/auth/useAuth"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; /** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */ const normaliseIdentifier = (raw: string): string => { const v = raw.trim(); const digits = v.replace(/\D/g, ""); if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) { - const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, ""); + const local = digits.startsWith("251") + ? digits.slice(3) + : digits.replace(/^0/, ""); return `+251${local}`; } return v.toLowerCase(); }; -const LOGIN_IMAGE = "/assets/login.png"; const EDR_LOGO = "/assets/logo.svg"; -const fieldClass = - "h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10"; - -const primaryButtonClass = - "h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none"; - -const LeftPanelDecor = () => ( -
- - {[0, 1, 2, 3, 4, 5].map((ring) => ( - - ))} - -
-
-); - -const RightPanelDecor = () => ( -
-
-
- - - - - - - - -
-); - -const LeftPanel = () => ( -
- Ethio Djibouti Railway -
- - - - -
-
-
-
- - Empower Your Freight Operations - -
-

- Sign in to manage bookings, track cargo, and run logistics operations - on the Ethio Djibouti Railway freight platform. -

-
-
-
-); - -const LanguageSelector = () => ( -
- - Eng - -
-); - -const FormFooter = () => ( - -); - const LoginPage = () => { const navigate = useNavigate(); const { login, verifyMfa } = useAuth(); @@ -165,7 +43,6 @@ const LoginPage = () => { const [otp, setOtp] = useState(""); const [needsMfa, setNeedsMfa] = useState(false); const [submitting, setSubmitting] = useState(false); - const [showPassword, setShowPassword] = useState(false); const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); const [error, setError] = useState(null); @@ -179,15 +56,14 @@ const LoginPage = () => { setNormalizedIdentifier(normalized); const result = await login({ email: normalized, password }); - console.log(result); if (result.mfaRequired) { setNeedsMfa(true); return; } // navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to sign in with those credentials."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } @@ -201,194 +77,132 @@ const LoginPage = () => { try { await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); navigate("/dashboard/overview", { replace: true }); - } catch { - setError("Unable to verify the one-time code."); + } catch (err) { + setError(extractApiError(err).message); } finally { setSubmitting(false); } }; const loginForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

- Get Started -

-

+ + + Welcome back! + + Log in to access the freight backoffice & explore all logistics resources. -

-
+ + -
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
- -
- setPassword(event.target.value)} - placeholder="Enter your password" - className={`${fieldClass} pr-11`} - /> - -
-
+ setPassword(event.target.value)} + /> {error ? ( -
+ }> {error} -
+ ) : null} - - -

- Need an account?{" "} - - Contact your admin - -

-
- + + +
); const mfaForm = ( -
-
- EDR Freight -
+ +
+ EDR Freight +
-
-

+ + Multi-factor verification - </h1> - <p className="text-sm leading-relaxed text-gray-500"> + + We sent a verification code to{" "} - + {normalizedIdentifier} - + . Enter it below to complete sign in. -

-

+ + -
-
- - + + + Verification code + + setOtp(event.target.value)} - placeholder="Enter the code" - className={fieldClass} + placeholder="0" + disabled={submitting} + styles={{ input: { textAlign: "center" } }} + onChange={setOtp} /> -
+ {error ? ( -
+ }> {error} -
+ ) : null} -
- - -
-
- + Verify + + + +
); - return ( - <> - - - - -
-
- - -
- - -
- -
- -
-
-
- {!needsMfa ? loginForm : mfaForm} -
-
-
- - -
-
-
- - ); + return {!needsMfa ? loginForm : mfaForm}; }; export default LoginPage; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index bdd10871b..323b5d5a8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,11 @@ import { type FormEvent, useState } from "react"; -import { Eye, EyeOff } from "lucide-react"; +import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; +import { AlertCircle } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import useAuth from "@/hooks/useAuth"; -import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import AuthShell from "@/components/auth/AuthShell"; +import { extractApiError } from "@/utils/result"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -24,7 +26,6 @@ export default function LoginPage() { const { login } = useAuth(); const [identifier, setIdentifier] = useState(""); const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -41,8 +42,8 @@ export default function LoginPage() { } else { setError(result.error.message); } - } catch { - setError("An unexpected error occurred"); + } catch (err) { + setError(extractApiError(err).message); } finally { setLoading(false); } @@ -64,60 +65,42 @@ export default function LoginPage() {

-
-
- - setIdentifier(event.target.value)} - placeholder="name@company.com or 09XXXXXXXX" - disabled={loading} - autoComplete="username" - className={fieldClass} - /> -
+ + setIdentifier(event.target.value)} + /> -
-
- +
+ -
- setPassword(event.target.value)} - placeholder="Enter your password" - disabled={loading} - className={`${fieldClass} pr-11`} - /> - -
+ setPassword(event.target.value)} + />
{error ? ( -
+ }> {error} -
+ ) : null} - +

Don't have an account?{" "} @@ -129,7 +112,7 @@ export default function LoginPage() { Create an account

-
+ ); From e8e3e01f312398088f9d473395867957577be05c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:36:53 +0000 Subject: [PATCH 12/73] release order document fix --- apps/edr-freight-api/Dockerfile | 11 ++++++++++- .../train-scheduling/train-scheduling.service.ts | 7 +++++-- .../warehouses/warehouse-release-document.service.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..b781fb4c0 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,6 +7,9 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +# Puppeteer uses the system Chromium installed in the runner stage — skip the +# ~150MB bundled-Chromium download during pnpm install. +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -32,8 +35,14 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the +# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects +# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation +# falls back to a degraded hand-built layout. +RUN apk add --no-cache libc6-compat \ + chromium nss freetype harfbuzz ca-certificates ttf-freefont ENV NODE_ENV=production +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..86e37451e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1265,7 +1265,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (would mislabel this as a + // gate-clearance / release order when Chromium is unavailable). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1283,7 +1285,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (see importLoadListDocument). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index f8c0dd355..68e630e0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService { }); } + /** + * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the + * release-order fallback. Non-release documents (e.g. the import/export + * marshalling load list) must use this so a Chromium-less fallback degrades to + * a plain-text dump of *their own* content — instead of masquerading as a + * "Warehouse Gate Clearance / Release Order", which the release-specific + * fallback would otherwise draw regardless of the input HTML. + */ + renderDocumentHtml(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { label }); + } + private htmlToBasicPdfBuffer(html: string): Buffer { const doc = this.extractReleaseDocument(html); const body: string[] = [ From 145240d3bded71b8da3c36ded965f89c27e6d93d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:43:03 +0000 Subject: [PATCH 13/73] refactor: remove gate pass granting logic from clearance services and UI - Removed the gate pass granting functionality from the BookingClearanceService and ContractClearanceService, replacing it with a new method to retrieve gate pass status from train schedules. - Updated the ContractsController to eliminate endpoints related to gate pass granting. - Refactored the UI components (ExportClearanceStepper and PhasedClearanceActionPanel) to reflect the new gate pass securing process, linking to the train scheduling interface instead. - Cleaned up related constants and query hooks, removing unused code and references to the gate pass functionality. - Adjusted types in the contracts to accommodate changes in the gate pass handling logic. --- .../contracts/booking-clearance.service.ts | 12 +- .../contracts/contract-clearance.service.ts | 14 +- .../modules/contracts/contracts.controller.ts | 40 -- .../contracts/dto/phased-clearance.dto.ts | 8 - .../contracts/gl-operations.service.ts | 222 +++-------- .../contracts/ExportClearanceStepper.tsx | 102 ++--- .../contracts/PhasedClearanceActionPanel.tsx | 103 +----- .../backoffice/src/constants/QUERY_KEYS.ts | 1 - .../backoffice/src/constants/URLS.ts | 5 - .../src/hooks/contracts/useContracts.ts | 9 - .../contracts/GlDjiboutiClearanceListPage.tsx | 349 +++--------------- .../src/services/contracts.service.ts | 29 -- packages/types/src/freight/contracts.ts | 26 +- 13 files changed, 136 insertions(+), 784 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f169c49c..61ac93925 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -205,7 +205,7 @@ export class BookingClearanceService { const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); @@ -242,14 +242,8 @@ export class BookingClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 532d26359..2c79ba42f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -278,7 +278,9 @@ export class ContractClearanceService { } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = cycle?.bookingId + ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) + : { granted: false, grantedAt: null }; const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState( @@ -344,14 +346,8 @@ export class ContractClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 06ac31d68..a22c7cad4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,7 +77,6 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, - GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -688,30 +687,6 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } - @Get('clearance/dj-schedules') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) - djClearanceSchedules() { - return this.glOperationsService.djSchedules(); - } - - @Post('clearance/schedules/:scheduleId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ - summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', - }) - grantScheduleGatepass( - @Param('scheduleId', ParseUUIDPipe) scheduleId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantScheduleGatepass( - scheduleId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -947,21 +922,6 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } - @Post('bookings/:bookingId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) - grantGatepass( - @Param('bookingId', ParseUUIDPipe) bookingId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantGatepass( - bookingId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index 6b784073b..34a903427 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -36,11 +36,3 @@ export class RoAmendmentDto { note?: string; } -export class GatepassDto { - @ApiPropertyOptional({ - description: 'When the gate pass was granted (ISO datetime; defaults to now)', - }) - @IsOptional() - @IsString() - gatepassAt?: string; -} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 8fed3a8ff..e639f0867 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, In, IsNull } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; @@ -17,7 +17,6 @@ import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { @@ -198,6 +197,7 @@ export class GlOperationsService { } return { + scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() @@ -208,6 +208,41 @@ export class GlOperationsService { }; } + /** + * Gate pass status for a booking, sourced from the train schedule's Djibouti + * gate-pass operation (secured via the train-scheduling "Save as Secured" + * action) rather than a clearance milestone. For EXPORT bookings this also + * backfills the arrival-chain milestones once secured, same as the retired + * clearance-side grant action used to. + */ + async gatepassForBooking( + bookingId: string, + ): Promise<{ granted: boolean; grantedAt: string | null }> { + const train = await this.trainState(bookingId); + if (!train.scheduleId) return { granted: false, grantedAt: null }; + const operation = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: train.scheduleId } }); + const grantedAt = operation?.gatepassGrantedAt + ? new Date(operation.gatepassGrantedAt).toISOString() + : null; + + if (grantedAt) { + const booking = await this.getBooking(bookingId); + if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code); + } + } + } + } + + return { granted: Boolean(grantedAt), grantedAt }; + } + /** * T1 transit-document lifecycle state for an import shipment booking. Wagon * allocation opens the upload window; train departure locks it; train arrival @@ -302,8 +337,11 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - if (!done('GATEPASS_GRANTED')) { - throw new BadRequestException('Grant the gate pass before closing T1.'); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Secure the Djibouti gate pass on the train schedule before closing T1.', + ); } // Export bookings seeded before T1_CLOSED joined the catalog lack the row. await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); @@ -322,182 +360,6 @@ export class GlOperationsService { 'ARRIVED_AT_DJIBOUTI', ]; - /** - * GL Djibouti grants the gate pass for a customs booking, capturing the time. - * Export: requires the train to have arrived at Djibouti; back-fills the - * arrival-chain milestones. Import: requires wagon allocation (pre-loading). - */ - async grantGatepass( - bookingId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> { - const booking = await this.getBooking(bookingId); - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Gate pass applies to customs bookings only.'); - } - const tradeDirection = booking.tradeDirection ?? 'IMPORT'; - const milestones = await this.milestoneService.listForBooking(bookingId); - const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - - const existing = byCode.get('GATEPASS_GRANTED'); - if (existing?.status === 'COMPLETED') { - return { - bookingId, - gatepassAt: - existing.metadata?.gatepassAt ?? - (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), - }; - } - - const train = await this.trainState(bookingId); - if (tradeDirection === 'EXPORT') { - if (!train.arrivedAt) { - throw new BadRequestException( - 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', - ); - } - for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { - if (byCode.get(code)?.status === 'PENDING') { - await this.milestoneService.completeForBooking(bookingId, code, userId); - } - } - } else if (!train.wagonAllocated) { - throw new BadRequestException( - 'Wagons must be allocated before the gate pass can be granted.', - ); - } - - const at = gatepassAt?.trim() || new Date().toISOString(); - await this.milestoneService.completeWithMetadataForBooking( - bookingId, - 'GATEPASS_GRANTED', - { gatepassAt: at }, - userId, - ); - return { bookingId, gatepassAt: at }; - } - - /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ - async djSchedules(): Promise { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - scheduleBookings: { booking: true }, - originStation: true, - destinationStation: true, - }, - order: { scheduledDepartureDate: 'DESC' }, - }); - - const withCustoms = schedules - .filter((s) => s.status !== 'CANCELLED') - .map((s) => ({ - schedule: s, - customs: (s.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), - })) - .filter((s) => s.customs.length > 0); - - const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); - const gatepassRows = bookingIds.length - ? await this.dataSource.getRepository(ClearanceMilestone).find({ - where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, - }) - : []; - const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); - - return withCustoms.map(({ schedule, customs }) => { - const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; - return { - id: schedule.id, - trainNumber: schedule.trainNumber ?? null, - routeName: null, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, - status: schedule.status, - scheduledDepartureDate: schedule.scheduledDepartureDate - ? new Date(schedule.scheduledDepartureDate).toISOString() - : null, - actualDepartureAt: schedule.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - actualArrivalAt: schedule.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, - freightType: - freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, - customsBookings: customs.map((b) => { - const m = gatepassByBooking.get(b.id); - const granted = m?.status === 'COMPLETED'; - return { - bookingId: b.id, - reference: b.reference ?? b.id, - tradeDirection: b.tradeDirection ?? 'IMPORT', - contractId: b.contractId ?? null, - gatepassGranted: granted, - gatepassAt: granted - ? (m?.metadata?.gatepassAt ?? - (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) - : null, - }; - }), - }; - }); - } - - /** - * One-click gate pass for every customs booking on a train schedule. Per-booking - * guard failures are collected, not fatal. Import schedules also get the - * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. - */ - async grantScheduleGatepass( - scheduleId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id: scheduleId }, - relations: { scheduleBookings: { booking: true } }, - }); - if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - - const customs = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); - if (customs.length === 0) { - throw new BadRequestException('No customs bookings ride this schedule.'); - } - - let granted = 0; - const skipped: Array<{ bookingId: string; error: string }> = []; - for (const booking of customs) { - try { - await this.grantGatepass(booking.id, gatepassAt, userId); - granted += 1; - } catch (e) { - skipped.push({ - bookingId: booking.id, - error: e instanceof Error ? e.message : 'Failed', - }); - } - } - - if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { - const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); - let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); - if (!operation) { - operation = opRepo.create({ trainScheduleId: scheduleId }); - } - if (!operation.gatepassGrantedAt) { - operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); - await opRepo.save(operation); - } - } - - return { granted, skipped }; - } /** * GL Djibouti raises the post-offload final invoice (export): manual amount + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index d7cd9207b..b13e38961 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -14,7 +14,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DateInput, DateTimePicker } from "@mantine/dates"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, CheckCircle2, @@ -397,15 +397,10 @@ export function ExportClearanceStepper({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -526,68 +513,21 @@ function GatepassStep({ done={false} pendingLabel={ arrived - ? "Train arrived — GL Djibouti can grant the gate pass." + ? "Train arrived — secure the gate pass on the train schedule." : "Available once the train arrives at Djibouti." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 82ac61382..d0b7f2c87 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Group, - Modal, NumberInput, Paper, SegmentedControl, @@ -15,7 +14,6 @@ import { Text, TextInput, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { TransitPermitMultiUpload, @@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -852,68 +836,21 @@ function ImportGatepassStep({ done={false} pendingLabel={ wagonAllocated - ? "Wagons allocated — GL Djibouti can grant the gate pass." + ? "Wagons allocated — secure the gate pass on the train schedule." : "Available once wagons are allocated." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 3275ef662..bd1c51e47 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -70,7 +70,6 @@ export const QUERY_KEYS = { ["contracts", "clearance-queue", region ?? "ET"] as const, clearanceHistory: (region?: string) => ["contracts", "clearance-history", region ?? "ET"] as const, - djSchedules: ["contracts", "clearance-dj-schedules"] as const, milestones: (id: string) => ["contracts", "milestones", id] as const, capacity: (id: string) => ["contracts", "capacity", id] as const, bookingMilestones: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0f09f508..c22427881 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -232,11 +232,6 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/t1-documents`, BOOKING_T1_CLOSE: (bookingId: string) => `/contracts/bookings/${bookingId}/t1-close`, - CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules", - CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) => - `/contracts/clearance/schedules/${scheduleId}/gatepass`, - BOOKING_GATEPASS: (bookingId: string) => - `/contracts/bookings/${bookingId}/gatepass`, BOOKING_FINAL_INVOICE: (bookingId: string) => `/contracts/bookings/${bookingId}/final-invoice`, BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index 56229415f..04a4720aa 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) { }); } -/** Train schedules carrying customs bookings — GL DJ gate-pass table. */ -export function useDjClearanceSchedules(enabled = true) { - return useQuery({ - queryKey: QUERY_KEYS.CONTRACTS.djSchedules, - queryFn: () => contractsService.getDjClearanceSchedules(), - enabled, - }); -} - /** Path A self-clearance queue (Operations reviews non-customs contracts). */ export function useOpsClearanceQueue(enabled = true) { return useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 3957fb7a5..0b7456851 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,326 +1,65 @@ -import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Badge, - Button, - Card, - Group, - Loader, - Modal, - Stack, - Tabs, - Text, -} from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { ChevronRight, Ship, Train, Truck } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; -import toast from "react-hot-toast"; +import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; +import { ChevronRight, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; -import { - useDjClearanceQueue, - useDjClearanceSchedules, -} from "@/hooks/contracts/useContracts"; -import { contractsService } from "@/services/contracts.service"; +import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); - const schedulesQuery = useDjClearanceSchedules(); const contractItems = contractQueue?.items ?? []; - const scheduleItems = schedulesQuery.data ?? []; - - const [gatepassTarget, setGatepassTarget] = - useState(null); - const [gatepassAt, setGatepassAt] = useState(new Date()); - const [granting, setGranting] = useState(false); - - const columns = useMemo[]>( - () => [ - { - header: "Train", - accessorKey: "trainNumber", - cell: ({ row }) => ( - - {row.original.trainNumber ?? "—"} - - ), - }, - { - header: "Route", - id: "route", - cell: ({ row }) => ( - - {row.original.origin ?? "—"} → {row.original.destination ?? "—"} - - ), - }, - { - header: "Scheduled departure", - id: "scheduled", - cell: ({ row }) => ( - - {row.original.scheduledDepartureDate - ? new Date(row.original.scheduledDepartureDate).toLocaleDateString() - : "—"} - - ), - }, - { - header: "Departed", - id: "departed", - cell: ({ row }) => ( - - {row.original.actualDepartureAt - ? new Date(row.original.actualDepartureAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Arrived", - id: "arrived", - cell: ({ row }) => ( - - {row.original.actualArrivalAt - ? new Date(row.original.actualArrivalAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Status", - accessorKey: "status", - cell: ({ row }) => ( - - {row.original.status} - - ), - }, - { - header: "Customs bookings", - id: "customs", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const directions = [...new Set(bookings.map((b) => b.tradeDirection))]; - return ( - - - {bookings.length} - - {directions.map((d) => ( - - {d} - - ))} - - ); - }, - }, - { - header: "Gate pass", - id: "gatepass", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const allGranted = - bookings.length > 0 && bookings.every((b) => b.gatepassGranted); - const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null; - if (allGranted) { - return ( - - Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""} - - ); - } - return ( - - ); - }, - }, - ], - [], - ); return ( - - - Contracts ({contractItems.length}) - }> - Schedules ({scheduleItems.length}) - - - - - {contractsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 ? ( - - No Djibouti customs contracts yet. - - ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
-
- - - Contract - - - -
-
- )) - )} -
- )} -
- - - void schedulesQuery.refetch(), - } - : undefined - } - emptyMessage="No train schedules carry customs bookings yet." - /> - -
- - setGatepassTarget(null)} - title={ - - - - Gate pass — train {gatepassTarget?.trainNumber ?? ""} + {contractsLoading ? ( + + + + ) : ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. - - } - radius="md" - size="sm" - > - - - Grants the gate pass for all{" "} - {gatepassTarget?.customsBookings.length ?? 0} customs booking - {(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this - train. - - setGatepassAt(v ? new Date(v) : null)} - required - /> - - - - + ) : ( + contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + + +
+
+ )) + )}
-
+ )}
); } - -function statusColor(status: string): string { - switch (status) { - case "SCHEDULED": - return "blue"; - case "DISPATCHED": - return "yellow"; - case "ARRIVED": - return "edr-green"; - default: - return "gray"; - } -} diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 8860df62a..7ad051ce7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -391,35 +391,6 @@ export const contractsService = { return unwrap(response.data) as Freight.ClearanceT1State; }, - /** Train schedules carrying customs bookings — GL DJ gate-pass table. */ - getDjClearanceSchedules: async (): Promise => { - const response = await client.get(C.CLEARANCE_DJ_SCHEDULES); - return unwrap(response.data) as Freight.DjClearanceSchedule[]; - }, - - /** Gate pass for every customs booking on a train schedule (captures time). */ - grantScheduleGatepass: async ( - scheduleId: string, - gatepassAt?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => { - const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), { - gatepassAt, - }); - return unwrap(response.data) as { - granted: number; - skipped: Array<{ bookingId: string; error: string }>; - }; - }, - - /** Gate pass for a single customs booking (captures time). */ - grantGatepass: async ( - bookingId: string, - gatepassAt?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> => { - const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt }); - return unwrap(response.data) as { bookingId: string; gatepassAt: string }; - }, - /** GL DJ raises the post-offload final invoice (amount + invoice document). */ sendFinalInvoice: async ( bookingId: string, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index fe653573d..829ae3217 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -265,6 +265,7 @@ export interface ClearanceT1State { /** Train link state for the booking tied to a customs clearance flow. */ export interface ClearanceTrainState { + scheduleId: string | null; wagonAllocated: boolean; departedAt: string | null; arrivedAt: string | null; @@ -305,31 +306,6 @@ export interface ClearanceSecondDuty { paid: boolean; } -/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */ -export interface DjClearanceScheduleBooking { - bookingId: string; - reference: string; - tradeDirection: string; - contractId: string | null; - gatepassGranted: boolean; - gatepassAt: string | null; -} - -/** Train schedule row for the GL Djibouti gate-pass table. */ -export interface DjClearanceSchedule { - id: string; - trainNumber: string | null; - routeName: string | null; - origin: string | null; - destination: string | null; - status: string; - scheduledDepartureDate: string | null; - actualDepartureAt: string | null; - actualArrivalAt: string | null; - freightType: string | null; - customsBookings: DjClearanceScheduleBooking[]; -} - export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ From 8916182a6248018d7058e76c6223f15db3517946 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:56:23 +0000 Subject: [PATCH 14/73] Truck assign containers --- .../bookings/booking-transition.service.ts | 11 +++++++ .../CustomerTruckAssignmentCard.tsx | 33 +++++++++++++++---- packages/types/src/freight/index.ts | 4 +++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 35fc7fd54..06edbf04e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1086,6 +1086,9 @@ export class BookingTransitionService { offeredAmount: number; paymentDeadline: Date; } | null; + /** Flat list of physical container numbers on this booking (for the + * customer truck-assignment container picker). */ + containerNumbers: string[]; } > { // This enrichment runs AFTER the transition has committed. A failure here @@ -1141,12 +1144,20 @@ export class BookingTransitionService { `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, ); } + // Physical container numbers entered at booking time (booking_container + // units), flattened for the customer truck-assignment container picker. + const containerNumbers = (booking.bookingContainers ?? []) + .flatMap((bc) => bc.units ?? []) + .map((unit) => unit.containerNumber) + .filter((n): n is string => Boolean(n)); + return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, activeBatchOffer, + containerNumbers, }; } } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 37e65ab2b..c6ec6bf5d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -38,6 +38,11 @@ export function CustomerTruckAssignmentCard({ ); const [error, setError] = useState(null); + // Physical container numbers on this booking — the customer picks which one to + // load onto the truck instead of typing it. Falls back to free entry when the + // booking has no container numbers recorded. + const containerOptions = booking.containerNumbers ?? []; + const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); @@ -120,13 +125,27 @@ export function CustomerTruckAssignmentCard({ onChange={(value) => setTruckType(value ?? "")} disabled={assigned} /> - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> + {containerOptions.length > 0 ? ( + setTruckType(value ?? "")} + /> + + + + + + + ) : ( + trucks.length > 0 && ( + + All containers on this booking have been assigned to a truck. + + ) )} - - setTruckPlateNumber(e.currentTarget.value)} - readOnly={assigned} - /> - setDriverName(e.currentTarget.value)} - readOnly={assigned} - /> - setContainerNumberToLoad(value ?? "")} - searchable - disabled={assigned} - nothingFoundMessage="No matching container" - /> - ) : ( - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> - )} - - - - {assigned ? ( + {trucks.length > 0 && ( + - ) : ( - - )} - + + )} ); diff --git a/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts new file mode 100644 index 000000000..ee317e204 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/customer-trucks.service.ts @@ -0,0 +1,33 @@ +import type { Freight } from "@edr/types"; + +import { URL_CONSTANTS } from "@/constants/URLS"; +import { client } from "../utils/api"; + +const B = URL_CONSTANTS.BOOKINGS; + +/** + * Multi-truck self-haul assignment for a booking (no EDR first/last mile). + * Each truck carries 1–2 of the booking's containers and tracks its own arrival. + */ +export const customerTrucksService = { + list: async (bookingId: string): Promise => { + const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId)); + return data.data ?? data; + }, + + add: async ( + bookingId: string, + payload: Freight.AddCustomerTruckPayload, + ): Promise => { + const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload); + return data.data ?? data; + }, + + remove: async ( + bookingId: string, + assignmentId: string, + ): Promise => { + const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId)); + return data.data ?? data; + }, +}; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 705439904..698266f67 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -383,6 +383,32 @@ export interface IYard extends BaseEntity { displayOrder: number; } +/** One container number loaded onto a customer self-haul truck. */ +export interface ICustomerTruckContainer { + id: string; + containerNumber: string; +} + +/** A customer self-haul truck on a booking, carrying 1–2 containers. */ +export interface ICustomerTruck { + id: string; + bookingId: string; + plateNumber: string; + driverName: string; + truckType: string; + assignedAt: string; + arrivedAt?: string | null; + containers?: ICustomerTruckContainer[]; +} + +/** Payload to add a customer self-haul truck (1–2 container numbers). */ +export interface AddCustomerTruckPayload { + truckPlateNumber: string; + driverName: string; + truckType: string; + containerNumbers: string[]; +} + export interface IBooking extends BaseEntity { reference: string; customerId: string; @@ -434,6 +460,8 @@ export interface IBooking extends BaseEntity { customerTruckArrivedAt?: string | null; customsClearingEnabled?: boolean; + // (multi-truck self-haul lives in ICustomerTruck[], fetched via the + // /customer-trucks endpoint; the fields above are the booking-level flag.) customsClearingAgent?: string | null; equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN"; From b866b59478086cb62d7c1f17854e3679e1bb90ee Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:02:49 +0000 Subject: [PATCH 20/73] feat: add etrade precheck --- .../modules/companies/companies.service.ts | 6 +- .../companies/dto/create-company.dto.ts | 7 +- .../companies/dto/etrade-response.dto.ts | 2 + .../companies/dto/update-profile.dto.ts | 7 +- .../src/components/onboarding/ETradeInfo.tsx | 95 ++++++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 4 +- packages/types/src/freight/etrade.ts | 2 + 7 files changed, 86 insertions(+), 37 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 02f77b2e0..62be578bb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1183,9 +1183,11 @@ export class CompaniesService { const { businessInfo } = await this.etradeService.resolveCompanyData(tin); if (!businessInfo) { throw new BadRequestException( - "No business license found for this TIN. Please check the number and try again.", + "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo); + const registrationData = this.etradeService.extractRegistrationData(businessInfo); + const tinTaken = await this.companiesRepo.existsByTin(tin); + return { ...registrationData, tinTaken }; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index e5b686d11..a56ea5ad8 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -17,10 +17,7 @@ export class CreateCompanyDto { @IsString() @IsNotEmpty() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin!: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts index 200b69fee..ef7eb2a21 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData { managerName!: string; managerEmail?: string; managerPhone!: string; + tinTaken?: boolean; constructor(data: CompanyRegistrationData) { this.licenceNumber = data.licenceNumber; @@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData { this.managerName = data.managerName; this.managerEmail = data.managerEmail; this.managerPhone = data.managerPhone; + this.tinTaken = data.tinTaken; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index 316038dc9..9fd8f28ae 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; @@ -34,10 +34,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() - @Length(10, 10) - @Matches(/^00\d{8}$/, { - message: 'TIN must be 10 digits starting with 00', - }) + @Length(10, 10, { message: 'TIN must be exactly 10 digits' }) tin?: string; @IsOptional() diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 98efc2613..6c38bba46 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -7,9 +7,11 @@ import { Text, TextInput, } from "@mantine/core"; +import { useEffect, useRef } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; -import { AlertCircle, CheckCircle2, Download } from "lucide-react"; +import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; +import { extractApiError } from "@/utils/result"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { @@ -22,6 +24,8 @@ interface ETradeInfoProps { onDataLoaded: (data: CompanyRegistrationData) => void; } +const isValidTin = (tin: string) => tin.length === 10; + export default function ETradeInfo({ tin, register, @@ -30,53 +34,100 @@ export default function ETradeInfo({ }: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasData = mutation.data; + const tinTaken = mutation.data?.tinTaken; + const hasData = + mutation.data && !mutation.data.tinTaken ? mutation.data : null; const handleFetch = async () => { - if (!tin || tin.length !== 10 || !tin.startsWith("00")) return; + if (!isValidTin(tin)) return; const result = await mutation.mutateAsync(tin); - if (result) { + if (result && !result.tinTaken) { onDataLoaded(result); } }; - const errorMessage = + // Auto-fetch as soon as the TIN reaches its full 10-digit length — only + // once per distinct value, so retyping the same TIN doesn't refetch. + const lastFetchedTin = useRef(null); + useEffect(() => { + if (isValidTin(tin) && lastFetchedTin.current !== tin) { + lastFetchedTin.current = tin; + handleFetch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tin]); + + const apiError = mutation.isError && mutation.error - ? (mutation.error as any).message || - "Failed to fetch company information. Please try again." + ? extractApiError(mutation.error) + : null; + // A 400 here means eTrade simply has no record for this TIN — not a + // failure. Soft-pedal it as an FYI, not a red error, so filling in + // manually doesn't feel like something went wrong. + const notFound = apiError?.statusCode === 400; + const errorMessage = + apiError && !notFound + ? apiError.message || + "We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below." : null; return ( TIN Number (10 digits) *} + label={ + <> + TIN Number (10 digits){" "} + * + + } placeholder="0012345678" maxLength={10} error={error} {...register} /> - + {errorMessage && ( + + )} + {notFound && ( + } color="gray"> + We couldn't find a matching business record for this TIN — no + problem, just fill in the details below. + + )} + {errorMessage && ( } color="red" - title="Failed to fetch data" + title="Couldn't fetch eTrade data" > - {errorMessage} You can still fill in the details manually below. + {errorMessage} + + )} + + {tinTaken && ( + } + color="red" + title="TIN already registered" + > + This TIN is already registered to another company account. Please + double-check the number, or contact support if you believe this is a + mistake. )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 85af9bfdd..3cce6d4f8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -450,8 +450,6 @@ export default function CompanyProfileForm({ onDataLoaded={handleETradeDataLoaded} /> - - - + Date: Sat, 4 Jul 2026 09:03:24 +0000 Subject: [PATCH 21/73] changes --- .../train-scheduling.service.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 3b36fdc9a..08c2ea229 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -20,6 +20,7 @@ import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { Container } from '../container-management/entities/container.entity'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; @@ -1168,12 +1169,47 @@ export class TrainSchedulingService { notes: dto.notes ?? operation.notes ?? null, }); + await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt); + console.log( `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`, ); return this.getImportDjiboutiOperation(schedule.id); } + /** + * Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED + * milestone for every customs booking on this schedule, so contract/booking + * clearance views still reading that milestone (older deployed builds) see + * the gate pass as done. Drop once every clearance-api deployment reads + * ImportDjiboutiOperation.gatepassGrantedAt directly. + */ + private async completeGatepassMilestoneForSchedule( + scheduleId: string, + securedAt: Date, + ): Promise { + const bookings = await this.dataSource.getRepository(Booking).find({ + where: { trainScheduleId: scheduleId, customsClearingEnabled: true }, + }); + if (bookings.length === 0) return; + + const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone); + const rows = await milestoneRepo.find({ + where: { + bookingId: In(bookings.map((b) => b.id)), + milestoneCode: 'GATEPASS_GRANTED', + }, + }); + + for (const row of rows) { + if (row.status === 'COMPLETED') continue; + row.status = 'COMPLETED'; + row.triggeredAt = securedAt; + row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() }; + await milestoneRepo.save(row); + } + } + async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { const schedule = await this.getImportDjiboutiSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); From a70804da1c742dcf231db4030fa68d74e83a8a26 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:08:40 +0000 Subject: [PATCH 22/73] fix: gm step --- .../src/pages/accounts/CompanyProfileForm.tsx | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3cce6d4f8..d7c3b913b 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react"; +import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -279,22 +279,39 @@ export default function CompanyProfileForm({ }); }; - /** Fill the General Manager from the eTrade business owner. */ - const useOwnerAsManager = () => { - if (!etradeOwner) return; - setValue("generalManagerName", etradeOwner.name); - setValue("generalManagerEmail", user.email); - setValue("generalManagerPhone", etradeOwner.phone ?? "", { - shouldValidate: true, - }); - }; - // "Same as …" links. A checked card prefills the target step's fields from the // source step and disables them (kept mirrored while linked); unchecking clears // them and re-enables editing. + const [gmSameAsOwner, setGmSameAsOwner] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false); const [poaSameAsContact, setPoaSameAsContact] = useState(false); + // General Manager source: the eTrade-registered business owner when a TIN + // lookup found one, otherwise the registering user's own account details. + const gmSourceName = etradeOwner?.name ?? user.name?.en ?? ""; + const gmSourcePhone = etradeOwner + ? etradeOwner.phone + : toEthiopianE164(user.phoneNumber); + + useEffect(() => { + if (!gmSameAsOwner) return; + setValue("generalManagerName", gmSourceName, { shouldValidate: true }); + setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true }); + setValue("generalManagerPhone", gmSourcePhone ?? "", { + shouldValidate: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]); + + const toggleGmSameAsOwner = (checked: boolean) => { + setGmSameAsOwner(checked); + if (!checked) { + setValue("generalManagerName", ""); + setValue("generalManagerEmail", ""); + setValue("generalManagerPhone", ""); + } + }; + const gmName = watch("generalManagerName"); const gmEmail = watch("generalManagerEmail"); const gmPhone = watch("generalManagerPhone"); @@ -584,22 +601,19 @@ export default function CompanyProfileForm({ {step === "personnel" && ( <> - - - General Manager - - {etradeOwner && ( - - )} - + + General Manager + + Date: Sat, 4 Jul 2026 09:21:16 +0000 Subject: [PATCH 23/73] changes --- apps/edr-freight-api/Dockerfile | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b781fb4c0..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,9 +7,6 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -# Puppeteer uses the system Chromium installed in the runner stage — skip the -# ~150MB bundled-Chromium download during pnpm install. -ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -35,14 +32,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the -# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects -# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation -# falls back to a degraded hand-built layout. -RUN apk add --no-cache libc6-compat \ - chromium nss freetype harfbuzz ca-certificates ttf-freefont +RUN apk add --no-cache libc6-compat ENV NODE_ENV=production -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs From 7a8e8dbc961bee789e6dda6fc76a71b5c3793304 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Sat, 4 Jul 2026 12:25:41 +0300 Subject: [PATCH 24/73] Update deploy.yml --- .github/workflows/deploy.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 72ad6de66..62530611c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -182,24 +182,6 @@ jobs: set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate - - name: Verify deployment health - if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) - run: | - set -euo pipefail - PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) - echo "Waiting for service to become healthy on port ${PORT}..." - for i in $(seq 1 12); do - if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then - echo "Service is healthy." - exit 0 - fi - echo "Attempt ${i}/12 — not ready yet, waiting 10s..." - sleep 10 - done - echo "Service failed health check after 120s — rolling back" - docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true - exit 1 - - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp From 1c15a2117b10491c27cdf55aea23352f948ffbfe Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:27:22 +0000 Subject: [PATCH 25/73] feat: add verifcation on the document step --- .../components/onboarding/RoleLicenseStep.tsx | 8 ++ .../src/pages/accounts/CompanyProfileForm.tsx | 100 +++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx index 451222841..fa1061622 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -70,6 +70,8 @@ interface RoleLicenseStepProps { /** Newly-selected files per profile id (not yet uploaded). */ value: Record; onChange: (value: Record) => void; + /** "Business license is required" style error, keyed by profile id. */ + errors?: Record; } /** @@ -82,6 +84,7 @@ export default function RoleLicenseStep({ profiles, value, onChange, + errors, }: RoleLicenseStepProps) { const setFiles = (profileId: string, files: File[]) => { onChange({ ...value, [profileId]: files }); @@ -123,6 +126,11 @@ export default function RoleLicenseStep({ file={buildLicenseSetting(profile.id, label)} value={{ [LICENSE_FILE_KEY]: selected }} uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined} + errors={ + errors?.[profile.id] + ? { [LICENSE_FILE_KEY]: errors[profile.id] } + : undefined + } onChange={(v) => { const next = v[LICENSE_FILE_KEY]; const files = Array.isArray(next) ? next : next ? [next] : []; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index d7c3b913b..cc9f81a29 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { CompanyRegistrationData } from "@edr/types"; import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; +import { getMinFiles } from "@/types/fileUploadSettings"; import { api } from "@/services/api"; import RoleLicenseStep, { type RoleLicenseProfile, @@ -358,6 +359,72 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); + // Hard verification for the documents step: required company-level + // documents and a business license per operational profile must both be + // present before the user can continue. + const [documentFieldErrors, setDocumentFieldErrors] = useState< + Record + >({}); + const [licenseFieldErrors, setLicenseFieldErrors] = useState< + Record + >({}); + + const validateRequiredDocuments = (): Record => { + const errs: Record = {}; + for (const field of uploadSetting?.fields ?? []) { + const min = getMinFiles(field); + if (min <= 0) continue; + if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; + const v = documentFiles[field.fileKey]; + const count = Array.isArray(v) ? v.length : v ? 1 : 0; + if (count < min) { + errs[field.fileKey] = `${field.fileLabel} is required`; + } + } + return errs; + }; + + // Every role needs at least one license file (existing or newly selected). + const validateLicenses = (): Record => { + const errs: Record = {}; + for (const p of roleProfiles ?? []) { + const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0; + const hasExisting = p.existingFiles.length > 0; + if (!hasNew && !hasExisting) { + errs[p.id] = "Business license is required"; + } + } + return errs; + }; + + const handleDocumentFilesChange = ( + next: Record, + ) => { + setDocumentFiles(next); + setDocumentFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const key of Object.keys(updated)) { + const v = next[key]; + const hasValue = Array.isArray(v) ? v.length > 0 : v != null; + if (hasValue) delete updated[key]; + } + return updated; + }); + }; + + const handleLicenseFilesChange = (next: Record) => { + onLicenseChange?.(next); + setLicenseFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const id of Object.keys(updated)) { + if ((next[id]?.length ?? 0) > 0) delete updated[id]; + } + return updated; + }); + }; + // The registration/license details come straight from the eTrade lookup and // are not user-editable — shown as a read-only confirmation once a TIN lookup // (or rehydration) has filled them in. The address fields below are separate: @@ -402,18 +469,21 @@ export default function CompanyProfileForm({ } }; - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - const nextStep = async () => { userNavigatedRef.current = true; - // The documents step auto-uploads whatever the user selected as they - // continue (partial uploads are allowed — required-doc completeness is - // re-checked on resume). A failed upload holds them on the step. + // The documents step hard-blocks on required company documents and a + // business license per operational profile before it auto-uploads and + // submits — no partial-completion path forward. if (step === "documents") { + const docErrors = validateRequiredDocuments(); + const licenseErrors = validateLicenses(); + if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) { + setDocumentFieldErrors(docErrors); + setLicenseFieldErrors(licenseErrors); + setSaveError("Please upload all required documents before continuing."); + return; + } + if (onUploadDocuments) { setSaving(true); try { @@ -427,12 +497,6 @@ export default function CompanyProfileForm({ } } - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } setSaveError(null); handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; @@ -749,15 +813,17 @@ export default function CompanyProfileForm({ file={uploadSetting} value={documentFiles} uploadedKeys={uploadedDocumentKeys} + errors={documentFieldErrors} containerClassName="lg:grid grid-cols-2 items-stretch" - onChange={setDocumentFiles} + onChange={handleDocumentFilesChange} /> )} { })} + onChange={handleLicenseFilesChange} + errors={licenseFieldErrors} /> )} From 66ffd51d5b355a82c62d58c60ea735dbfe97742f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 4 Jul 2026 09:27:38 +0000 Subject: [PATCH 26/73] style: update the multi file document ui --- .../src/components/SmartFileInput/index.tsx | 600 ++++++++++++------ 1 file changed, 397 insertions(+), 203 deletions(-) diff --git a/packages/ui-common/src/components/SmartFileInput/index.tsx b/packages/ui-common/src/components/SmartFileInput/index.tsx index 0da3e2f37..1e1eaf223 100644 --- a/packages/ui-common/src/components/SmartFileInput/index.tsx +++ b/packages/ui-common/src/components/SmartFileInput/index.tsx @@ -153,6 +153,76 @@ function ExistingFileLink({ ); } +/** + * A file the user just picked (in memory, not yet persisted). Rendered with a + * subtle "just added" entrance + an emerald accent so a fresh upload reads as + * distinct from the neutral surrounding surface. + */ +function NewFileCard({ + file: fileObj, + onRemove, + disabled, + hasError, + inputName, +}: { + file: File; + onRemove: () => void; + disabled?: boolean; + hasError?: boolean; + inputName: string; +}) { + return ( +
+
+
+ +
+ +
+

+ {fileObj.name} +

+
+ + {formatBytes(fileObj.size)} + + + Ready to upload + +
+
+
+ + + + {/* Hidden input to represent file details in traditional form submissions */} + +
+ ); +} + export function SmartFileInput({ file, value, @@ -407,228 +477,352 @@ export function SmartFileInput({

)} - {/* Selected Files List */} - {currentFiles.length > 0 && ( -
- {currentFiles.map((fileObj, idx) => ( -
-
-
- -
- -
-

- {fileObj.name} -

-
- - {formatBytes(fileObj.size)} - - - Ready - -
-
-
- - - - {/* Hidden inputs to represent file details in traditional form submissions */} - -
- ))} -
- )} - - {/* Dropzone area */} - {!reachedLimit && - (variant === "minimal" ? ( -
- + {/* + Multiple-file fields (default variant) render as ONE integrated + drag-and-drop surface. Uploaded files live INSIDE the dropzone as + lightweight rows — part of the surface, not separate cards — with + the "add more" prompt on the same surface below them. A full-cover + transparent input makes clicking anywhere (outside a file row) + open the picker; the prompt is pointer-transparent so clicks fall + through to it, while file rows and their controls sit above it. + */} + {variant === "default" && field.isMultiple ? ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative flex flex-col gap-2.5 rounded-xl border-2 border-dashed p-4 transition-all", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : fieldError + ? "border-destructive/70" + : "border-border bg-card/40 hover:border-primary/40", + disabled && "pointer-events-none opacity-50", + )} + > + {/* Click anywhere on the surface (except a file row) to browse */} + {!reachedLimit && ( { - if (fileInputRefs.current) { - fileInputRefs.current[field.fileKey] = el; - } - }} - multiple={field.isMultiple} + multiple accept={acceptString} disabled={disabled} onChange={(e) => handleFileSelect(e, field)} - className="hidden" - /> - - Accepts:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} - - {existingForField.length > 0 && ( -
- {existingForField.map((f, idx) => ( - - ))} -
- )} -
- ) : isUploaded ? ( - // Uploaded state: a solid success panel that still doubles as a - // replace target (click anywhere or drag a new file onto it). -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", - isDragOver - ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" - : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", - disabled && - "opacity-50 pointer-events-none cursor-not-allowed", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - aria-label={`Replace ${field.fileLabel}`} + className="absolute inset-0 z-0 h-full w-full cursor-pointer opacity-0 disabled:cursor-not-allowed" + aria-label={`Add files to ${field.fileLabel}`} /> + )} -
- {isDragOver ? ( - - ) : ( - - )} -
- -
-

- {isDragOver ? "Drop to replace" : "Document uploaded"} -

- {existingForField.length > 0 ? ( -
- {existingForField.map((f, idx) => ( + {(existingForField.length > 0 || currentFiles.length > 0) && ( +
+ {/* Already-saved (server) files — view/download only */} + {existingForField.map((f, idx) => ( +
+ +
- ))} +
+ + Saved +
- ) : ( -

- {isDragOver - ? "Release to replace the document on file." - : "Saved to your application. Drag a new file here or click to replace it."} -

+ ))} + + {/* Just-added (in-memory) files */} + {currentFiles.map((fileObj, idx) => ( +
+ +
+

+ {fileObj.name} +

+

+ {formatBytes(fileObj.size)} +

+
+ + Ready + + + +
+ ))} +
+ )} + + {reachedLimit ? ( +
+ + Maximum of {maxFiles} files reached +
+ ) : ( +
0 || currentFiles.length > 0 + ? "py-1" + : "py-6", )} -
- - - - Replace - -
- ) : ( -
handleDrag(e, field.fileKey, true)} - onDragLeave={(e) => handleDrag(e, field.fileKey, false)} - onDrop={(e) => handleDrop(e, field)} - className={cn( - "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", - isDragOver - ? "border-primary bg-primary/5 dark:bg-primary/10" - : "border-border hover:border-primary/50 hover:bg-muted/10", - fieldError && - "border-destructive hover:border-destructive/80", - disabled && - "opacity-50 pointer-events-none cursor-not-allowed", - )} - > - handleFileSelect(e, field)} - id={`file-input-${field.fileKey}`} - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" - /> - -
- +
0 || currentFiles.length > 0 + ? "p-1.5" + : "p-3", )} - /> + > + 0 || + currentFiles.length > 0 + ? "h-4 w-4" + : "h-6 w-6", + isDragOver && "animate-bounce text-primary", + )} + /> +
+

+ {isDragOver + ? "Drop your files here" + : existingForField.length > 0 || + currentFiles.length > 0 + ? "Add more files, or " + : "Drag & drop your files here, or "} + {!isDragOver && ( + browse + )} +

+

+ {field.allowedExtensions.join(", ").toUpperCase() || + "All formats"} + {" • "} + {currentFiles.length}/{maxFiles} added +

+ )} +
+ ) : ( + <> + {/* Selected Files List */} + {currentFiles.length > 0 && ( +
+ {currentFiles.map((fileObj, idx) => ( + removeFile(field.fileKey, idx)} + /> + ))} +
+ )} -

- Drag & drop your file here, or{" "} - - browse - -

+ {/* Dropzone area */} + {!reachedLimit && + (variant === "minimal" ? ( +
+ + { + if (fileInputRefs.current) { + fileInputRefs.current[field.fileKey] = el; + } + }} + multiple={field.isMultiple} + accept={acceptString} + disabled={disabled} + onChange={(e) => handleFileSelect(e, field)} + className="hidden" + /> + + Accepts:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} + + {existingForField.length > 0 && ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ )} +
+ ) : isUploaded ? ( + // Uploaded state: a solid success panel that still doubles as a + // replace target (click anywhere or drag a new file onto it). +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "group relative flex items-center gap-4 rounded-lg border p-4 transition-all", + isDragOver + ? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10" + : "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + aria-label={`Replace ${field.fileLabel}`} + /> -

- Supported formats:{" "} - {field.allowedExtensions.join(", ").toUpperCase() || - "All"} -

-
- ))} +
+ {isDragOver ? ( + + ) : ( + + )} +
+ +
+

+ {isDragOver + ? "Drop to replace" + : "Document uploaded"} +

+ {existingForField.length > 0 ? ( +
+ {existingForField.map((f, idx) => ( + + ))} +
+ ) : ( +

+ {isDragOver + ? "Release to replace the document on file." + : "Saved to your application. Drag a new file here or click to replace it."} +

+ )} +
+ + + + Replace + +
+ ) : ( +
handleDrag(e, field.fileKey, true)} + onDragLeave={(e) => handleDrag(e, field.fileKey, false)} + onDrop={(e) => handleDrop(e, field)} + className={cn( + "relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50", + isDragOver + ? "border-primary bg-primary/5 dark:bg-primary/10" + : "border-border hover:border-primary/50 hover:bg-muted/10", + fieldError && + "border-destructive hover:border-destructive/80", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed", + )} + > + handleFileSelect(e, field)} + id={`file-input-${field.fileKey}`} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed" + /> + +
+ +
+ +

+ Drag & drop your file here, or{" "} + + browse + +

+ +

+ Supported formats:{" "} + {field.allowedExtensions.join(", ").toUpperCase() || + "All"} +

+
+ ))} + + )} {/* Validation Error Message */} {fieldError && ( From 00cd1fccf6c6c32e7f3bbdfbbe72ba2a015538cf Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 09:33:07 +0000 Subject: [PATCH 27/73] train loading for import --- ...ContainerReceiptToBookingContainerUnits.ts | 36 +++++ ...1970000000000-AddCustomerTruckDeparture.ts | 26 ++++ .../modules/bookings/bookings.controller.ts | 52 +++++++ .../src/modules/bookings/bookings.module.ts | 3 + .../bookings/container-receipt.service.ts | 145 ++++++++++++++++++ .../bookings/customer-truck.service.ts | 111 ++++++++++++-- .../bookings/dto/add-customer-truck.dto.ts | 14 +- .../bookings/dto/depart-customer-truck.dto.ts | 37 +++++ .../modules/bookings/dto/generate-grn.dto.ts | 17 ++ .../entities/booking-container-unit.entity.ts | 13 ++ .../customer-truck-assignment.entity.ts | 8 + .../warehouses/warehouse-inventory.service.ts | 69 ++++++++- .../CustomerTruckAssignmentCard.tsx | 37 +++-- 13 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts create mode 100644 apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts diff --git a/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts new file mode 100644 index 000000000..59a0441c5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1960000000000-AddContainerReceiptToBookingContainerUnits.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-container receive tracking. A booking's containers arrive individually + * (on separate self-haul trucks), so each container unit tracks whether it has + * been received into the port and, once staff confirm it, the GRN it belongs to. + * A single GRN covers the containers received together — so if the whole booking + * arrives at once, all its units share one GRN (per-booking GRN). + */ +export class AddContainerReceiptToBookingContainerUnits1960000000000 + implements MigrationInterface +{ + name = 'AddContainerReceiptToBookingContainerUnits1960000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS received_at timestamptz, + ADD COLUMN IF NOT EXISTS grn_number varchar(100) + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`); + await queryRunner.query(` + ALTER TABLE freight.booking_container_units + DROP COLUMN IF EXISTS received_to_port, + DROP COLUMN IF EXISTS received_at, + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts new file mode 100644 index 000000000..e36420751 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1970000000000-AddCustomerTruckDeparture.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Import self-haul trucks are weighed on leaving. The customer does not + * pre-specify what an import truck takes — staff register the containers loaded + * and the weighed gross when the truck departs. These columns capture that. + */ +export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface { + name = 'AddCustomerTruckDeparture1970000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2), + ADD COLUMN IF NOT EXISTS departed_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.customer_truck_assignments + DROP COLUMN IF EXISTS gross_weight_kg, + DROP COLUMN IF EXISTS departed_at + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 795040eeb..106b7ed5b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -62,7 +63,10 @@ import { import { ContractViewDto } from './dto/contract-view.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckService } from './customer-truck.service'; +import { GenerateGrnDto } from './dto/generate-grn.dto'; +import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { @@ -86,6 +90,7 @@ export class BookingsController { private readonly contractService: BookingContractService, private readonly bookingClearanceService: BookingClearanceService, private readonly customerTruckService: CustomerTruckService, + private readonly containerReceiptService: ContainerReceiptService, ) {} @Post() @@ -353,6 +358,53 @@ export class BookingsController { return this.customerTruckService.removeTruck(id, assignmentId); } + @Post(':id/customer-trucks/:assignmentId/depart') + @ApiOperation({ + summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', + }) + async departCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Body() dto: DepartCustomerTruckDto, + @CurrentUser() user: TCurrentUser, + ) { + // Weighing + registering the load on exit is a warehouse/gate staff action. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can register a truck departure'); + } + return this.customerTruckService.departTruck(id, assignmentId, dto); + } + + @Get(':id/received-pending-grn') + @ApiOperation({ summary: 'Containers received into port but not yet on a GRN' }) + async receivedPendingGrn( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.listReceivedPendingGrn(id); + } + + @Post(':id/generate-grn') + @ApiOperation({ + summary: + 'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch', + }) + async generateGrn( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: GenerateGrnDto, + @CurrentUser() user: TCurrentUser, + ) { + // GRN is a warehouse-staff action — no customer access. + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + throw new ForbiddenException('Only warehouse staff can view or generate GRNs'); + } + return this.containerReceiptService.generateGrn(id, dto.containerNumbers); + } + @Get(':id/tracking') @ApiOperation({ summary: "Shipment tracking timeline for a booking", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 8f750af34..2cb10ce8e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -37,6 +37,7 @@ import { CustomerTruckAssignment } from './entities/customer-truck-assignment.en import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; import { CustomerTruckService } from './customer-truck.service'; +import { ContainerReceiptService } from './container-receipt.service'; import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractsModule } from '../contracts/contracts.module'; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; @@ -99,6 +100,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ContractPdfService, CustomerTruckAssignmentsRepository, CustomerTruckService, + ContainerReceiptService, ], exports: [ BookingsService, @@ -106,6 +108,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingPricingService, BookingInvoiceService, CustomerTruckService, + ContainerReceiptService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts new file mode 100644 index 000000000..fde3ab797 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-receipt.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; + +export interface ReceivedUnitRow { + id: string; + containerNumber: string; + receivedToPort: boolean; + receivedAt: string | null; + grnNumber: string | null; +} + +/** + * Per-container receive + GRN tracking on booking_container_units. + * + * Containers arrive individually (on separate self-haul trucks), so each unit is + * flipped `received_to_port` when its truck arrives (auto). Staff then confirm a + * Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a + * batch, so if the whole booking arrives together every unit shares a single GRN + * (per-booking GRN); if trucks arrive separately each batch gets its own GRN. + */ +@Injectable() +export class ContainerReceiptService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Auto-mark the containers loaded on an arrived truck as received into the + * port. Idempotent — only flips units not already received. Runs inside the + * caller's transaction when a manager is supplied. + */ + async markReceivedForAssignment( + bookingId: string, + assignmentId: string, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + await m.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, + freight.customer_truck_containers ctc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND ctc.assignment_id = $2 + AND ctc.deleted_at IS NULL + AND ctc.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId, assignmentId], + ); + } + + /** Received-into-port containers that have not yet been assigned a GRN. */ + async listReceivedPendingGrn(bookingId: string): Promise { + return this.dataSource.query( + `SELECT bcu.id, + bcu.container_number AS "containerNumber", + bcu.received_to_port AS "receivedToPort", + bcu.received_at AS "receivedAt", + bcu.grn_number AS "grnNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ORDER BY bcu.received_at`, + [bookingId], + ); + } + + /** + * Confirm a GRN over the currently received-but-un-GRN'd containers (optionally + * a subset by container number). Assigns one GRN number to the whole batch and + * returns it with the covered containers. If the batch covers every container + * on the booking it is effectively a per-booking GRN. + */ + async generateGrn( + bookingId: string, + containerNumbers?: string[], + ): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> { + const [booking] = await this.dataSource.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + + return this.dataSource.transaction(async (manager) => { + const wanted = containerNumbers?.map((n) => n.trim().toUpperCase()); + const pending: ReceivedUnitRow[] = await manager.query( + `SELECT bcu.id, bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = true + AND bcu.grn_number IS NULL + ${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`, + wanted ? [bookingId, wanted] : [bookingId], + ); + if (!pending.length) { + throw new BadRequestException('No received containers are awaiting a GRN'); + } + + // Batch sequence = number of GRNs already issued for this booking + 1. + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT bcu.grn_number) AS batches + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`, + [bookingId], + ); + const seq = Number(batches) + 1; + const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`; + + const ids = pending.map((p) => p.id); + await manager.query( + `UPDATE freight.booking_container_units + SET grn_number = $1, updated_at = NOW() + WHERE id = ANY($2::uuid[])`, + [grnNumber, ids], + ); + + // Per-booking when no container on the booking is left un-GRN'd. + const [{ remaining }]: Array<{ remaining: string }> = await manager.query( + `SELECT COUNT(*) AS remaining + FROM freight.booking_container_units bcu + JOIN freight.booking_containers bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`, + [bookingId], + ); + + return { + grnNumber, + containerNumbers: pending.map((p) => p.containerNumber), + perBooking: Number(remaining) === 0 && seq === 1, + }; + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 5ea1a898c..5d0650219 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -7,6 +7,7 @@ import { import { DataSource, EntityManager, IsNull } from 'typeorm'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; @@ -41,17 +42,31 @@ export class CustomerTruckService { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); - const requested = dto.containerNumbers.map((n) => n.trim().toUpperCase()); - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + const isExport = booking.tradeDirection === 'EXPORT'; + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + + // EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are + // not pre-specified — they are registered + weighed when the truck leaves. + if (isExport) { + if (requested.length < 1 || requested.length > 2) { + throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers'); } + } else if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); } - const alreadyAssigned = await this.assignedContainerNumbers(bookingId); - for (const n of requested) { - if (alreadyAssigned.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); + + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const alreadyAssigned = await this.assignedContainerNumbers(bookingId); + for (const n of requested) { + if (alreadyAssigned.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } } } @@ -118,6 +133,71 @@ export class CustomerTruckService { return this.listTrucks(bookingId); } + /** + * Register an IMPORT self-haul truck leaving the port: the containers it + * actually loaded (replacing any provisional list) and its weighed gross. + * Export bookings have no truck departure — trucks only deliver (receive). + */ + async departTruck( + bookingId: string, + assignmentId: string, + dto: DepartCustomerTruckDto, + ): Promise { + const booking = await this.loadBookingGuard(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'Truck departure/weighing applies to import self-haul only (export trucks only deliver)', + ); + } + const assignment = await this.assignments.findByIdWithContainers(assignmentId); + if (!assignment || assignment.bookingId !== bookingId) { + throw new NotFoundException('Truck assignment not found for this booking'); + } + // Once filled, the departure record is uneditable. + if (assignment.departedAt) { + throw new ConflictException('This truck has already departed — its exit record is locked'); + } + + const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (requested.length) { + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + for (const n of requested) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + } + const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); + for (const n of requested) { + if (elsewhere.includes(n)) { + throw new ConflictException(`Container ${n} is already loaded onto another truck`); + } + } + } + + await this.dataSource.transaction(async (manager) => { + if (requested.length) { + // Replace the truck's containers with what was actually loaded. + await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId }); + await manager.getRepository(CustomerTruckContainer).save( + requested.map((containerNumber) => + manager.getRepository(CustomerTruckContainer).create({ + assignmentId, + bookingId, + containerNumber, + }), + ), + ); + } + await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { + grossWeightKg: dto.grossWeightKg, + departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(), + arrivedAt: assignment.arrivedAt ?? new Date(), + }); + }); + + return this.listTrucks(bookingId); + } + /** * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * receive flow. When every truck on the booking has arrived, the booking-level @@ -225,4 +305,17 @@ export class CustomerTruckService { ); return rows.map((r) => r.containerNumber.trim().toUpperCase()); } + + private async assignedContainerNumbersExcept( + bookingId: string, + exceptAssignmentId: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" + FROM freight.customer_truck_containers + WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`, + [bookingId, exceptAssignmentId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 17458dafa..4356d66ec 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -1,10 +1,10 @@ import { ArrayMaxSize, - ArrayMinSize, ArrayUnique, IsArray, IsIn, IsNotEmpty, + IsOptional, IsString, Matches, MaxLength, @@ -13,9 +13,11 @@ import { import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; /** - * Add one external customer truck to a booking, carrying 1–2 container numbers. - * Each container must be one of the booking's containers and not already loaded - * onto another truck (enforced in the service + a partial unique index). + * Add one external customer truck to a booking. + * - EXPORT: the truck delivers 1–2 known containers (required, validated in the + * service against the booking's containers). + * - IMPORT: the customer does not pre-specify — containers are registered and + * weighed when the truck leaves, so `containerNumbers` may be omitted/empty. */ export class AddCustomerTruckDto { @IsString() @@ -33,13 +35,13 @@ export class AddCustomerTruckDto { @IsIn(CUSTOMER_TRUCK_TYPES) truckType!: string; + @IsOptional() @IsArray() - @ArrayMinSize(1) @ArrayMaxSize(2) @ArrayUnique() @Matches(/^[A-Z]{4}\d{7}$/, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) - containerNumbers!: string[]; + containerNumbers?: string[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts new file mode 100644 index 000000000..31ab1b5bd --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -0,0 +1,37 @@ +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + Matches, + Min, +} from 'class-validator'; + +/** + * Register an import self-haul truck leaving the port: the containers it actually + * loaded (staff read them off the truck) and the weighed gross. Container numbers + * are optional here only because they may already have been recorded; the weighed + * gross is required. + */ +export class DepartCustomerTruckDto { + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @IsNumber() + @Min(0) + grossWeightKg!: number; + + /** Gate-out time. Defaults to now when omitted. */ + @IsOptional() + @IsDateString() + gateOutTime?: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts new file mode 100644 index 000000000..2f5ea86af --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-grn.dto.ts @@ -0,0 +1,17 @@ +import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator'; + +/** + * Confirm a Goods Received Note. Omit `containerNumbers` to GRN every + * received-but-un-GRN'd container on the booking (per-booking when that's all of + * them); pass a subset to GRN just those. + */ +export class GenerateGrnDto { + @IsOptional() + @IsArray() + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts index e8ef1b138..619013280 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity { @Column({ name: 'sort_order', type: 'smallint', default: 0 }) sortOrder!: number; + + /** Whether this container has been received into the port (auto-set when its + * self-haul truck arrives). */ + @Column({ name: 'received_to_port', type: 'boolean', default: false }) + receivedToPort!: boolean; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date | null; + + /** The GRN this container was received under (assigned when staff confirm the + * Goods Received Note for a batch of received containers). */ + @Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true }) + grnNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts index 83b70a135..6eeaba963 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/customer-truck-assignment.entity.ts @@ -34,6 +34,14 @@ export class CustomerTruckAssignment extends BaseEntity { @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + /** Weighed gross of what the truck actually loaded (import), captured on + * leaving. Null until the truck departs. */ + @Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true }) + grossWeightKg?: number | null; + + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + @OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true }) containers?: CustomerTruckContainer[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 9a9f8caa4..c9844051b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -920,6 +920,23 @@ export class WarehouseInventoryService { }), ); + // Receiving the booking flags every container unit as received into the + // port (self-haul export: the delivering truck's goods are now in) so + // staff can raise the per-container GRN over what's received. + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [bookingId], + ); + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -1774,6 +1791,26 @@ export class WarehouseInventoryService { await this.applyCapacityDelta(manager, dto, weight, volume, containerCount); + // Per-container receive: flag this container's unit as received into the + // port so staff can raise the GRN over what's received. + if (dto.bookingId && dto.containerId) { + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + updated_at = NOW() + FROM freight.booking_containers bc, freight.containers cont + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND bc.deleted_at IS NULL + AND cont.id = $2 + AND cont.container_number = bcu.container_number + AND bcu.deleted_at IS NULL + AND bcu.received_to_port = false`, + [dto.bookingId, dto.containerId], + ); + } + await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', @@ -2084,6 +2121,9 @@ export class WarehouseInventoryService { AND a.deleted_at IS NULL`, [item.bookingId, item.containerId], ); + // NB: import arrival changes nothing on the goods — received_to_port is + // an EXPORT concept (set when a truck delivers into the port). Import + // load + weight are captured on truck departure, not arrival. } // Booking-level flag stamped on the FIRST truck arrival. The import // handover is signed ONCE (before the first truck leaves), even though @@ -2175,12 +2215,16 @@ export class WarehouseInventoryService { truckType: string; containerNumbers: string; truckWeightTons: string | number | null; + grossWeightKg: string | number | null; + departedAt: string | null; } | null = null; if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers", COALESCE(( SELECT SUM(bcu.vgm_tons) @@ -2231,8 +2275,14 @@ export class WarehouseInventoryService { truckPlateNumber: truck?.plateNumber ?? null, truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, - truckContainers: truck?.containerNumbers ?? null, - truckWeightKg: truck ? Number(truck.truckWeightTons ?? 0) * 1000 : null, + truckGateOut: truck?.departedAt ?? null, + // Prefer the weighed gross captured on departure; fall back to the summed + // container VGM when the truck hasn't been weighed yet. + truckWeightKg: truck + ? Number(truck.grossWeightKg ?? 0) > 0 + ? Number(truck.grossWeightKg) + : Number(truck.truckWeightTons ?? 0) * 1000 + : null, }); return { @@ -3165,7 +3215,7 @@ export class WarehouseInventoryService { truckPlateNumber?: string | null; truckDriverName?: string | null; truckType?: string | null; - truckContainers?: string | null; + truckGateOut?: string | null; truckWeightKg?: number | null; }): string { const esc = (value: unknown) => @@ -3208,7 +3258,18 @@ export class WarehouseInventoryService { ['Pickup Truck Plate', data.truckPlateNumber], ['Truck Driver', data.truckDriverName], ['Truck Type', data.truckType], - ['Containers Loaded on Truck', data.truckContainers], + [ + 'Gate-Out Time', + data.truckGateOut + ? new Date(data.truckGateOut).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : null, + ], ] as [string, string | null][]) : []), ...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []), diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index ad7f6b72b..65af584d3 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -73,6 +73,10 @@ export function CustomerTruckAssignmentCard({ (n) => !assignedNumbers.has(n), ); + // EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't — + // staff register + weigh what was loaded when the truck leaves. + const isExport = booking.tradeDirection === "EXPORT"; + const resetForm = () => { setPlateNumber(""); setDriverName(""); @@ -87,7 +91,8 @@ export function CustomerTruckAssignmentCard({ truckPlateNumber: plateNumber.trim().toUpperCase(), driverName: driverName.trim(), truckType: truckType.trim(), - containerNumbers: containers, + // Import: containers are registered + weighed on departure, not here. + containerNumbers: isExport ? containers : [], }), onSuccess: (list) => { queryClient.setQueryData(trucksKey, list); @@ -118,7 +123,7 @@ export function CustomerTruckAssignmentCard({ setError("Plate number, driver name and truck type are required."); return; } - if (containers.length < 1 || containers.length > 2) { + if (isExport && (containers.length < 1 || containers.length > 2)) { setError("Select 1 or 2 container numbers for this truck."); return; } @@ -202,8 +207,8 @@ export function CustomerTruckAssignmentCard({ )} - {/* Add-truck form */} - {availableContainers.length > 0 ? ( + {/* Add-truck form. Export needs unassigned containers; import always allows another truck. */} + {(isExport ? availableContainers.length > 0 : true) ? ( <> @@ -226,17 +231,19 @@ export function CustomerTruckAssignmentCard({ value={truckType || null} onChange={(value) => setTruckType(value ?? "")} /> - + {isExport && ( + + )} + Continue + -

- Already have an account?{" "} - -

- - + < p className = "text-center text-sm text-gray-500" > + Already have an account ? { " "} + < button + type = "button" +onClick = {() => navigate("/login")} +className = "font-semibold text-primary hover:underline" + > + Sign In + +

+ + ) : ( - -
- - - -
-
-

- Verify your {otpChannel === "email" ? "email" : "phone"} -

-

- We sent a 6-digit code to{" "} - - {otpChannel === "email" - ? maskEmail(pendingData?.email ?? "") - : maskPhone(pendingData?.phone ?? "")} - - . Enter it to finish creating your account. + +

+ + + +
+ < div className = "space-y-1.5 text-center" > +

+ Verify your { otpChannel === "email" ? "email" : "phone" } +

+ < p className = "text-sm leading-relaxed text-gray-500" > + We sent a 6 - digit code to{ " " } + + { otpChannel === "email" + ? maskEmail(pendingData?.email ?? "") + : maskPhone(pendingData?.phone ?? "")} + + .Enter it to finish creating your account.

-
+
- {otpError ? ( - } +{ + otpError ? ( + } > - {otpError} - + { otpError } + ) : null} - - - Verification code - - - + + + Verification code + + < PinInput +length = { 6} +type = "number" +oneTimeCode +value = { otpCode } +placeholder = "0" +disabled = { verifying } +styles = {{ input: { textAlign: "center" } }} +onChange = { setOtpCode } + /> + - + < Button +color = "edr-green" +fullWidth +loading = { verifying } +disabled = { verifying || otpCode.trim().length !== 6} +onClick = { confirmOtp } + > + Verify & amp; create account + -
- - -
- + Back + + < Button +variant = "subtle" +color = "edr-green" +leftSection = {< RotateCw size = { 14} />} +disabled = { resendIn > 0 || sending || verifying} +onClick = { resendOtp } + > + { resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"} + +
+
)} -
- +
+ ); } diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 58b81c5ba..3f9ef4e53 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -54,7 +54,7 @@ export const authService = { }, checkAvailability: async (params: CheckAvailabilityPayload) => { - const res = await client.get>( + const res = await client.get( URL_CONSTANTS.USERS.CHECK_AVAILABILITY, { params }, ); From 595be6e123bb428972cdf1ba6c64673946b3331b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 5 Jul 2026 00:28:06 +0300 Subject: [PATCH 31/73] Tour package booking, app release, new endpoints, more updates and fixes --- .../migration.sql | 9 + .../migration.sql | 2 + .../migration.sql | 13 + apps/edr-passenger-api/prisma/schema.prisma | 25 +- apps/edr-passenger-api/prisma/seed.ts | 70 +- apps/edr-passenger-api/src/app.module.ts | 2 + .../common/filters/http-exception.filter.ts | 17 +- .../app-releases/app-releases.controller.ts | 50 ++ .../app-releases/app-releases.module.ts | 11 + .../app-releases/app-releases.service.ts | 71 ++ .../src/modules/audit/audit.controller.ts | 4 +- .../src/modules/bookings/bookings.dto.ts | 6 + .../src/modules/bookings/bookings.service.ts | 414 +++++++++-- .../modules/bookings/guest-booking.service.ts | 20 +- .../fare-engine/fare-engine.service.ts | 4 +- .../src/modules/fraud/fraud.controller.ts | 27 +- .../src/modules/fraud/fraud.module.ts | 3 +- .../src/modules/fraud/fraud.service.ts | 10 + .../src/modules/loyalty/loyalty.controller.ts | 4 +- .../src/modules/loyalty/loyalty.service.ts | 47 ++ .../modules/packages/packages.controller.ts | 32 +- .../src/modules/packages/packages.dto.ts | 8 +- .../src/modules/packages/packages.module.ts | 3 +- .../src/modules/packages/packages.service.ts | 115 ++- .../modules/passengers/passengers.service.ts | 54 +- .../modules/payments/payments.controller.ts | 9 + .../src/modules/payments/payments.service.ts | 7 + .../src/modules/search/search.controller.ts | 31 +- .../src/modules/search/search.dto.ts | 37 + .../src/modules/search/search.service.ts | 120 +++- .../seat-classes/seat-classes.service.ts | 19 +- .../src/modules/seats/seats.controller.ts | 19 + .../src/modules/seats/seats.service.ts | 69 +- .../src/modules/stations/stations.service.ts | 31 +- .../src/modules/wallet/wallet.controller.ts | 8 +- .../src/modules/wallet/wallet.service.ts | 46 ++ .../src/app/app-releases/layout.tsx | 5 + .../backoffice/src/app/app-releases/page.tsx | 186 +++++ .../backoffice/src/app/audit/page.tsx | 4 +- .../backoffice/src/app/bookings/page.tsx | 104 ++- .../backoffice/src/app/classes/page.tsx | 15 +- .../backoffice/src/app/coaches/page.tsx | 93 ++- .../backoffice/src/app/loyalty/page.tsx | 33 +- .../src/app/package-bookings/layout.tsx | 7 + .../src/app/package-bookings/page.tsx | 255 +++++++ .../backoffice/src/app/payments/page.tsx | 42 +- .../backoffice/src/app/reports/page.tsx | 83 ++- .../backoffice/src/app/routes/page.tsx | 15 +- .../backoffice/src/app/stations/page.tsx | 15 +- .../backoffice/src/app/trains/page.tsx | 15 +- .../src/app/wallet-accounts/layout.tsx | 5 + .../src/app/wallet-accounts/page.tsx | 206 ++++++ .../src/components/layout/Sidebar.tsx | 31 +- .../src/components/ui/ConfirmDialog.tsx | 32 +- .../backoffice/src/lib/api/bookings.ts | 2 + .../backoffice/src/lib/api/index.ts | 41 +- .../backoffice/src/types/index.ts | 2 + .../src/app/booking/confirmation/page.tsx | 21 +- .../portal/src/app/booking/payment/page.tsx | 4 +- .../portal/src/app/booking/results/page.tsx | 56 +- .../portal/src/app/booking/review/page.tsx | 117 +-- .../portal/src/app/booking/seats/page.tsx | 28 +- .../portal/src/app/packages/[id]/page.tsx | 676 +++++++----------- .../portal/src/app/packages/page.tsx | 5 + .../portal/src/components/PackagesSection.tsx | 11 +- .../portal/src/lib/booking-store.ts | 21 +- .../portal/src/types/index.ts | 1 + .../portal/src/utils/fare-utils.ts | 12 +- 68 files changed, 2773 insertions(+), 787 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts create mode 100644 apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts create mode 100644 apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/package-bookings/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/wallet-accounts/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/wallet-accounts/page.tsx create mode 100644 apps/edr-passenger-web/portal/src/app/packages/page.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql new file mode 100644 index 000000000..365c15559 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704132103_add_package_fields_to_booking/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Booking" ADD COLUMN "packageId" TEXT, +ADD COLUMN "priceTierId" TEXT; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "TravelPackage"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Booking" ADD CONSTRAINT "Booking_priceTierId_fkey" FOREIGN KEY ("priceTierId") REFERENCES "PackagePriceTier"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql new file mode 100644 index 000000000..51e09889a --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260704212551_add_fraud_alert_acknowledged_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FraudAlert" ADD COLUMN "acknowledgedAt" TIMESTAMP(3); diff --git a/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql new file mode 100644 index 000000000..0ce87d7de --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260705000000_add_app_releases/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE "passenger"."AppRelease" ( + "id" TEXT NOT NULL, + "os" TEXT NOT NULL, + "version" TEXT NOT NULL, + "forceUpdate" BOOLEAN NOT NULL DEFAULT false, + "storeLink" TEXT, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "AppRelease_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AppRelease_os_version_key" ON "passenger"."AppRelease"("os", "version"); diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 68b3ac234..ba3571ab1 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -506,6 +506,8 @@ model Booking { bookingRef String @unique passengerId String scheduleId String + packageId String? + priceTierId String? bookingType String @default("ONE_WAY") status BookingStatus @default(DRAFT) currency String @default("ETB") @@ -544,6 +546,8 @@ model Booking { passenger Passenger @relation(fields: [passengerId], references: [id]) schedule TrainSchedule @relation("OutboundSchedule", fields: [scheduleId], references: [id]) returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) + package TravelPackage? @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? tickets Ticket[] @@ -1301,6 +1305,7 @@ model FraudAlert { context Json severity String @default("MEDIUM") acknowledged Boolean @default(false) + acknowledgedAt DateTime? createdAt DateTime @default(now()) @@index([iamUserId, createdAt]) @@index([acknowledged]) @@ -1428,7 +1433,8 @@ model TravelPackage { outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id]) returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) priceTiers PackagePriceTier[] - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@index([status, validFrom]) @@ -1446,7 +1452,8 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) - bookings PackageBooking[] + bookings Booking[] + packageBookings PackageBooking[] inquiries PackageInquiry[] @@unique([packageId, seatType]) @@ -1536,3 +1543,17 @@ model PackageInquiry { @@index([packageId]) @@schema("passenger") } + +model AppRelease { + id String @id @default(uuid()) + os String // "android" | "ios" + version String + forceUpdate Boolean @default(false) + storeLink String? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([os, version]) + @@schema("passenger") +} diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 0e9f85079..313ce34b0 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -854,26 +854,64 @@ async function runStep(name: string, step: () => Promise): Promise Promise]> = [ - ['System Users', seedSystemUsers], - ['Stations', seedStations], - ['Coach Types & Classes', seedCoachTypesAndClasses], - ['Route', seedRoute], - ['Coaches', seedCoaches], - ['Trips', seedTrips], - ['Fare Rules', seedFareRules], - ['Currency', seedCurrency], - ['Payment Methods', seedPaymentMethods], - ['Segment Fares', seedSegmentFares], - ['Notification Templates', seedNotificationTemplates], - ['Menu & Food', seedMenuAndFood], - ['Promotions', seedPromotions], - ['FAQ', seedFAQ], - ['Fraud Rules', seedFraudRules], - ['Kulubbi Package', seedKulubbiPackage], + // ['System Users', seedSystemUsers], + // ['Stations', seedStations], + // ['Coach Types & Classes', seedCoachTypesAndClasses], + // ['Route', seedRoute], + // ['Coaches', seedCoaches], + // ['Trips', seedTrips], + // ['Fare Rules', seedFareRules], + // ['Currency', seedCurrency], + // ['Payment Methods', seedPaymentMethods], + // ['Segment Fares', seedSegmentFares], + // ['Notification Templates', seedNotificationTemplates], + // ['Menu & Food', seedMenuAndFood], + // ['Promotions', seedPromotions], + // ['FAQ', seedFAQ], + // ['Fraud Rules', seedFraudRules], + // ['Kulubbi Package', seedKulubbiPackage], + // ['Package Bookings', seedPackageBookings], ]; let failed = 0; diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index ba7a18086..65381f519 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -61,6 +61,7 @@ import { PackagesModule } from './modules/packages/packages.module'; import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module'; import { HealthModule } from './modules/health/health.module'; import { TasksModule } from './modules/tasks/tasks.module'; +import { AppReleasesModule } from './modules/app-releases/app-releases.module'; @Module({ imports: [ @@ -130,6 +131,7 @@ import { TasksModule } from './modules/tasks/tasks.module'; ExcessBaggageModule, HealthModule, TasksModule, + AppReleasesModule, ], providers: [ { provide: APP_GUARD, useClass: DynamicThrottlerGuard }, diff --git a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts index 39d492b2c..0cc8d93b6 100644 --- a/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts +++ b/apps/edr-passenger-api/src/common/filters/http-exception.filter.ts @@ -6,6 +6,7 @@ import { HttpStatus, Logger, } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -22,15 +23,27 @@ export class HttpExceptionFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); + let prismaMessage: string | null = null; + if (exception instanceof PrismaClientKnownRequestError) { + if (exception.code === 'P2003') { + const field = (exception.meta?.field_name as string | undefined) ?? 'a related record'; + prismaMessage = `Cannot delete this record because it is still referenced by ${field}. Remove the related records first.`; + } else if (exception.code === 'P2025') { + prismaMessage = 'Record not found.'; + } + } + const status = exception instanceof HttpException ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + : prismaMessage + ? HttpStatus.BAD_REQUEST + : HttpStatus.INTERNAL_SERVER_ERROR; const messageRaw = exception instanceof HttpException ? exception.getResponse() - : 'Internal server error'; + : prismaMessage ?? 'Internal server error'; const message = typeof messageRaw === 'string' diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts new file mode 100644 index 000000000..7fbfa7de0 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, SetMetadata } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { AppReleasesService, AppReleaseDto } from './app-releases.service'; +import { PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; + +@ApiTags('App Releases') +@Controller('app-releases') +export class AppReleasesController { + constructor(private service: AppReleasesService) {} + + @Get() + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'List all app releases (public)' }) + getAll() { + return this.service.getAll(); + } + + @Get('latest/:os') + @SetMetadata('isPublic', true) + @ApiOperation({ summary: 'Get latest release for a given OS (public)' }) + @ApiParam({ name: 'os', enum: ['android', 'ios'] }) + getLatest(@Param('os') os: string) { + return this.service.getLatest(os); + } + + @Post() + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Create an app release (admin)' }) + create(@Body() dto: AppReleaseDto) { + return this.service.create(dto); + } + + @Patch(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Update an app release (admin)' }) + update(@Param('id') id: string, @Body() dto: Partial) { + return this.service.update(id, dto); + } + + @Delete(':id') + @PassengerStaff(PASSENGER_PERMS.admin) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete an app release (admin)' }) + remove(@Param('id') id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts new file mode 100644 index 000000000..89e1f733a --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AppReleasesController } from './app-releases.controller'; +import { AppReleasesService } from './app-releases.service'; +import { PrismaModule } from '../../common/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [AppReleasesController], + providers: [AppReleasesService], +}) +export class AppReleasesModule {} diff --git a/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts new file mode 100644 index 000000000..16b221ecc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/app-releases/app-releases.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { IsBoolean, IsIn, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PrismaService } from '../../common/prisma.service'; + +export class AppReleaseDto { + @ApiProperty({ enum: ['android', 'ios'] }) + @IsIn(['android', 'ios']) + os: string; + + @ApiProperty({ example: '1.2.3' }) + @IsString() + version: string; + + @ApiProperty({ default: false }) + @IsBoolean() + forceUpdate: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storeLink?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +@Injectable() +export class AppReleasesService { + constructor(private prisma: PrismaService) {} + + private get db() { + return (this.prisma as any); + } + + getAll() { + return this.db.appRelease.findMany({ orderBy: [{ os: 'asc' }, { createdAt: 'desc' }] }); + } + + async getLatest(os: string) { + const release = await this.db.appRelease.findFirst({ + where: { os }, + orderBy: { createdAt: 'desc' }, + }); + if (!release) throw new NotFoundException(`No release found for ${os}`); + return release; + } + + async create(dto: AppReleaseDto) { + const existing = await this.db.appRelease.findUnique({ + where: { os_version: { os: dto.os, version: dto.version } }, + }); + if (existing) throw new ConflictException(`Release ${dto.os} ${dto.version} already exists`); + return this.db.appRelease.create({ data: dto }); + } + + async update(id: string, dto: Partial) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + return this.db.appRelease.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + const release = await this.db.appRelease.findUnique({ where: { id } }); + if (!release) throw new NotFoundException('App release not found'); + await this.db.appRelease.delete({ where: { id } }); + return { deleted: true, id }; + } +} diff --git a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts index 1202e4d45..3a1e94760 100644 --- a/apps/edr-passenger-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-passenger-api/src/modules/audit/audit.controller.ts @@ -30,8 +30,8 @@ export class AuditController { entityType: entityType || undefined, }; - const items = await this.auditService.getLogs(filters); - return { items }; + const result = await this.auditService.getLogs(filters); + return { items: result.data, total: result.total, limit: result.limit, offset: result.offset }; } @Get('logs/:id') diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index 4062acddc..170ff84a3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -133,6 +133,12 @@ export class CreateBookingDto { @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; + @ApiPropertyOptional({ description: 'Package ID — when set, fare is taken from the package price tier instead of the fare engine' }) + @IsOptional() @IsString() packageId?: string; + + @ApiPropertyOptional({ description: 'Package price tier ID — required when packageId is provided' }) + @IsOptional() @IsString() priceTierId?: string; + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) @IsOptional() @IsString() promoCode?: string; diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 2ca7d5ac4..1aba4ea97 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -202,9 +202,12 @@ export class BookingsService { async findAll(filters: BookingFilters = {}) { const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; - + + const onlyPackages = bookingType === 'PACKAGE'; + const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT'; + const where: any = {}; - + if (search) { const iamRows = await this.dataSource.query<{ id: string }[]>( `SELECT u.id FROM iam.users u @@ -229,10 +232,10 @@ export class BookingsService { { seats: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, ]; } - + if (status) where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; - if (bookingType) where.bookingType = bookingType; + if (bookingType && !onlyPackages) where.bookingType = bookingType; if (dateFrom || dateTo) { where.createdAt = { ...(dateFrom ? { gte: new Date(dateFrom) } : {}), @@ -240,17 +243,125 @@ export class BookingsService { }; } if (paymentStatus) { - const statusMap: Record = { - PAID: 'SUCCEEDED', - PENDING: 'REQUIRES_ACTION', - FAILED: 'FAILED', - REFUNDED: 'REFUNDED', - }; + const statusMap: Record = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' }; const mapped = statusMap[paymentStatus] ?? paymentStatus; where.paymentIntent = { is: { status: mapped } }; } - - const [items, total] = await Promise.all([ + + const pkgWhere: any = {}; + if (search) { + pkgWhere.OR = [ + { bookingRef: { contains: search, mode: 'insensitive' } }, + { contactEmail: { contains: search, mode: 'insensitive' } }, + { contactPhone: { contains: search, mode: 'insensitive' } }, + { passengers: { some: { passengerName: { contains: search, mode: 'insensitive' } } } }, + ]; + } + if (status) pkgWhere.status = status; + if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt; + if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } }; + + if (onlyPackages) { + // Package bookings live in two places: + // 1. PackageBooking table (dedicated package bookings) + // 2. Booking table with packageId != null (round-trip bookings linked to a package) + const bookingPkgWhere: any = { packageId: { not: null } }; + if (status) bookingPkgWhere.status = status; + if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt; + if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent; + if (search) bookingPkgWhere.OR = where.OR; + + const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where: pkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }), + this.prisma.packageBooking.count({ where: pkgWhere }), + this.prisma.booking.findMany({ + where: bookingPkgWhere, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + include: { + passenger: { select: { id: true, iamUserId: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true } }, + paymentIntent: true, + seats: { include: { seat: true } }, + }, + }), + this.prisma.booking.count({ where: bookingPkgWhere }), + ]); + + const iamUserIds = regPkgItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamRows = iamUserIds.length > 0 + ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( + `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, + [iamUserIds], + ) + : []; + const iamMap = new Map(iamRows.map(r => [r.id, r])); + + const mappedRegPkg = regPkgItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, bookingRef: booking.bookingRef, status: booking.status, + totalMinor: booking.totalMinor, currency: 'ETB', + displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, + bookingType: booking.bookingType, packageId: booking.packageId, isPackageBooking: true, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: booking.schedule ? { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + } : null, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, bookingRef: b.bookingRef, status: b.status, + totalMinor: b.totalMinor, currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, contactPhone: b.contactPhone, + bookingType: 'PACKAGE', packageId: b.packageId, isPackageBooking: true, + packageName: b.package?.name, packageCode: b.package?.code, + returnLegStatus: null, adultCount: b.passengerCount, childCount: 0, + createdAt: b.createdAt, passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, paymentIntent: b.paymentIntent, seatCount: b.passengerCount, + })); + + const total = pkgTotal + regPkgTotal; + const allItems = [...mappedPkg, ...mappedRegPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + + return { + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, + }; + } + + const [regularItems, regularTotal, pkgItems, pkgTotal] = await Promise.all([ this.prisma.booking.findMany({ where, skip, @@ -264,9 +375,22 @@ export class BookingsService { }, }), this.prisma.booking.count({ where }), + includePackageBookings + ? this.prisma.packageBooking.findMany({ + where: pkgWhere, + orderBy: { createdAt: 'desc' }, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + }) + : Promise.resolve([] as any[]), + includePackageBookings ? this.prisma.packageBooking.count({ where: pkgWhere }) : Promise.resolve(0), ]); - const iamUserIds = items.map(b => b.passenger?.iamUserId).filter(Boolean) as string[]; + const iamUserIds = regularItems.map((b: any) => b.passenger?.iamUserId).filter(Boolean) as string[]; const iamRows = iamUserIds.length > 0 ? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>( `SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`, @@ -275,59 +399,80 @@ export class BookingsService { : []; const iamMap = new Map(iamRows.map(r => [r.id, r])); + const mappedRegular = regularItems.map((booking: any) => { + const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; + const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); + const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.totalMinor, + currency: 'ETB', + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + contactEmail: booking.contactEmail, + contactPhone: booking.contactPhone, + bookingType: booking.bookingType, + packageId: booking.packageId ?? null, + isPackageBooking: !!booking.packageId, + returnLegStatus: (booking as any).returnLegStatus ?? null, + adultCount: booking.adultCount, + childCount: booking.childCount, + createdAt: booking.createdAt, + passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, + passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], + passengers: uniquePassengers, + schedule: { + train: booking.schedule.train, + originStation: booking.schedule.originStation, + destinationStation: booking.schedule.destinationStation, + departureAt: booking.schedule.departureAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }); + + const mappedPkg = pkgItems.map((b: any) => ({ + id: b.id, + bookingRef: b.bookingRef, + status: b.status, + totalMinor: b.totalMinor, + currency: b.currency || 'ETB', + displayCurrency: b.displayCurrency, + displayTotalMinor: b.displayTotalMinor, + contactEmail: b.contactEmail, + contactPhone: b.contactPhone, + bookingType: 'PACKAGE', + packageId: b.packageId, + isPackageBooking: true, + packageName: b.package?.name, + packageCode: b.package?.code, + returnLegStatus: null, + adultCount: b.passengerCount, + childCount: 0, + createdAt: b.createdAt, + passenger: null, + passengerNames: b.passengers?.map((p: any) => p.passengerName) ?? [], + passengers: b.passengers?.map((p: any) => ({ name: p.passengerName, category: 'ADULT' })) ?? [], + schedule: null, + paymentIntent: b.paymentIntent, + seatCount: b.passengerCount, + })); + + const total = regularTotal + pkgTotal; + const allItems = [...mappedRegular, ...mappedPkg] + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, pageSize); + return { - items: items.map(booking => { - const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; - // Build passenger list with categories - const passengerDetails = booking.seats.map((s: any) => ({ - name: s.passengerName, - category: s.passengerCategory // 'ADULT' or 'CHILD' - })); - // Get unique names with their categories - const uniquePassengers = Array.from( - new Map(passengerDetails.map(p => [p.name, p])).values() - ); - - return { - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.totalMinor, - currency: 'ETB', - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - contactEmail: booking.contactEmail, - contactPhone: booking.contactPhone, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - adultCount: booking.adultCount, - childCount: booking.childCount, - createdAt: booking.createdAt, - passenger: iam - ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } - : null, - passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], - passengers: uniquePassengers, // Include category info - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - }; - }), - meta: { - page, - pageSize, - total, - totalPages: Math.ceil(total / pageSize), - }, + items: allItems, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } - async create(dto: CreateBookingDto) { + async create(dto: CreateBookingDto) { if (dto.bookingType === 'ROUND_TRIP') return this.createRoundTripBooking(dto); if (dto.bookingType === 'TRANSIT') return this.createTransitBooking(dto); if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createRoundTripTransitBooking(dto); @@ -363,7 +508,9 @@ export class BookingsService { const passengersData = await this.processPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + const fareCalculation = dto.packageId && dto.priceTierId + ? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount) + : await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = fareCalculation.totalMinor; @@ -401,6 +548,7 @@ export class BookingsService { childCount, displayCurrency, displayTotalMinor, + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: passengersWithFares.map(p => ({ seat: { connect: { id: p.seatId } }, @@ -421,6 +569,12 @@ export class BookingsService { }); await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } this.eventEmitter.emit('booking.created', { booking }); return { ...booking, fareBreakdown: fareCalculation }; } @@ -468,23 +622,38 @@ export class BookingsService { const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); const { adultCount, childCount } = this.countPassengers(passengersData); - const [outboundFare, returnFare] = await Promise.all([ - this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), - this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) - ]); - - const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + // Package bookings use fixed tier price split equally across both legs + let outboundFare: Awaited>; + let returnFare: Awaited>; + let combinedBaseFareMinor: number; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); - if (promo?.active && promo.validUntil > new Date()) { - discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); - } - } + let loyaltyMinor = 0; + let totalMinor: number; - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + if (dto.packageId && dto.priceTierId) { + const pkgFare = await this.calculatePackageFare(dto.priceTierId, adultCount, childCount); + // Split evenly across both legs for per-seat fare recording + const halfMinor = Math.round(pkgFare.baseFareMinor / 2); + outboundFare = { ...pkgFare, baseFareMinor: halfMinor, totalBaseFareMinor: Math.round(pkgFare.totalBaseFareMinor / 2) }; + returnFare = { ...pkgFare, baseFareMinor: pkgFare.baseFareMinor - halfMinor, totalBaseFareMinor: pkgFare.totalBaseFareMinor - Math.round(pkgFare.totalBaseFareMinor / 2) }; + combinedBaseFareMinor = pkgFare.totalBaseFareMinor; + totalMinor = pkgFare.totalMinor; + } else { + [outboundFare, returnFare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) + ]); + combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); + } const taxesMinor = 0; - const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor); const displayCurrency = dto.displayCurrency || Currency.ETB; let displayTotalMinor = totalMinor; @@ -541,6 +710,7 @@ export class BookingsService { returnHoldId: dto.returnHoldId, returnSeatClassId: dto.returnSeatClassId, returnLegStatus: 'NEITHER_USED', + ...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}), seats: { create: [ ...passengersWithFares.map(p => ({ @@ -586,6 +756,13 @@ export class BookingsService { this.seatsService.confirmSeats(returnSeatIds) ]); + if (dto.packageId && dto.priceTierId) { + await this.prisma.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { bookedSeats: { increment: passengersData.length } }, + }); + } + this.eventEmitter.emit('booking.created', { booking }); return { @@ -1048,6 +1225,30 @@ export class BookingsService { return { adultCount, childCount }; } + private async calculatePackageFare( + priceTierId: string, + adultCount: number, + childCount: number, + ) { + const tier = await this.prisma.packagePriceTier.findUniqueOrThrow({ where: { id: priceTierId } }); + const passengerCount = adultCount + childCount; + const totalBaseFareMinor = tier.priceMinor * passengerCount; + return { + baseFareMinor: tier.priceMinor, + adultCount, + adultFareMinor: tier.priceMinor * adultCount, + childCount, + freeChildrenCount: 0, + paidChildrenCount: childCount, + childFareMinor: tier.priceMinor * childCount, + totalBaseFareMinor, + discountMinor: 0, + loyaltyRedemptionMinor: 0, + taxesFeesMinor: 0, + totalMinor: totalBaseFareMinor, + }; + } + private async calculateFare( scheduleId: string, seatClassId: string, @@ -1182,7 +1383,64 @@ export class BookingsService { paymentIntent: true, tickets: { take: 1 }, }, }); - if (!booking) throw new NotFoundException('Booking not found'); + + if (!booking) { + // Fall back to PackageBooking + const pkgBooking = await this.prisma.packageBooking.findUnique({ + where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId }, + include: { + package: { include: { outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } }, returnSchedule: { include: { originStation: true, destinationStation: true } } } }, + priceTier: true, + passengers: true, + paymentIntent: true, + }, + }); + if (!pkgBooking) throw new NotFoundException('Booking not found'); + return { + id: pkgBooking.id, + bookingRef: pkgBooking.bookingRef, + status: pkgBooking.status, + totalMinor: pkgBooking.totalMinor, + currency: pkgBooking.currency || 'ETB', + adultCount: pkgBooking.passengerCount, + childCount: 0, + displayCurrency: pkgBooking.displayCurrency, + displayTotalMinor: pkgBooking.displayTotalMinor ?? undefined, + bookingType: 'PACKAGE', + packageId: pkgBooking.packageId, + priceTierId: pkgBooking.priceTierId, + packageName: (pkgBooking as any).package?.name, + packageCode: (pkgBooking as any).package?.code, + tierLabel: (pkgBooking as any).priceTier?.label, + isPackageBooking: true, + returnLegStatus: null, + contactEmail: pkgBooking.contactEmail, + contactPhone: pkgBooking.contactPhone, + createdAt: pkgBooking.createdAt, + schedule: (pkgBooking as any).package?.outboundSchedule ? { + id: (pkgBooking as any).package.outboundSchedule.id, + trainNumber: (pkgBooking as any).package.outboundSchedule.train?.number, + trainName: (pkgBooking as any).package.outboundSchedule.train?.name, + origin: (pkgBooking as any).package.outboundSchedule.originStation, + destination: (pkgBooking as any).package.outboundSchedule.destinationStation, + departureAt: (pkgBooking as any).package.outboundSchedule.departureAt, + arrivalAt: (pkgBooking as any).package.outboundSchedule.arrivalAt, + } : null, + passengers: (pkgBooking as any).passengers?.map((p: any) => ({ + fullName: p.passengerName, + category: 'ADULT', + leg: 1, + fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount), + verifaydaVerified: false, + seat: null, + })), + payment: (pkgBooking as any).paymentIntent + ? { method: (pkgBooking as any).paymentIntent.method, status: (pkgBooking as any).paymentIntent.status } + : undefined, + ticket: undefined, + }; + } + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, totalMinor: booking.totalMinor, currency: 'ETB', diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..5b6b876d6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -81,8 +81,10 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId); - const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); + const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) + ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; @@ -306,10 +308,16 @@ export class GuestBookingService { throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); } - const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); - const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); - const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); - const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + const synth = (sched: any, stationId: string, seq: number) => { + const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; + return { stationId, sequence: seq, station }; + }; + const obStops = outboundSchedule.stopTimes.length > 0 ? outboundSchedule.stopTimes : [synth(outboundSchedule, outboundSchedule.originStationId, 0), synth(outboundSchedule, outboundSchedule.destinationStationId, 1)]; + const retStops = returnSchedule.stopTimes.length > 0 ? returnSchedule.stopTimes : [synth(returnSchedule, returnSchedule.originStationId, 0), synth(returnSchedule, returnSchedule.destinationStationId, 1)]; + const outboundOriginStop = obStops.find((s: any) => s.stationId === dto.originStationId) ?? obStops[0]; + const outboundDestStop = obStops.find((s: any) => s.stationId === dto.destinationStationId) ?? obStops[obStops.length - 1]; + const returnOriginStop = retStops.find((s: any) => s.stationId === dto.returnOriginStationId) ?? retStops[0]; + const returnDestStop = retStops.find((s: any) => s.stationId === dto.returnDestinationStationId) ?? retStops[retStops.length - 1]; if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index 592aadacd..f7f2c4558 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -130,7 +130,7 @@ export class FareEngineService { const adultCount = dto.adultCount ?? 1; const childCount = dto.childCount ?? 0; - const freeChildrenCount = Math.min(childCount, 1); + const freeChildrenCount = Math.min(childCount, adultCount); const paidChildrenCount = Math.max(0, childCount - 1); // Subtotal includes: (distance-based fare + premium + insurance) × passengers @@ -169,7 +169,7 @@ export class FareEngineService { `Total fare/pax: ${farePerPassengerMinor} ETB minor`, ``, `Adults: ${adultCount} × ${farePerPassengerMinor} = ${adultSubtotal} ETB minor`, - `Children: ${childCount} (${freeChildrenCount} free + ${paidChildrenCount} paid)`, + `Children: ${childCount} (${freeChildrenCount} free [1 per adult] + ${paidChildrenCount} paid)`, ` Free child: ${freeChildrenCount} × ${premiumPerPassenger + insurancePerPassenger} = ${freeChildSubtotal} ETB minor`, ` Paid child: ${paidChildrenCount} × ${farePerPassengerMinor} = ${paidChildSubtotal} ETB minor`, ``, diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts index c53d3a5fb..87007f16a 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { FraudService, FraudRuleConfig } from './fraud.service'; import { PassengerStaff } from '../../common/passenger-guards'; @@ -48,6 +48,31 @@ export class FraudController { return { data: rule, message: 'Rule updated successfully' }; } + /** + * Acknowledge a fraud alert + */ + @Patch('alerts/:id/acknowledge') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Acknowledge a fraud alert' }) + async acknowledgeAlert(@Param('id') id: string) { + const alert = await this.fraudService.acknowledgeAlert(id); + return { data: alert, message: 'Alert acknowledged' }; + } + + /** + * Block user via userId + */ + @Post('users/:userId/block') + @PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin]) + @ApiOperation({ summary: 'Block user by userId' }) + async blockUserById( + @Param('userId') userId: string, + @Body() body: { reason?: string; durationMinutes?: number }, + ) { + await this.fraudService.blockUserTemporarily(userId, body.durationMinutes ?? 60); + return { message: `User blocked for ${body.durationMinutes ?? 60} minutes` }; + } + /** * Block user temporarily */ diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts index a95078578..17b86705f 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { FraudService } from './fraud.service'; import { FraudController } from './fraud.controller'; @Module({ - imports: [HttpModule], + imports: [HttpModule, TypeOrmModule], providers: [FraudService], controllers: [FraudController], exports: [FraudService], diff --git a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts index a75db4449..2f988fd31 100644 --- a/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts +++ b/apps/edr-passenger-api/src/modules/fraud/fraud.service.ts @@ -164,6 +164,16 @@ export class FraudService { this.logger.log(`Passenger (iamUserId=${iamUserId}) unblocked`); } + /** + * Acknowledge a fraud alert + */ + async acknowledgeAlert(id: string) { + return this.prisma.fraudAlert.update({ + where: { id }, + data: { acknowledged: true, acknowledgedAt: new Date() }, + }); + } + /** * Get all fraud alerts */ diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts index 7095110e4..b4b1a63c6 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { LoyaltyService } from './loyalty.service'; import { JwtGuard } from '../../common/jwt.guard'; @@ -9,7 +9,9 @@ import { JwtGuard } from '../../common/jwt.guard'; @ApiBearerAuth('JWT-auth') export class LoyaltyController { constructor(private service: LoyaltyService) {} + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all loyalty accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } @Get(':passengerId') @ApiOperation({ summary: 'Get loyalty account with tier progress' }) getAccount(@Param('passengerId') id: string) { return this.service.getAccount(id); } @Get(':passengerId/rewards') @ApiOperation({ summary: 'Get available rewards' }) getRewards(@Param('passengerId') id: string) { return this.service.getRewards(id); } @Post(':passengerId/rewards/:rewardId/redeem') @ApiOperation({ summary: 'Redeem a loyalty reward' }) redeemReward(@Param('passengerId') pid: string, @Param('rewardId') rid: string) { return this.service.redeemReward(pid, rid); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete loyalty account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts index 4cc69b214..22b919f6f 100644 --- a/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts +++ b/apps/edr-passenger-api/src/modules/loyalty/loyalty.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class LoyaltyService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; tier?: string; page?: string; pageSize?: string } = {}) { + const { search, tier, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (tier) where.tier = tier; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.loyaltyAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { pointsBalance: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.loyaltyAccount.count({ where }), + ]); + return { + items: items.map(a => ({ + ...a, + passenger: a.passenger ? { + id: a.passenger.id, + fullName: (a.passenger as any).user?.fullName ?? null, + email: (a.passenger as any).user?.email ?? null, + phone: (a.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } async getAccount(passengerId: string) { const account = await this.prisma.loyaltyAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!account) throw new NotFoundException('Loyalty account not found'); @@ -40,4 +76,15 @@ export class LoyaltyService { await this.prisma.loyaltyReward.update({ where: { id: rewardId }, data: { available: false } }); return { redeemed: true, pointsUsed: reward.costPoints, balanceAfter: newBalance }; } + + async deleteAccount(id: string) { + const account = await this.prisma.loyaltyAccount.findUnique({ where: { id } }); + if (!account) throw new NotFoundException('Loyalty account not found'); + await this.prisma.$transaction([ + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyReward.deleteMany({ where: { accountId: id } }), + this.prisma.loyaltyAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index c88bdc15d..f814eb3e7 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -1,8 +1,8 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PackagesService } from './packages.service'; -import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto'; +import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto'; import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; @@ -63,6 +63,19 @@ export class PackagesController { return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20); } + @Get('bookings') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all package bookings (backoffice)' }) + listBookings( + @Query('packageId') packageId?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listBookings({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 }); + } + @Get('my-bookings') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @@ -78,6 +91,21 @@ export class PackagesController { return this.service.getBookingByRef(ref); } + @Get(':id/booking-context') + @IsPublic() + @ApiOperation({ summary: 'Get booking context for self-service package booking' }) + @ApiQuery({ name: 'tierId', required: true }) + @ApiQuery({ name: 'adultCount', required: true }) + @ApiQuery({ name: 'childCount', required: false }) + getBookingContext( + @Param('id') id: string, + @Query('tierId') tierId: string, + @Query('adultCount') adultCount: string, + @Query('childCount') childCount?: string, + ) { + return this.service.getBookingContext(id, tierId, parseInt(adultCount), childCount ? parseInt(childCount) : 0); + } + @Get(':id') @IsPublic() @ApiOperation({ summary: 'Get package details' }) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index b4dac126d..074d7454d 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID, IsPositive } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -93,6 +93,12 @@ export class BookPackagePassengerDto { @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; } +export class PackageBookingContextDto { + @ApiProperty() @IsUUID() tierId: string; + @ApiProperty({ example: 1 }) @IsInt() @IsPositive() adultCount: number; + @ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() @Min(0) childCount?: number; +} + export class BookPackageDto { @ApiProperty() @IsUUID() packageId: string; @ApiProperty() @IsUUID() priceTierId: string; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.module.ts b/apps/edr-passenger-api/src/modules/packages/packages.module.ts index f84a23781..32aec44fc 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.module.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.module.ts @@ -3,9 +3,10 @@ import { PrismaModule } from '../../common/prisma.module'; import { PackagesController } from './packages.controller'; import { PackagesService } from './packages.service'; import { CurrencyModule } from '../currency/currency.module'; +import { BookingsModule } from '../bookings/bookings.module'; @Module({ - imports: [PrismaModule, CurrencyModule], + imports: [PrismaModule, CurrencyModule, BookingsModule], controllers: [PackagesController], providers: [PackagesService], exports: [PackagesService], diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index f6c25611c..4a9f9382e 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; import { Currency } from '@prisma/client'; +import { BookingsService } from '../bookings/bookings.service'; +import { GuestBookingService } from '../bookings/guest-booking.service'; function generateRef(): string { return 'PKG-' + Array.from({ length: 6 }, () => @@ -15,8 +17,94 @@ export class PackagesService { constructor( private readonly prisma: PrismaService, private readonly currencyService: CurrencyService, + private readonly bookingsService: BookingsService, + private readonly guestBookingService: GuestBookingService, ) {} + async getBookingContext(packageId: string, tierId: string, adultCount: number, childCount = 0) { + const pkg = await this.prisma.travelPackage.findUnique({ + where: { id: packageId }, + include: { + priceTiers: true, + outboundSchedule: { + include: { + originStation: true, + destinationStation: true, + coachAssignments: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } }, + }, + }, + returnSchedule: { include: { originStation: true, destinationStation: true } }, + }, + }); + if (!pkg || pkg.status !== 'ACTIVE') throw new NotFoundException('Package not available'); + + const tier = pkg.priceTiers.find(t => t.id === tierId); + if (!tier) throw new NotFoundException('Price tier not found'); + + const passengerCount = adultCount + childCount; + if (passengerCount < 1) throw new BadRequestException('At least one passenger required'); + + const remaining = tier.availableSeats - tier.bookedSeats; + if (passengerCount > remaining) + throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); + + const totalMinor = tier.priceMinor * passengerCount; + + // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches + let seatClassId: string | null = null; + let coachTypeId: string | null = null; + for (const a of pkg.outboundSchedule.coachAssignments) { + const sc = a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + } + // Fallback: use the first coach assignment's coachTypeId if no match found + if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { + const first = pkg.outboundSchedule.coachAssignments[0]; + coachTypeId = first.coach.coachTypeId ?? first.coach.coachType?.id ?? null; + } + + return { + packageId: pkg.id, + packageName: pkg.name, + priceTierId: tier.id, + tierLabel: tier.label, + seatType: tier.seatType, + seatClassId, + coachTypeId, + adultCount, + childCount, + passengerCount, + pricePerPassengerMinor: tier.priceMinor, + totalMinor, + currency: tier.currency, + remainingSeats: remaining, + outboundSchedule: { + scheduleId: pkg.outboundScheduleId, + originStationId: pkg.originStationId, + destinationStationId: pkg.destinationStationId, + departureAt: pkg.outboundSchedule.departureAt, + arrivalAt: pkg.outboundSchedule.arrivalAt, + originStation: pkg.outboundSchedule.originStation, + destinationStation: pkg.outboundSchedule.destinationStation, + }, + returnSchedule: pkg.returnSchedule ? { + scheduleId: pkg.returnScheduleId, + originStationId: pkg.destinationStationId, + destinationStationId: pkg.originStationId, + departureAt: pkg.returnSchedule.departureAt, + arrivalAt: pkg.returnSchedule.arrivalAt, + originStation: pkg.returnSchedule.destinationStation, + destinationStation: pkg.returnSchedule.originStation, + } : null, + includedServices: pkg.includedServices, + busTransferIncluded: pkg.busTransferIncluded, + busTransferRoute: pkg.busTransferRoute, + }; + } + async createInquiry(dto: CreateInquiryDto) { return this.prisma.packageInquiry.create({ data: { @@ -74,7 +162,7 @@ export class PackagesService { returnSchedule: { include: { originStation: true, destinationStation: true } }, }, orderBy: { validFrom: 'asc' }, - }); + }).then(pkgs => pkgs.map(p => ({ ...p, journeyType: p.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }))); } async getById(id: string) { @@ -87,7 +175,7 @@ export class PackagesService { }, }); if (!pkg) throw new NotFoundException('Package not found'); - return pkg; + return { ...pkg, journeyType: pkg.returnScheduleId ? 'ROUND_TRIP' : 'ONE_WAY' }; } create(dto: CreatePackageDto) { @@ -296,6 +384,29 @@ export class PackagesService { return booking; } + async listBookings({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) { + const where: any = {}; + if (packageId) where.packageId = packageId; + if (status) where.status = status; + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + this.prisma.packageBooking.findMany({ + where, + include: { + package: { select: { id: true, name: true, code: true } }, + priceTier: { select: { id: true, label: true, seatType: true } }, + passengers: true, + paymentIntent: true, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.packageBooking.count({ where }), + ]); + return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }; + } + async listAll(page = 1, pageSize = 20) { const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index ea952d98c..d41fcbfed 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -433,39 +433,49 @@ export class PassengersService { } async deletePassenger(id: string) { - const passenger = await this.prisma.passenger.findUnique({ + // id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id + let passenger = await this.prisma.passenger.findUnique({ where: { id }, - include: { - user: true - } + include: { user: true }, }); - if (!passenger) throw new NotFoundException('Passenger not found'); + + if (!passenger) { + const profile = await this.prisma.travelerProfile.findUnique({ where: { id } }); + if (!profile?.passengerId) throw new NotFoundException('Passenger not found'); + passenger = await this.prisma.passenger.findUnique({ + where: { id: profile.passengerId }, + include: { user: true }, + }); + if (!passenger) throw new NotFoundException('Passenger not found'); + } + + const passengerId = passenger.id; // Check usage before allowing deletion - const usage = await this.checkPassengerUsage(id); + const usage = await this.checkPassengerUsage(passengerId); if (usage.isInUse && usage.constraints) { - const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`; + const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`; throw new DeleteOperationException('Passenger', passengerName, usage.constraints); } await this.prisma.$transaction([ - this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }), - this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }), - this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId: id } } }), - this.prisma.walletAccount.deleteMany({ where: { passengerId: id } }), - this.prisma.notification.deleteMany({ where: { passengerId: id } }), - this.prisma.travelerProfile.deleteMany({ where: { passengerId: id } }), - this.prisma.savedRoute.deleteMany({ where: { passengerId: id } }), - this.prisma.packageBooking.deleteMany({ where: { passengerId: id } }), - this.prisma.ticket.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId: id } } }), - this.prisma.booking.deleteMany({ where: { passengerId: id } }), - this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId: id } } }), - this.prisma.journey.deleteMany({ where: { passengerId: id } }), - this.prisma.passenger.delete({ where: { id } }), + this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId } } }), + this.prisma.loyaltyAccount.deleteMany({ where: { passengerId } }), + this.prisma.walletLedgerEntry.deleteMany({ where: { wallet: { passengerId } } }), + this.prisma.walletAccount.deleteMany({ where: { passengerId } }), + this.prisma.notification.deleteMany({ where: { passengerId } }), + this.prisma.travelerProfile.deleteMany({ where: { passengerId } }), + this.prisma.savedRoute.deleteMany({ where: { passengerId } }), + this.prisma.packageBooking.deleteMany({ where: { passengerId } }), + this.prisma.ticket.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.bookingSeat.deleteMany({ where: { booking: { passengerId } } }), + this.prisma.booking.deleteMany({ where: { passengerId } }), + this.prisma.journeySegment.deleteMany({ where: { journey: { passengerId } } }), + this.prisma.journey.deleteMany({ where: { passengerId } }), + this.prisma.passenger.delete({ where: { id: passengerId } }), ]); - return { deleted: true, passengerId: id }; + return { deleted: true, passengerId }; } async checkPassengerUsage(id: string) { diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 280ef2752..5be11434a 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, HttpStatus, Param, @@ -42,6 +43,14 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; export class PaymentsController { constructor(private service: PaymentsService) {} + @Delete(":id") + @PassengerStaff([PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ summary: "Delete a payment intent record (admin only)" }) + deletePayment(@Param("id") id: string) { + return this.service.deletePayment(id); + } + @Get("all") @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") 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 af496a5a2..b79079c39 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -55,6 +55,13 @@ export class PaymentsService { private currencyService: CurrencyService, ) {} + async deletePayment(id: string) { + const intent = await this.prisma.paymentIntent.findUnique({ where: { id } }); + if (!intent) throw new NotFoundException('Payment intent not found'); + await this.prisma.paymentIntent.delete({ where: { id } }); + return { deleted: true, id }; + } + async getAll(filters: { search?: string; status?: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 6bb9d1960..384592dc9 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Body, Controller, Post, Get, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -66,4 +66,29 @@ Nationality-Based: getFareQuote(@Body() dto: FareQuoteDto) { return this.service.getFareQuote(dto); } + + @Get('fare-breakdown') + @ApiOperation({ + summary: 'Per-passenger fare breakdown for booking review page', + description: `Calculates a line-item fare for each individual passenger based on their date of birth, nationality, and chosen seat class. + +- Age is derived from dateOfBirth at request time (ADULT ≥5 yrs, CHILD <5 yrs) +- First CHILD in the list travels free (pays only premium + insurance fees) +- Each passenger can have a different seat class and nationality +- Returns per-passenger lines plus subtotal, discount, and grand total + +**passengers** must be a URL-encoded JSON array, e.g.: +\`[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]\``, + }) + @ApiQuery({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', description: 'Origin station UUID' }) + @ApiQuery({ name: 'destinationStationId', description: 'Destination station UUID' }) + @ApiQuery({ name: 'passengers', description: 'URL-encoded JSON array of passengers: [{passengerName, dateOfBirth, seatClassId, nationality?}]' }) + @ApiQuery({ name: 'promoCode', required: false }) + @ApiQuery({ name: 'displayCurrency', required: false, enum: ['ETB', 'DJF', 'USD'] }) + @ApiResponse({ status: 200, description: 'Per-passenger fare lines with grand total' }) + @ApiResponse({ status: 404, description: 'Schedule not found' }) + getFareBreakdown(@Query() dto: FareBreakdownRequestDto) { + return this.service.getFareBreakdown(dto); + } } diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index 9eb035ef2..cfb1075ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -75,6 +75,43 @@ export class CoachTypeOptionClass { @ApiProperty({ example: 35000 }) baseFareMinor: number; } +export class FareBreakdownPassengerDto { + @ApiProperty({ example: 'Abebe Kebede', description: 'Passenger name (for display only)' }) + @IsString() passengerName: string; + + @ApiProperty({ example: '1985-03-15', description: 'Date of birth — determines ADULT (≥5 yrs) or CHILD (<5 yrs)' }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'SeatClass UUID for this passenger' }) + @IsString() seatClassId: string; + + @ApiPropertyOptional({ example: 'Ethiopian', description: 'Nationality — affects billing currency and seat class variant' }) + @IsOptional() @IsString() nationality?: string; +} + +export class FareBreakdownRequestDto { + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() scheduleId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID (must be a stop on the schedule)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ + example: '[{"passengerName":"Abebe","dateOfBirth":"1985-03-15","seatClassId":"uuid","nationality":"Ethiopian"}]', + description: 'URL-encoded JSON array of passengers. Each entry: { passengerName, dateOfBirth (YYYY-MM-DD), seatClassId, nationality? }', + }) + @IsString() passengers: string; + + @ApiPropertyOptional({ example: 'WEEKEND15' }) + @IsOptional() @IsString() promoCode?: string; + + @ApiPropertyOptional({ example: 'USD', enum: Currency }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; +} + export class CoachTypeOption { @ApiProperty({ example: 'coach-type-uuid' }) coachTypeId: string; @ApiProperty({ example: 'Economy' }) coachTypeName: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6f3bc5a67..621e5487d 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; -import { SearchTripsDto, FareQuoteDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto } from './search.dto'; import { CurrencyService } from '../currency/currency.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; import { SegmentsService } from '../segments/segments.service'; @@ -477,6 +477,124 @@ export class SearchService { }; } + async getFareBreakdown(dto: FareBreakdownRequestDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { routeId: true, originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation'); + + const now = new Date(); + const displayCurrency = dto.displayCurrency ?? Currency.ETB; + + let parsedPassengers: FareBreakdownPassengerDto[]; + try { + parsedPassengers = JSON.parse(dto.passengers as unknown as string); + } catch { + throw new NotFoundException('passengers must be a valid JSON array'); + } + + // Categorise passengers by age + const categorised = parsedPassengers.map(p => { + const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime(); + const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25); + return { ...p, category: (ageYears >= 5 ? 'ADULT' : 'CHILD') as 'ADULT' | 'CHILD', ageYears }; + }); + + const adultCount = categorised.filter(p => p.category === 'ADULT').length; + const childCount = categorised.filter(p => p.category === 'CHILD').length; + + // Ask the fare engine for the authoritative free-child count using the full group + // Use the first passenger's seatClassId as a representative — freeChildrenCount + // depends only on adultCount/childCount, not on seat class. + const groupFare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: categorised[0].seatClassId, + nationality: categorised[0].nationality, + scheduleId: dto.scheduleId, + adultCount, + childCount, + }); + const freeChildrenAllowed = groupFare.freeChildrenCount; + + // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup) + let freeChildrenUsed = 0; + const passengerLines = await Promise.all( + categorised.map(async (p) => { + const fare = await this.fareEngine.calculate({ + routeId: schedule.routeId!, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + seatClassId: p.seatClassId, + nationality: p.nationality, + scheduleId: dto.scheduleId, + adultCount: 1, + childCount: 0, + }); + + const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed; + if (isFree) freeChildrenUsed++; + + const fareMinor = isFree + ? fare.premiumPerPassenger + fare.insurancePerPassenger + : fare.farePerPassengerMinor; + const displayFareMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(fareMinor, Currency.ETB, displayCurrency) + : fareMinor; + + return { + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + category: p.category, + ageYears: Math.floor(p.ageYears), + seatClassId: fare.seatClassId, + seatClassName: fare.seatClassName, + nationality: p.nationality ?? null, + baseFareMinor: fare.baseFarePerPassengerMinor, + premiumMinor: fare.premiumPerPassenger, + insuranceFeeMinor: fare.insurancePerPassenger, + fareMinor, + isFree, + displayCurrency, + displayFareMinor, + }; + }), + ); + + let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0); + + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > now) { + discountMinor = promo.percentOff + ? Math.round(subtotalMinor * promo.percentOff / 100) + : (promo.amountOffMinor ?? 0); + } + } + + const totalMinor = subtotalMinor - discountMinor; + const displayTotalMinor = displayCurrency !== Currency.ETB + ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) + : totalMinor; + + return { + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: passengerLines, + subtotalMinor, + discountMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor, + }; + } + private async calculateFaresForSegment( schedule: ScheduleWithIncludes, originStationId: string, diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 79151bdc9..63f5e1f29 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; @Injectable() export class SeatClassesService { @@ -45,8 +46,24 @@ export class SeatClassesService { } async deleteSeatClass(id: string) { - const sc = await this.prisma.seatClass.findUnique({ where: { id } }); + const sc = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + _count: { select: { fareRules: true, routeFareRules: true, segmentFares: true } }, + }, + }); if (!sc) throw new NotFoundException('SeatClass not found'); + + const totalFareRules = + (sc as any)._count.fareRules + + (sc as any)._count.routeFareRules + + (sc as any)._count.segmentFares; + + if (totalFareRules > 0) + throw new DeleteOperationException('Seat Class', sc.name, [ + { entityName: 'fare rule', count: totalFareRules, action: 'delete' }, + ]); + return this.prisma.seatClass.delete({ where: { id } }); } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts index a8d5d724c..4a9783101 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts @@ -28,6 +28,25 @@ import { IamGuard } from "../../common/iam-adapter"; export class SeatsController { constructor(private service: SeatsService) {} + // ── Coach Availability ──────────────────────────────────────────────────── + @Get('coaches/:scheduleId') + @SetMetadata('isPublic', true) + @ApiOperation({ + summary: 'List coaches with remaining seat counts for a schedule', + description: 'Returns each coach assigned to the schedule with total, available, held, and booked seat counts. Optionally scoped to a specific origin→destination leg.', + }) + @ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) + @ApiQuery({ name: 'originStationId', required: false, description: 'Scope availability to this origin station' }) + @ApiQuery({ name: 'destinationStationId', required: false, description: 'Scope availability to this destination station' }) + @ApiResponse({ status: 200, description: 'Coaches with seat availability counts' }) + getCoachesWithAvailability( + @Param('scheduleId') scheduleId: string, + @Query('originStationId') originStationId?: string, + @Query('destinationStationId') destinationStationId?: string, + ) { + return this.service.getCoachesWithAvailability(scheduleId, originStationId, destinationStationId); + } + // ── Seat Map ────────────────────────────────────────────────────────────── @Get("seatmap/:scheduleId") @SetMetadata('isPublic', true) diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 1562a600b..5c237e6ba 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -367,7 +367,24 @@ export class SeatsService { where: { scheduleId: dto.scheduleId }, select: { stationId: true, sequence: true }, }); - const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence; + + // When no stop times exist, fall back to the schedule's own origin/destination + // with synthetic sequences so the hold can still be created. + let effectiveStopTimes = stopTimes; + if (stopTimes.length === 0) { + const sched = await tx.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (sched) { + effectiveStopTimes = [ + { stationId: sched.originStationId, sequence: 0 }, + { stationId: sched.destinationStationId, sequence: 1 }, + ]; + } + } + + const seqOf = (stationId: string) => effectiveStopTimes.find(s => s.stationId === stationId)?.sequence; const reqFrom = seqOf(dto.originStationId); const reqTo = seqOf(dto.destinationStationId); @@ -604,6 +621,56 @@ export class SeatsService { await this.prisma.journey.deleteMany({ where: { bookingId } as any }); } + async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) { + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: scheduleId }, + select: { originStationId: true, destinationStationId: true }, + }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const assignments = await this.prisma.coachAssignment.findMany({ + where: { scheduleId }, + include: { + coach: { + include: { + seats: { select: { id: true, status: true, seatNumber: true } }, + coachType: { include: { seatClasses: { select: { name: true } } } }, + }, + }, + }, + orderBy: { positionNumber: 'asc' }, + }); + + const allSeatIds = assignments.flatMap(a => a.coach.seats.map(s => s.id)); + const effectiveStatuses = await this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + ); + + return assignments.map(a => { + const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); + const totalSeats = seats.length; + const unavailable = seats.filter(s => { + const status = effectiveStatuses.get(s.id) ?? s.status; + return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED'; + }).length; + + return { + coachId: a.coach.id, + coachNumber: a.coach.number, + positionNumber: a.positionNumber, + coachTypeName: a.coach.coachType?.name ?? '', + seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], + totalSeats, + availableSeats: totalSeats - unavailable, + heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length, + bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length, + }; + }); + } + async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise { const seats = await this.prisma.seat.findMany({ where: { diff --git a/apps/edr-passenger-api/src/modules/stations/stations.service.ts b/apps/edr-passenger-api/src/modules/stations/stations.service.ts index 9e9fc824d..ec2480b21 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.service.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.service.ts @@ -3,6 +3,7 @@ import { REQUEST } from '@nestjs/core'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { CreateStationDto } from './stations.dto'; +import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; interface StationFilters { search?: string; @@ -96,7 +97,35 @@ export class StationsService { } async remove(id: string) { - const station = await this.findOne(id); + const station = await this.prisma.station.findUnique({ + where: { id }, + include: { + _count: { select: { stopTimes: true } }, + originSchedules: { take: 1, select: { id: true } }, + destinationSchedules: { take: 1, select: { id: true } }, + }, + }); + if (!station) throw new NotFoundException('Station not found'); + + const [routeStopCount, originCount, destCount, stopTimeCount] = await Promise.all([ + this.prisma.routeStop.count({ where: { stationId: id } }), + this.prisma.trainSchedule.count({ where: { originStationId: id } }), + this.prisma.trainSchedule.count({ where: { destinationStationId: id } }), + (station as any)._count.stopTimes as number, + ]); + + const constraints = []; + if (routeStopCount > 0) + constraints.push({ entityName: 'route', count: routeStopCount, action: 'delete' as const }); + const scheduleCount = originCount + destCount; + if (scheduleCount > 0) + constraints.push({ entityName: 'schedule', count: scheduleCount, action: 'delete' as const }); + if (stopTimeCount > 0) + constraints.push({ entityName: 'stop time', count: stopTimeCount, action: 'delete' as const }); + + if (constraints.length > 0) + throw new DeleteOperationException('Station', `${station.name} (${station.code})`, constraints); + const deleted = await this.prisma.station.delete({ where: { id } }); await this.auditService.log({ diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts index 1ecb2edea..8b0c5ed2e 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Delete, UseGuards, SetMetadata, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { WalletService } from './wallet.service'; @@ -11,6 +11,8 @@ import { JwtGuard } from '../../common/jwt.guard'; @Throttle({ strict: { limit: 20, ttl: 60_000 } }) export class WalletController { constructor(private service: WalletService) {} - @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } - @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Get('accounts') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'List all wallet accounts' }) getAccounts(@Query() q: any) { return this.service.getAccounts(q); } + @Get(':passengerId') @ApiOperation({ summary: 'Get wallet balance and ledger' }) getWallet(@Param('passengerId') id: string) { return this.service.getWallet(id); } + @Post(':passengerId/topup') @ApiOperation({ summary: 'Top up wallet' }) topUp(@Param('passengerId') id: string, @Body('amountMinor') amount: number) { return this.service.topUp(id, amount); } + @Delete('accounts/:id') @SetMetadata('isPublic', true) @ApiOperation({ summary: 'Delete wallet account' }) deleteAccount(@Param('id') id: string) { return this.service.deleteAccount(id); } } diff --git a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts index a83d97e0b..ac092ee41 100644 --- a/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts +++ b/apps/edr-passenger-api/src/modules/wallet/wallet.service.ts @@ -5,6 +5,42 @@ import { PrismaService } from '../../common/prisma.service'; export class WalletService { constructor(private prisma: PrismaService) {} + async getAccounts(params: { search?: string; page?: string; pageSize?: string } = {}) { + const { search, page = '1', pageSize = '20' } = params; + const skip = (parseInt(page) - 1) * parseInt(pageSize); + const where: any = {}; + if (search) { + where.passenger = { + OR: [ + { user: { fullName: { contains: search, mode: 'insensitive' } } }, + { user: { email: { contains: search, mode: 'insensitive' } } }, + ], + }; + } + const [items, total] = await Promise.all([ + this.prisma.walletAccount.findMany({ + where, + skip, + take: parseInt(pageSize), + orderBy: { balanceMinor: 'desc' }, + include: { passenger: { include: { user: true } } }, + }), + this.prisma.walletAccount.count({ where }), + ]); + return { + items: items.map(w => ({ + ...w, + passenger: w.passenger ? { + id: w.passenger.id, + fullName: (w.passenger as any).user?.fullName ?? null, + email: (w.passenger as any).user?.email ?? null, + phone: (w.passenger as any).user?.phone ?? null, + } : null, + })), + meta: { page: parseInt(page), pageSize: parseInt(pageSize), total, totalPages: Math.ceil(total / parseInt(pageSize)) }, + }; + } + async getWallet(passengerId: string) { const wallet = await this.prisma.walletAccount.findUnique({ where: { passengerId }, include: { ledger: { orderBy: { createdAt: 'desc' }, take: 20 } } }); if (!wallet) throw new NotFoundException('Wallet not found'); @@ -18,4 +54,14 @@ export class WalletService { await this.prisma.walletAccount.update({ where: { passengerId }, data: { balanceMinor: newBalance } }); return this.prisma.walletLedgerEntry.create({ data: { walletId: wallet.id, type: 'CREDIT', amountMinor, balanceAfterMinor: newBalance, description } }); } + + async deleteAccount(id: string) { + const wallet = await this.prisma.walletAccount.findUnique({ where: { id } }); + if (!wallet) throw new NotFoundException('Wallet account not found'); + await this.prisma.$transaction([ + this.prisma.walletLedgerEntry.deleteMany({ where: { walletId: id } }), + this.prisma.walletAccount.delete({ where: { id } }), + ]); + return { deleted: true, accountId: id }; + } } diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx new file mode 100644 index 000000000..86d53715f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function Layout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx new file mode 100644 index 000000000..2e036f31e --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/app-releases/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Pencil, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { appReleasesApi } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; + +const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' }; + +export default function AppReleasesPage() { + const queryClient = useQueryClient(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState({ ...EMPTY_FORM }); + const [formError, setFormError] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['app-releases'], + queryFn: () => appReleasesApi.getAll(), + }); + + const flash = (msg: string) => { setSuccessMessage(msg); setTimeout(() => setSuccessMessage(''), 3000); }; + + const saveMutation = useMutation({ + mutationFn: (payload: any) => + editing ? appReleasesApi.update(editing.id, payload) : appReleasesApi.create(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setFormOpen(false); + setEditing(null); + setForm({ ...EMPTY_FORM }); + setFormError(''); + flash(editing ? 'Release updated.' : 'Release created.'); + }, + onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save.'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => appReleasesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['app-releases'] }); + setDeleteTarget(null); + setDeleteError(null); + flash('Release deleted.'); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete.'), + }); + + const openCreate = () => { setEditing(null); setForm({ ...EMPTY_FORM }); setFormError(''); setFormOpen(true); }; + const openEdit = (r: any) => { + setEditing(r); + setForm({ os: r.os, version: r.version, forceUpdate: r.forceUpdate, storeLink: r.storeLink || '', notes: r.notes || '' }); + setFormError(''); + setFormOpen(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.version.trim()) { setFormError('Version is required.'); return; } + saveMutation.mutate({ ...form, version: form.version.trim(), storeLink: form.storeLink || undefined, notes: form.notes || undefined }); + }; + + const releases: any[] = Array.isArray(data) ? data : []; + + const columns = [ + { + key: 'os', label: 'OS', + render: (r: any) => ( + + {r.os === 'ios' ? '🍎 iOS' : '🤖 Android'} + + ), + }, + { key: 'version', label: 'Version', render: (r: any) => {r.version} }, + { + key: 'forceUpdate', label: 'Force Update', + render: (r: any) => {r.forceUpdate ? 'Yes' : 'No'}, + }, + { + key: 'storeLink', label: 'Store Link', + render: (r: any) => r.storeLink + ? {r.storeLink} + : , + }, + { key: 'notes', label: 'Notes', render: (r: any) => {r.notes || '—'} }, + { key: 'createdAt', label: 'Created', render: (r: any) => {formatDateTime(r.createdAt)} }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Pencil }, + { label: 'Delete', onClick: (r: any) => { setDeleteError(null); setDeleteTarget(r); }, variant: 'danger' as const, icon: Trash2 }, + ]; + + return ( +
+
+
+

App Releases

+

Manage mobile app version release control

+
+ New Release +
+ + {successMessage && ( +
✓ {successMessage}
+ )} + +
+ +
+ + {/* Create / Edit Modal */} + setFormOpen(false)} title={editing ? 'Edit Release' : 'New Release'} size="md"> +
+
+
+ + +
+
+ + setForm({ ...form, version: e.target.value })} /> +
+
+ +
+ +
+ {(['true', 'false'] as const).map((val) => ( + + ))} +
+
+ +
+ + setForm({ ...form, storeLink: e.target.value })} /> +
+ +
+ +