Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
ghost2023
2026-07-01 14:40:58 +03:00
49 changed files with 3016 additions and 981 deletions

View File

@@ -50,7 +50,7 @@ jobs:
SERVICES=()
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"

View File

@@ -32,7 +32,8 @@
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
},
"dependencies": {
"@edr/api-common": "workspace:*",
@@ -84,13 +85,15 @@
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"@types/supertest": "^6.0.2",
"@types/vorpal": "^1.12.8",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"vorpal": "^1.12.0"
},
"jest": {
"moduleFileExtensions": [

View File

@@ -0,0 +1,10 @@
import type Vorpal from "vorpal";
import type { CommandContext } from "./types";
import { registerSeedTestContracts } from "./seed-test-contracts.cmd";
import { registerSeedTestSchedules } from "./seed-test-schedules.cmd";
export function registerCommands(vorpal: Vorpal, ctx: CommandContext): void {
registerSeedTestContracts(vorpal, ctx);
registerSeedTestSchedules(vorpal, ctx);
}

View File

@@ -0,0 +1,358 @@
import type Vorpal from "vorpal";
import { DataSource } from "typeorm";
import type { CommandContext } from "./types";
import { Company, CompanyType, CompanyKind, CompanyStatus, CompanyNationality } from "../../modules/companies/entities/company.entity";
import { CompanyProfile, ProfileStatus, ProfileType } from "../../modules/companies/entities/company-profile.entity";
import { ExternalProfile } from "../../modules/companies/entities/external-profile.entity";
import { Yard } from "../../modules/rule-engine/entities/yard.entity";
import { ServiceType } from "../../modules/rule-engine/entities/service-type.entity";
import { CargoType } from "../../modules/rule-engine/entities/cargo-type.entity";
import { Rate } from "../../modules/rule-engine/entities/rate.entity";
import { Contract } from "../../modules/contracts/entities/contract.entity";
import { ContractRoute } from "../../modules/contracts/entities/contract-route.entity";
import { ContractCargoScope } from "../../modules/contracts/entities/contract-cargo-scope.entity";
import { ContractRateSnapshot } from "../../modules/contracts/entities/contract-rate-snapshot.entity";
const COMPANY_IDS = [
"a0000001-0000-4000-8000-000000000001",
"a0000001-0000-4000-8000-000000000002",
"a0000001-0000-4000-8000-000000000003",
"a0000001-0000-4000-8000-000000000004",
];
const PROFILE_IDS = [
"b0000001-0000-4000-8000-000000000001",
"b0000001-0000-4000-8000-000000000002",
"b0000001-0000-4000-8000-000000000003",
"b0000001-0000-4000-8000-000000000004",
"b0000001-0000-4000-8000-000000000005",
"b0000001-0000-4000-8000-000000000006",
"b0000001-0000-4000-8000-000000000007",
"b0000001-0000-4000-8000-000000000008",
];
const EXTERNAL_PROFILE_IDS = [
"c0000001-0000-4000-8000-000000000001",
"c0000001-0000-4000-8000-000000000002",
"c0000001-0000-4000-8000-000000000003",
"c0000001-0000-4000-8000-000000000004",
];
function generateRef(index: number): string {
return `TST-CTR-${String(index).padStart(5, "0")}`;
}
export function registerSeedTestContracts(
vorpal: Vorpal,
ctx: CommandContext,
): void {
vorpal
.command("seed:test-contracts", "Generate test contracts with companies and all deps")
.option("-n, --count <n>", "Number of contracts to create (default: 4)")
.option("--status <statuses>", "Comma-separated contract statuses (default: DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE)")
.option("--freight <types>", "Freight types: CONTAINER,BULK (default: both)")
.option("--direction <dirs>", "Trade directions: IMPORT,EXPORT (default: both)")
.option("--company <name>", "Only create contracts for company matching name/TIN")
.action(async function (this: any, args: any) {
const { app } = ctx;
const ds = app.get(DataSource);
const count = Math.max(1, Math.min(20, parseInt(args.options?.count ?? "4", 10)));
const statusList = (args.options?.status ?? "DRAFT,SUBMITTED,APPROVED,CONTRACT_ACTIVE")
.split(",").map((s: string) => s.trim()).filter(Boolean);
const freightList = (args.options?.freight ?? "CONTAINER,BULK")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "CONTAINER" || s === "BULK");
const directionList = (args.options?.direction ?? "IMPORT,EXPORT")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "IMPORT" || s === "EXPORT");
const companyFilter = args.options?.company as string | undefined;
if (freightList.length === 0 || directionList.length === 0) {
this.log("error: at least one freight type and trade direction required");
return;
}
this.log(`Seeding ${count} contracts (statuses=${statusList.join(",")}, freight=${freightList.join(",")}, dir=${directionList.join(",")})...`);
const yards = await ds.getRepository(Yard).find({ where: { isActive: true } });
const yardByCode = new Map(yards.map((y) => [y.code, y]));
const djibouti = yardByCode.get("DJIBOUTI");
const addis = yardByCode.get("ADDIS_ABABA");
if (!djibouti || !addis) {
this.log("error: need at least DJIBOUTI and ADDIS_ABABA yards seeded");
return;
}
const serviceTypes = await ds
.getRepository(ServiceType)
.find({ where: { isActive: true } });
const stByCode = new Map(serviceTypes.map((st) => [st.code, st]));
const railContainer = stByCode.get("RAIL_CONTAINER");
const railBulk = stByCode.get("RAIL_BULK");
if (!railContainer && !railBulk) {
this.log("error: need at least RAIL_CONTAINER or RAIL_BULK service type seeded");
return;
}
const cargoTypes = await ds
.getRepository(CargoType)
.find({ where: { isActive: true } });
const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c]));
const grain = cargoByCode.get("GRAIN");
const sugar = cargoByCode.get("SUGAR");
const fertilizer = cargoByCode.get("FERTILIZER");
const rates = await ds.getRepository(Rate).find({ where: { status: "LIVE" } });
const companyRepo = ds.getRepository(Company);
let companies = await companyRepo.find({});
if (companyFilter) {
companies = companies.filter(
(c) =>
c.name.toLowerCase().includes(companyFilter.toLowerCase()) ||
c.tin.includes(companyFilter),
);
}
if (companies.length === 0) {
this.log("No existing companies found — seeding test companies...");
companies = await seedTestCompanies(ds, (msg) => this.log(msg));
} else {
this.log(`Using ${companies.length} existing companies from DB`);
}
const contractRepo = ds.getRepository(Contract);
let contractCount = 0;
for (let i = 0; i < count; i++) {
const statusIdx = i % statusList.length;
const ftIdx = i % freightList.length;
const dirIdx = i % directionList.length;
const companyIdx = i % companies.length;
const status = statusList[statusIdx];
const freightType = freightList[ftIdx];
const direction = directionList[dirIdx];
const company = companies[companyIdx];
const profile = await ds.getRepository(CompanyProfile).findOne({
where: {
companyId: company.id,
type: direction === "IMPORT" ? ProfileType.importer : ProfileType.exporter,
},
});
if (!profile) continue;
const ref = generateRef(i + 1);
const exists = await contractRepo.findOne({ where: { reference: ref } });
if (exists) {
this.log(` Contract ${ref} already exists — skipping`);
contractCount++;
continue;
}
const serviceTypeId =
freightType === "BULK" && railBulk
? railBulk.id
: railContainer
? railContainer.id
: serviceTypes[0].id;
const originId = direction === "IMPORT" ? djibouti.id : addis.id;
const destId = direction === "IMPORT" ? addis.id : djibouti.id;
const contract = contractRepo.create({
reference: ref,
companyId: company.id,
companyProfileId: profile.id,
contractKind: "ONE_TIME" as const,
tradeDirection: direction,
freightType,
serviceTypeId,
paymentCurrency: "USD",
customsClearingEnabled: false,
equipmentReturn: "without_return",
status,
versionNumber: 1,
});
const saved = await contractRepo.save(contract);
await ds.getRepository(ContractRoute).save(
ds.getRepository(ContractRoute).create({
contractId: saved.id,
originYardId: originId,
destinationYardId: destId,
sortOrder: 1,
}),
);
if (freightType === "CONTAINER") {
for (const size of ["20FT", "40FT"] as const) {
await ds.getRepository(ContractCargoScope).save(
ds.getRepository(ContractCargoScope).create({
contractId: saved.id,
containerSize: size,
}),
);
}
} else {
const bulkCargo = grain || sugar || fertilizer;
if (bulkCargo) {
await ds.getRepository(ContractCargoScope).save(
ds.getRepository(ContractCargoScope).create({
contractId: saved.id,
cargoTypeId: bulkCargo.id,
quantityCap: 10000,
}),
);
}
}
const matchingRates = rates.filter((r) => {
if (r.appliesTo === "CONTAINER" && freightType !== "CONTAINER") return false;
if (r.appliesTo === "BULK" && freightType !== "BULK") return false;
if (r.tradeDirection && r.tradeDirection !== direction) return false;
return r.status === "LIVE" && r.trigger === "ALWAYS";
});
const seen = new Set<string>();
for (const rate of matchingRates.slice(0, 3)) {
const sig = `${rate.rateType}|${rate.currency}|${rate.rateValue}`;
if (seen.has(sig)) continue;
seen.add(sig);
await ds.getRepository(ContractRateSnapshot).save(
ds.getRepository(ContractRateSnapshot).create({
contractId: saved.id,
rateId: rate.id,
rateCode: rate.rateType,
unitPrice: Number(rate.rateValue),
unitOfMeasure: rate.rateUnit,
currency: rate.currency ?? "USD",
containerSize: freightType === "CONTAINER" ? "20FT" : null,
isSurcharge: rate.trigger !== "ALWAYS",
conditionalOn: rate.trigger !== "ALWAYS" ? rate.trigger : null,
}),
);
}
contractCount++;
this.log(` Created ${status} ${freightType} ${direction} contract: ${ref} (${company.name})`);
}
this.log(`Done — ${contractCount} contracts ensured`);
});
}
interface CompanySeed {
id: string;
name: string;
tin: string;
profiles: Array<{ id: string; type: ProfileType; reference: string }>;
externalProfile: { id: string; userId: string; firstName: string; lastName: string };
}
const TEST_COMPANIES: CompanySeed[] = [
{
id: COMPANY_IDS[0], name: "Test Importer Co.", tin: "TST000001",
profiles: [
{ id: PROFILE_IDS[0], type: ProfileType.importer, reference: "TST-IM-001" },
{ id: PROFILE_IDS[1], type: ProfileType.exporter, reference: "TST-EX-001" },
],
externalProfile: { id: EXTERNAL_PROFILE_IDS[0], userId: "00000000-0000-0000-0000-000000000001", firstName: "Abebe", lastName: "Kebede" },
},
{
id: COMPANY_IDS[1], name: "Test Exporter Ltd.", tin: "TST000002",
profiles: [
{ id: PROFILE_IDS[2], type: ProfileType.importer, reference: "TST-IM-002" },
{ id: PROFILE_IDS[3], type: ProfileType.exporter, reference: "TST-EX-002" },
],
externalProfile: { id: EXTERNAL_PROFILE_IDS[1], userId: "00000000-0000-0000-0000-000000000002", firstName: "Bekele", lastName: "Alemu" },
},
{
id: COMPANY_IDS[2], name: "Bulk Commodities PLC", tin: "TST000003",
profiles: [
{ id: PROFILE_IDS[4], type: ProfileType.importer, reference: "TST-IM-003" },
{ id: PROFILE_IDS[5], type: ProfileType.exporter, reference: "TST-EX-003" },
],
externalProfile: { id: EXTERNAL_PROFILE_IDS[2], userId: "00000000-0000-0000-0000-000000000003", firstName: "Chala", lastName: "Tesfaye" },
},
{
id: COMPANY_IDS[3], name: "Hazardous Logistics Inc.", tin: "TST000004",
profiles: [
{ id: PROFILE_IDS[6], type: ProfileType.importer, reference: "TST-IM-004" },
{ id: PROFILE_IDS[7], type: ProfileType.exporter, reference: "TST-EX-004" },
],
externalProfile: { id: EXTERNAL_PROFILE_IDS[3], userId: "00000000-0000-0000-0000-000000000004", firstName: "Desta", lastName: "Hailu" },
},
];
async function seedTestCompanies(ds: DataSource, log: (msg: string) => void): Promise<Company[]> {
const companyRepo = ds.getRepository(Company);
const profileRepo = ds.getRepository(CompanyProfile);
const extProfileRepo = ds.getRepository(ExternalProfile);
const result: Company[] = [];
for (const seed of TEST_COMPANIES) {
let company = await companyRepo.findOne({ where: { id: seed.id } });
if (!company) {
company = await companyRepo.save(
companyRepo.create({
id: seed.id,
name: seed.name,
type: CompanyType.Customer,
kind: CompanyKind.Commercial,
status: CompanyStatus.Active,
tin: seed.tin,
country: "Ethiopia",
nationality: CompanyNationality.Ethiopian,
email: `info@${seed.name.toLowerCase().replace(/\s+/g, "")}.com`,
phone: "+251911000001",
}),
);
log(` Created company: ${seed.name}`);
} else {
log(` Company already exists: ${seed.name}`);
}
for (const p of seed.profiles) {
const existing = await profileRepo.findOne({ where: { id: p.id } });
if (!existing) {
await profileRepo.save(
profileRepo.create({
id: p.id,
companyId: seed.id,
type: p.type,
reference: p.reference,
status: ProfileStatus.Active,
}),
);
log(` Created ${p.type} profile: ${p.reference}`);
}
}
const ext = seed.externalProfile;
const existingExt = await extProfileRepo.findOne({ where: { id: ext.id } });
if (!existingExt) {
await extProfileRepo.save(
extProfileRepo.create({
id: ext.id,
userId: ext.userId,
companyId: seed.id,
firstName: ext.firstName,
lastName: ext.lastName,
isPrimaryContact: true,
onboardingCompleted: true,
}),
);
log(` Created external profile: ${ext.firstName} ${ext.lastName}`);
}
result.push(company);
}
return result;
}

View File

@@ -0,0 +1,298 @@
import type Vorpal from "vorpal";
import { DataSource } from "typeorm";
import { WagonStatus } from "@edr/types";
import type { CommandContext } from "./types";
import { Yard } from "../../modules/rule-engine/entities/yard.entity";
import { Route } from "../../modules/routes/entities/route.entity";
import { RouteMilestone } from "../../modules/routes/entities/route-milestone.entity";
import { Locomotive } from "../../modules/locomotives/entities/locomotive.entity";
import { Wagon } from "../../modules/wagons/entities/wagon.entity";
import { WagonType } from "../../modules/wagon-types/entities/wagon-type.entity";
import { TrainSet } from "../../modules/train-sets/entities/train-set.entity";
import { TrainSetLocomotive } from "../../modules/train-sets/entities/train-set-locomotive.entity";
import { TrainSetWagon } from "../../modules/train-sets/entities/train-set-wagon.entity";
import { TrainSchedule } from "../../modules/train-schedules/entities/train-schedule.entity";
import { TrainScheduleBooking } from "../../modules/train-schedules/entities/train-schedule-booking.entity";
import { Booking } from "../../modules/bookings/entities/booking.entity";
export function registerSeedTestSchedules(
vorpal: Vorpal,
ctx: CommandContext,
): void {
vorpal
.command("seed:test-schedules", "Seed train schedules with routes, wagons, and all deps for booking")
.option("-n, --count <n>", "Number of schedules to create (default: 3)")
.option("--direction <dirs>", "IMPORT,EXPORT (default: both)")
.option("--status <statuses>", "DRAFT,SCHEDULED,DISPATCHED (default: SCHEDULED)")
.option("--days-ahead <n>", "Days from now for departure (default: 3)")
.action(async function (this: any, args: any) {
const { app } = ctx;
const ds = app.get(DataSource);
const count = Math.max(1, Math.min(10, parseInt(args.options?.count ?? "3", 10)));
const directionList = (args.options?.direction ?? "IMPORT,EXPORT")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "IMPORT" || s === "EXPORT");
const statusList = (args.options?.status ?? "SCHEDULED")
.split(",").map((s: string) => s.toUpperCase().trim())
.filter((s: string) => s === "DRAFT" || s === "SCHEDULED" || s === "DISPATCHED");
const daysAhead = Math.max(0, parseInt(args.options?.daysAhead ?? "3", 10));
if (directionList.length === 0 || statusList.length === 0) {
this.log("error: at least one direction and status required");
return;
}
this.log(`Seeding ${count} schedules (direction=${directionList.join(",")}, status=${statusList.join(",")}, days_ahead=${daysAhead})...`);
const yards = await ds.getRepository(Yard).find({ where: { isActive: true } });
const yardByCode = new Map(yards.map((y) => [y.code.toUpperCase(), y]));
const djibouti = yardByCode.get("DJIBOUTI") ?? yards.find((y) => y.country === "Djibouti");
const addis = yardByCode.get("ADDIS_ABABA") ?? yards.find((y) => y.country === "Ethiopia");
if (!djibouti || !addis) {
this.log("error: need at least one Djibouti and one Ethiopia yard");
return;
}
const wagonTypes = await ds.getRepository(WagonType).find({ where: { isActive: true } });
if (wagonTypes.length === 0) {
this.log("error: no wagon types found — seed reference data first");
return;
}
const route = await ensureTestRoute(ds, yards, djibouti, addis, (msg) => this.log(msg));
const scheduleRepo = ds.getRepository(TrainSchedule);
const trainSetRepo = ds.getRepository(TrainSet);
let seeded = 0;
for (let i = 0; i < count; i++) {
const dir = directionList[i % directionList.length];
const status = statusList[i % statusList.length];
const trainNumber = `TST-SCH-${String(i + 1).padStart(3, "0")}`;
const existing = await scheduleRepo.findOne({ where: { trainNumber } });
if (existing) {
this.log(` Schedule ${trainNumber} already exists — skipping`);
seeded++;
continue;
}
const originYard = dir === "IMPORT" ? djibouti : addis;
const destYard = dir === "IMPORT" ? addis : djibouti;
const isDispatched = status === "DISPATCHED";
const now = new Date();
const departure = new Date(now);
departure.setDate(departure.getDate() + daysAhead);
departure.setHours(7, 0, 0, 0);
const travelHours = dir === "IMPORT" ? 12 : 10;
const arrival = new Date(departure.getTime() + travelHours * 60 * 60 * 1000);
const locomotive = await ensureTestLocomotive(ds, originYard.id, (msg) => this.log(msg));
const wagonCount = 4;
const wagonType = wagonTypes[0];
const wagonCapacity = Number(wagonType.capacityTons) || 70;
const wagonLength = Number(wagonType.lengthMeters) || 14;
const tareWeight = Number(wagonType.tareWeightTons) || 14;
const totalWagonWeight = wagonCount * (tareWeight + 20);
const trainSet = await trainSetRepo.save(
trainSetRepo.create({
locomotiveId: locomotive.id,
totalWeightTons: totalWagonWeight,
totalLengthMeters: wagonLength * wagonCount,
wagonCount,
status: isDispatched ? "DISPATCHED" : status === "DRAFT" ? "DRAFT" : "ASSIGNED",
}),
);
await ds.getRepository(TrainSetLocomotive).save(
ds.getRepository(TrainSetLocomotive).create({
trainSetId: trainSet.id,
locomotiveId: locomotive.id,
sequenceNo: 0,
}),
);
const schedule = await scheduleRepo.save(
scheduleRepo.create({
trainSetId: trainSet.id,
routeId: route.id,
originStationId: originYard.id,
destinationStationId: destYard.id,
scheduledDepartureDate: departure,
scheduledArrivalDate: arrival,
actualDepartureAt: isDispatched ? departure : null,
status,
trainNumber,
direction: dir,
maxWagons: 53,
bookingWindowStatus: isDispatched ? "CLOSED" : "OPEN",
}),
);
for (let w = 0; w < wagonCount; w++) {
const seq = w + 1;
const wagonNumber = `${trainNumber}-W${String(seq).padStart(2, "0")}`;
const physicalWagon = await ensureTestWagon(ds, {
wagonNumber,
wagonTypeId: wagonType.id,
yardId: originYard.id,
trainScheduleId: schedule.id,
tareWeight,
capacityTons: wagonCapacity,
dispatched: isDispatched,
});
await ds.getRepository(TrainSetWagon).save(
ds.getRepository(TrainSetWagon).create({
trainSetId: trainSet.id,
wagonTypeId: wagonType.id,
physicalWagonId: physicalWagon.id,
sequenceNo: seq,
capacityTons: wagonCapacity,
lengthMeters: wagonLength,
assignedWeightTons: 20,
status: isDispatched ? "DEPARTED" : "PLANNED",
}),
);
}
const scheduleBookings = await ds.getRepository(TrainScheduleBooking).find({
where: { trainScheduleId: schedule.id },
});
if (scheduleBookings.length === 0) {
const booking = await ds.getRepository(Booking).findOne({ where: {}, order: { createdAt: "DESC" } });
if (booking) {
await ds.getRepository(TrainScheduleBooking).save(
ds.getRepository(TrainScheduleBooking).create({
trainScheduleId: schedule.id,
bookingId: booking.id,
}),
);
}
}
seeded++;
this.log(` Created ${status} ${dir} schedule: ${trainNumber} (${originYard.label}${destYard.label})`);
}
this.log(`Done — ${seeded} train schedules ensured`);
});
}
async function ensureTestRoute(
ds: DataSource,
yards: Yard[],
originYard: Yard,
destYard: Yard,
log: (msg: string) => void,
): Promise<Route> {
const repo = ds.getRepository(Route);
const name = `TST-RTE-${originYard.code}_${destYard.code}`;
let route = await repo.findOne({ where: { name } });
if (route) return route;
route = await repo.save(
repo.create({
name,
originYardId: originYard.id,
destinationYardId: destYard.id,
isActive: true,
}),
);
log(` Created route: ${name}`);
const milestoneRepo = ds.getRepository(RouteMilestone);
const intermediateYards = yards.filter(
(y) => y.id !== originYard.id && y.id !== destYard.id,
);
await milestoneRepo.save(
milestoneRepo.create({
routeId: route.id,
yardId: originYard.id,
sequenceNo: 0,
}),
);
for (const [idx, y] of intermediateYards.entries()) {
await milestoneRepo.save(
milestoneRepo.create({
routeId: route.id,
yardId: y.id,
sequenceNo: (idx + 1) * 2,
}),
);
}
await milestoneRepo.save(
milestoneRepo.create({
routeId: route.id,
yardId: destYard.id,
sequenceNo: (intermediateYards.length + 1) * 2,
}),
);
log(` Added ${intermediateYards.length + 2} milestones`);
return route;
}
async function ensureTestLocomotive(
ds: DataSource,
yardId: string,
log: (msg: string) => void,
): Promise<Locomotive> {
const repo = ds.getRepository(Locomotive);
const code = "TST-LOCO-01";
let loco = await repo.findOne({ where: { code } });
if (loco) return loco;
loco = await repo.save(
repo.create({
code,
name: "Test Locomotive",
locomotiveType: "DIESEL",
maxPullWeightTons: 4200,
maxTrainLengthMeters: 760,
status: "AVAILABLE",
currentYardId: yardId,
}),
);
log(` Created locomotive: ${code}`);
return loco;
}
async function ensureTestWagon(
ds: DataSource,
input: {
wagonNumber: string;
wagonTypeId: string;
yardId: string;
trainScheduleId: string;
tareWeight: number;
capacityTons: number;
dispatched: boolean;
},
): Promise<Wagon> {
const repo = ds.getRepository(Wagon);
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
if (existing) return existing;
const wagon = repo.create({
wagonNumber: input.wagonNumber,
wagonTypeId: input.wagonTypeId,
currentYardId: input.yardId,
currentTrainScheduleId: input.trainScheduleId,
tareWeight: input.tareWeight,
maxPayloadWeight: input.capacityTons,
status: input.dispatched ? WagonStatus.Assigned : WagonStatus.Available,
notes: "Test seed wagon",
});
const saved = await repo.save(wagon as any);
return Array.isArray(saved) ? saved[0] : saved;
}

View File

@@ -0,0 +1,6 @@
import type Vorpal from "vorpal";
import type { INestApplicationContext } from "@nestjs/common";
export type CommandContext = {
app: INestApplicationContext;
};

View File

@@ -0,0 +1,36 @@
import "reflect-metadata";
import { config } from "dotenv";
config();
import Vorpal from "vorpal";
import { registerCommands } from "./cmds/index";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "../app.module";
const vorpal = new Vorpal();
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: false,
});
try {
registerCommands(vorpal, { app });
const args = process.argv.slice(2);
if (args.length > 0) {
await vorpal.exec(args.join(" "));
} else {
vorpal.parse(process.argv);
}
} finally {
await app.close();
}
}
main().catch((err) => {
console.error("Script failed:", err);
process.exit(1);
});

View File

@@ -1,7 +1,12 @@
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the
* bytes through `GET /api/files/:id` (served from MinIO with backend

View File

@@ -1,4 +1,5 @@
export const API_BASE_URL = import.meta.env.VITE_BASE_API_URL;
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.getType() !== 'http') {
return true;
}
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),

View File

@@ -23,7 +23,10 @@ export interface FaydaConfig {
authorizationEndpoint: string;
tokenEndpoint: string;
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
webRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
@@ -73,6 +76,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
if (!enabled) {
return {
enabled: false,
@@ -81,6 +85,7 @@ export default registerAs('fayda', (): FaydaConfig => {
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
@@ -111,6 +116,7 @@ export default registerAs('fayda', (): FaydaConfig => {
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,

View File

@@ -1,5 +1,5 @@
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator';
import { Type } from 'class-transformer';
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Currency, IdDocumentType } from '@prisma/client';
@@ -9,7 +9,11 @@ export class PassengerInputDto {
@ApiPropertyOptional({ example: 'return-seat-uuid', description: '**ROUND_TRIP / ROUND_TRIP_TRANSIT:** Return leg-1 seat ID' }) @IsOptional() @IsString() returnSeatId?: string;
@ApiPropertyOptional({ example: 'ret-leg2-seat-uuid', description: '**ROUND_TRIP_TRANSIT:** Return leg-2 seat ID' }) @IsOptional() @IsString() returnLeg2SeatId?: string;
@ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first free), Age ≥5 = ADULT (full fare)' }) @IsDateString() dateOfBirth: string;
@ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD). Must not be a future date.' })
@IsDateString()
@Transform(({ value }) => value)
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
dateOfBirth: string;
@ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType, description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType;
@ApiPropertyOptional({ example: 'ET123456789', description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' }) @IsOptional() @IsString() idDocumentNumber?: string;
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
@@ -38,9 +42,12 @@ export class RoundTripPassengerDto {
@ApiProperty({
example: '1990-05-15',
description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)'
})
@IsDateString() dateOfBirth: string;
description: 'Date of birth (YYYY-MM-DD). Must not be a future date.'
})
@IsDateString()
@Transform(({ value }) => value)
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
dateOfBirth: string;
@ApiProperty({
example: 'NATIONAL_ID',

View File

@@ -206,6 +206,13 @@ export class FleetController {
return this.service.listCoaches(dto);
}
@Get('coaches/utilization')
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' })
@ApiResponse({ status: 200, description: 'Coach utilization data' })
getCoachUtilization() {
return this.service.getCoachUtilization();
}
@Get('coaches/:id')
@ApiOperation({ summary: 'Get single coach with seat layout' })
@ApiParam({ name: 'id', description: 'Coach UUID' })

View File

@@ -1,4 +1,5 @@
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
export class CreateTrainDto {
@@ -7,7 +8,7 @@ export class CreateTrainDto {
@ApiPropertyOptional({ example: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @IsBoolean() isActive?: boolean;
@ApiPropertyOptional({ example: true, description: 'Whether the train is active' }) @IsOptional() @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @IsBoolean() isActive?: boolean;
}
export class CreateCoachDto {

View File

@@ -593,6 +593,58 @@ export class FleetService {
};
}
async getCoachUtilization() {
const coaches = await this.prisma.coach.findMany({
include: {
coachType: true,
seats: { select: { id: true, status: true } },
assignments: {
include: {
schedule: {
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
},
},
orderBy: { schedule: { departureAt: 'desc' } },
take: 10,
},
},
orderBy: { sequence: 'asc' },
});
return coaches.map((coach) => {
const totalSeats = coach.seats.length;
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length;
const totalAssignments = coach.assignments.length;
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
return {
id: coach.id,
number: coach.number,
sequence: coach.sequence,
coachType: coach.coachType?.name,
status: coach.status,
totalSeats,
availableSeats,
bookedSeats,
blockedSeats,
maintenanceSeats,
utilizationRate,
totalAssignments,
totalBookings,
recentSchedules: coach.assignments.slice(0, 5).map((a) => ({
scheduleId: a.scheduleId,
departureAt: a.schedule.departureAt,
scheduleStatus: a.schedule.status,
bookings: (a.schedule as any)._count?.bookings ?? 0,
})),
};
});
}
async getAnalytics() {
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(),

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import {
PAYMENT_EVENTS_DLX,
PAYMENT_EVENTS_EXCHANGE,
@@ -19,6 +20,7 @@ export class PaymentEventsConsumer {
constructor(private readonly paymentsService: PaymentsService) {}
@IsPublic()
@RabbitSubscribe({
exchange: PAYMENT_EVENTS_EXCHANGE,
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
@@ -29,6 +31,11 @@ export class PaymentEventsConsumer {
},
})
async handle(event: PaymentEvent): Promise<Nack | void> {
// Logged the instant RabbitMQ delivers the message, before any DB work — proves the
// payment -> passenger broker connection works even if processing later fails/hangs.
this.logger.log(
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
);
try {
const result = await this.paymentsService.handlePaymentEvent(
event as unknown as PaymentEventDto,

View File

@@ -42,4 +42,5 @@ export class UpdateRouteDto {
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -81,7 +81,8 @@ export class RoutesService {
async updateRoute(id: string, dto: UpdateRouteDto) {
const route = await this.prisma.route.findUnique({ where: { id } });
if (!route) throw new NotFoundException('Route not found');
return this.prisma.route.update({
await this.prisma.route.update({
where: { id },
data: {
name: dto.name,
@@ -89,6 +90,22 @@ export class RoutesService {
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
},
});
if (dto.stops && dto.stops.length >= 2) {
await this.prisma.routeStop.deleteMany({ where: { routeId: id } });
await this.prisma.routeStop.createMany({
data: dto.stops.map(s => ({
routeId: id,
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
})),
});
}
return this.prisma.route.findUnique({
where: { id },
include: { stops: { orderBy: { sequence: 'asc' } } },
});
}
@@ -128,6 +145,7 @@ export class RoutesService {
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
if (!station.isOperational) throw new BadRequestException(`Station ${dto.stationId} is not operational`);
const existing = await this.prisma.routeStop.findUnique({
where: { routeId_sequence: { routeId, sequence: dto.sequence } },

View File

@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
const POINTS_TO_MINOR = 10;
// Shape returned by the heavy schedule include used throughout this service
type ScheduleWithIncludes = {
id: string;
routeId: string | null;
departureAt: Date;
arrivalAt: Date;
status: string;
train: any;
originStation: any;
destinationStation: any;
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
};
const SCHEDULE_INCLUDE = {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
} as const;
@Injectable()
export class SearchService {
constructor(
@@ -124,7 +148,7 @@ export class SearchService {
if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate);
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
const totalPassengers = adultCount + (childCount ?? 0);
@@ -139,30 +163,16 @@ export class SearchService {
],
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
});
const results: any[] = [];
for (const schedule of schedules) {
const result = await this.buildScheduleResult(
schedule,
originStationId,
destinationStationId,
totalPassengers,
nationality,
);
if (result) results.push(result);
}
return results;
const results = await Promise.all(
schedules.map(schedule =>
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter(Boolean);
}
private async searchSchedules(
@@ -185,29 +195,18 @@ export class SearchService {
departureAt: { gte: date < now ? now : date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
include: SCHEDULE_INCLUDE,
});
const results: any[] = [];
for (const schedule of schedules) {
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
if (result) results.push(result);
}
return results;
const results = await Promise.all(
schedules.map(schedule =>
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter(Boolean);
}
// ── Transit search ─────────────────────────────────────────────────────────
// Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination)
// where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes
// to change trains at the transit station.
private readonly MIN_CONNECTION_MINUTES = 30;
private readonly MAX_CONNECTION_MINUTES = 360;
@@ -219,82 +218,67 @@ export class SearchService {
childCount?: number,
nationality?: string,
) {
// Find all stations that can serve as transit points:
// they must be a stop after origin on some schedule AND
// a stop before destination on another schedule on the same day.
const [y, m, d] = dateStr.split('-').map(Number);
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
const totalPassengers = adultCount + (childCount ?? 0);
// Load all schedules on this date that pass through origin
const leg1Schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
// Load leg1 and all potential leg2 candidates in one parallel round-trip
// instead of firing a separate DB query per transit stop.
const [leg1Schedules, allCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
},
},
});
include: SCHEDULE_INCLUDE,
}),
this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: dayStart, lt: leg2WindowEnd },
},
include: SCHEDULE_INCLUDE,
}),
]);
const results: any[] = [];
for (const leg1 of leg1Schedules) {
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
// Every stop after origin on leg1 is a candidate transit station
const candidateTransitStops = leg1.stopTimes.filter(
(s: any) => s.sequence > originStop.sequence,
s => s.sequence > originStop.sequence,
);
for (const transitStop of candidateTransitStops) {
// leg1 must NOT already contain the final destination
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
if (leg1HasDest) continue;
const transitStationId = transitStop.stationId;
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
// Find leg2 schedules departing from the transit station within the connection window,
// and reaching the final destination. Search up to the next calendar day to handle
// overnight connections.
const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000);
const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
const leg2Schedules = await this.prisma.trainSchedule.findMany({
where: {
status: { in: ['SCHEDULED', 'BOARDING'] },
departureAt: { gte: connWindowStart, lte: connWindowEnd },
stopTimes: { some: { stationId: transitStationId } },
},
include: {
train: true,
originStation: true,
destinationStation: true,
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
},
},
// Filter from pre-loaded candidates in memory — no extra DB query
const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
const dep = new Date(s.departureAt).getTime();
return dep >= connWindowStart.getTime()
&& dep <= connWindowEnd.getTime()
&& s.stopTimes.some(st => st.stationId === transitStationId);
});
for (const leg2 of leg2Schedules) {
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
if (!leg2TransitStop || !leg2DestStop) continue;
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
// Build individual leg result objects (reuse existing per-schedule logic)
const [leg1Result, leg2Result] = await Promise.all([
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
@@ -326,7 +310,6 @@ export class SearchService {
displayCurrency,
combinedMinFareMinor,
combinedMinFareDisplay,
// Convenience top-level fields so round-trip filter can read them uniformly
departureAt: leg1Result.departureAt,
arrivalAt: leg2Result.arrivalAt,
totalDurationMinutes:
@@ -339,19 +322,37 @@ export class SearchService {
return results;
}
// Builds the same result shape as searchSchedules for a single schedule+leg,
// extracted so both direct and transit paths share identical output.
private async buildScheduleResult(
schedule: any,
schedule: ScheduleWithIncludes,
originStationId: string,
destinationStationId: string,
totalPassengers: number,
nationality?: string,
) {
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
// Collect all valid seat IDs upfront for a single batch availability check
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
a.coach.seats
.filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim())
.map((s: any) => s.id as string)
);
// Run availability batch and fare calculation in parallel
const [freeSeats, faresByClass] = await Promise.all([
this.segmentsService.getFreeSeatIds(
schedule.id,
allValidSeatIds,
schedule.stopTimes,
originStop.sequence,
destStop.sequence,
),
this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality),
]);
// Compute per-class availability using the pre-computed free seat set
const availabilityByClass: Record<string, number> = {};
for (const assignment of schedule.coachAssignments) {
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
@@ -362,8 +363,7 @@ export class SearchService {
let count = 0;
for (const seat of assignment.coach.seats) {
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
if (free) count++;
if (freeSeats.has(seat.id)) count++;
}
if (count > 0) {
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
@@ -374,15 +374,13 @@ export class SearchService {
let available = 0;
for (const seat of assignment.coach.seats) {
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
if (free) available++;
if (freeSeats.has(seat.id)) available++;
}
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
}
}
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
@@ -400,8 +398,8 @@ export class SearchService {
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
status: schedule.status,
stops: schedule.stopTimes
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
.filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
.map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
availabilityByClass,
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
displayCurrency,
@@ -500,46 +498,31 @@ export class SearchService {
}
private async calculateFaresForSegment(
schedule: any,
schedule: ScheduleWithIncludes,
originStationId: string,
destinationStationId: string,
nationality?: string,
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const seatClassIds: string[] = Array.from(
new Set(
schedule.coachAssignments
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
.map((sc: any) => sc.id)
.filter((id: any) => id)
)
);
if (seatClassIds.length === 0) {
console.log(`No seat classes assigned to schedule ${schedule.id}`);
return [];
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
const seatClassMap = new Map<string, any>();
for (const a of schedule.coachAssignments) {
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
}
}
const seatClasses = Array.from(seatClassMap.values())
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
const seatClasses = await this.prisma.seatClass.findMany({
where: {
isActive: true,
id: { in: seatClassIds }
},
orderBy: { baseFareMinor: 'asc' },
});
if (seatClasses.length === 0) {
console.log(`No active seat classes for schedule ${schedule.id}`);
return [];
}
if (seatClasses.length === 0) return [];
if (schedule.routeId) {
const results = await Promise.all(
seatClasses.map(async (sc) => {
try {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId,
routeId: schedule.routeId!,
originStationId,
destinationStationId,
seatClassId: sc.id,
@@ -552,8 +535,7 @@ export class SearchService {
displayCurrency: fare.billingCurrency as Currency,
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
};
} catch (error) {
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
} catch {
return null;
}
}),
@@ -562,36 +544,32 @@ export class SearchService {
const validResults = results.filter(
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
);
if (validResults.length > 0) {
return validResults;
}
if (validResults.length > 0) return validResults;
}
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
// Fallback: use station codes from already-loaded stopTimes when available
const originStop = schedule.stopTimes.find(st => st.stationId === originStationId);
const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId);
const originCode = originStop?.station?.code;
const destCode = destStop?.station?.code;
if (originStation && destStation) {
const segmentRoute = `${originStation.code}-${destStation.code}`;
if (originCode && destCode) {
const segmentRoute = `${originCode}-${destCode}`;
const now = new Date();
const fareRules = await this.prisma.fareRule.findMany({
where: {
route: segmentRoute,
seatClassId: { in: seatClassIds },
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
validFrom: { lte: now },
OR: [
{ validUntil: null },
{ validUntil: { gte: now } },
],
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
},
});
if (fareRules.length > 0) {
console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`);
const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name]));
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
return fareRules.map(rule => ({
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
baseFareMinor: rule.baseFareMinor,
displayCurrency,
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
@@ -599,20 +577,20 @@ export class SearchService {
}
}
console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`);
return [];
}
private async buildCoachTypeDetails(
schedule: any,
// buildCoachTypeDetails is pure in-memory — no async needed
private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
): Promise<Array<{
): Array<{
coachTypeId: string;
coachTypeName: string;
coachTypeCode: string;
coachId: string;
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
}>> {
}> {
const coachTypeMap = new Map<
string,
{ coachType: any; classNames: Set<string>; coachId: string }
@@ -682,14 +660,6 @@ export class SearchService {
return fare.baseFarePerPassengerMinor;
}
private getDefaultFareForClass(_className: string): never {
throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead');
}
private defaultFare(_seatClassName: string): never {
throw new Error('defaultFare should not be called — use resolveScheduleFare instead');
}
private selectBestFareRule(
candidates: any[],
scheduleId: string,

View File

@@ -184,6 +184,27 @@ This makes it clear which segment of the route each seat is held for, enabling s
return this.service.unblockSeat(seatId);
}
// ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance")
@UseGuards(IamGuard)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({ status: 200, description: "Seat set to under maintenance" })
setMaintenance(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
return this.service.setMaintenance(seatId, body.reason);
}
@Delete(":seatId/maintenance")
@UseGuards(IamGuard)
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({ status: 200, description: "Seat cleared from maintenance" })
clearMaintenance(@Param("seatId") seatId: string) {
return this.service.clearMaintenance(seatId);
}
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove")
@UseGuards(IamGuard)

View File

@@ -742,6 +742,23 @@ export class SeatsService {
return { unblocked: true, seatId };
}
async setMaintenance(seatId: string, reason: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
if (seat.status === 'BOOKED') throw new BadRequestException('Cannot set a booked seat to maintenance');
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'UNDER_MAINTENANCE' as any } });
await this.prisma.seatBlock.create({ data: { seatId, reason: `MAINTENANCE: ${reason}`, blockedBy: 'system' } });
return { maintenance: true, seatId, reason };
}
async clearMaintenance(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' as any } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
return { maintenance: false, seatId };
}
async removeSeat(seatId: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');

View File

@@ -146,6 +146,96 @@ export class SegmentsService {
return true;
}
/**
* Batch availability check for multiple seats on a single schedule.
* Replaces N×isSeatFreeForLeg calls with 2 queries total.
* Returns a Set of seat IDs that are free for [reqFrom, reqTo).
*/
async getFreeSeatIds(
scheduleId: string,
seatIds: string[],
stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>,
reqFrom: number,
reqTo: number,
): Promise<Set<string>> {
if (seatIds.length === 0) return new Set();
const seqOf = (stationId: string) =>
stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence;
const seatIdSet = new Set(seatIds);
const now = new Date();
const [allHolds, bookedLegs] = await Promise.all([
this.prisma.seatHold.findMany({
where: { scheduleId, expiresAt: { gt: now } },
select: { seatIds: true, createdBy: true },
}),
this.prisma.journeySegment.findMany({
where: {
scheduleId,
seatId: { in: seatIds },
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
},
select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true },
}),
]);
// Determine which seats are blocked by active holds
const holdBlockedSeats = new Set<string>();
for (const hold of allHolds) {
let holdFrom: number | undefined;
let holdTo: number | undefined;
try {
if (hold.createdBy) {
const meta = JSON.parse(hold.createdBy as string);
holdFrom = seqOf(meta.originStationId);
holdTo = seqOf(meta.destinationStationId);
}
} catch { /* ignore */ }
for (const sid of hold.seatIds) {
if (!seatIdSet.has(sid)) continue;
// Conservative block if leg can't be resolved; otherwise check overlap
if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) {
holdBlockedSeats.add(sid);
}
}
}
// Build full journey ranges per seat (group multi-leg journeys)
const journeyRangesBySeat = new Map<string, Map<string, { from: number; to: number }>>();
for (const leg of bookedLegs) {
if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue;
const depSeq = seqOf(leg.departureStationId);
const arrSeq = seqOf(leg.arrivalStationId);
if (depSeq === undefined || arrSeq === undefined) continue;
let rangeMap = journeyRangesBySeat.get(leg.seatId);
if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); }
const existing = rangeMap.get(leg.journeyId);
rangeMap.set(leg.journeyId, existing
? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) }
: { from: depSeq, to: arrSeq });
}
const freeSeats = new Set<string>();
for (const seatId of seatIds) {
if (holdBlockedSeats.has(seatId)) continue;
let blocked = false;
const rangeMap = journeyRangesBySeat.get(seatId);
if (rangeMap) {
for (const { from, to } of rangeMap.values()) {
if (from < reqTo && reqFrom < to) { blocked = true; break; }
}
}
if (!blocked) freeSeats.add(seatId);
}
return freeSeats;
}
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
async getOverlappingReservations(
scheduleId: string,

View File

@@ -16,7 +16,7 @@ export class StartVerificationDto {
enum: ['WEB', 'MOBILE'],
default: 'WEB',
description:
'Client platform. Decides where /callback redirects on completion: a web https URL (WEB) or a custom-scheme deep link the Flutter app intercepts (MOBILE).',
'Client platform. Selects which OAuth redirect_uri is sent to eSignet: WEB uses FAYDA_WEB_REDIRECT_URI, MOBILE uses FAYDA_REDIRECT_URI. Both land on the same /complete endpoint with identical handling.',
})
@IsOptional()
@IsIn(['WEB', 'MOBILE'])

View File

@@ -35,6 +35,7 @@ function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo',
redirectUri: 'http://localhost:4000/fayda/verification/complete',
webRedirectUri: 'http://localhost:5174/fayda/verification/complete',
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code',
@@ -101,13 +102,14 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
// Default platform is WEB → webRedirectUri.
expect(parsed.searchParams.get('redirect_uri')).toBe(
'http://localhost:4000/fayda/verification/complete',
'http://localhost:5174/fayda/verification/complete',
);
expect(parsed.searchParams.get('state')).toBe(created.state);
});
it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
it('sends the MOBILE redirect_uri (base redirectUri) for MOBILE sessions', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
@@ -122,6 +124,19 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
);
});
it('sends the WEB redirect_uri (webRedirectUri) for WEB sessions', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'VERIFY',
platform: 'WEB',
});
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
'http://localhost:5174/fayda/verification/complete',
);
});
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })),

View File

@@ -161,7 +161,18 @@ export class VerifaydaService {
`Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`,
);
return this.buildAuthorizationUrl({ state, codeChallenge });
return this.buildAuthorizationUrl({
state,
codeChallenge,
redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'),
});
}
/** WEB clients use `webRedirectUri`; MOBILE uses the base `redirectUri`. */
private redirectUriForPlatform(platform?: FaydaPlatform): string {
return platform === 'MOBILE'
? this.faydaConfig.redirectUri
: this.faydaConfig.webRedirectUri;
}
@@ -213,6 +224,7 @@ export class VerifaydaService {
const tokens = await this.exchangeCodeForTokens(
query.code,
session.codeVerifier,
this.redirectUriForPlatform(session.platform as FaydaPlatform),
);
const userInfo = await this.fetchUserInfo(tokens.access_token);
const normalized = this.normalizeUserInfo(userInfo);
@@ -307,11 +319,12 @@ export class VerifaydaService {
private buildAuthorizationUrl(args: {
state: string;
codeChallenge: string;
redirectUri: string;
}): string {
const params = new URLSearchParams({
client_id: this.faydaConfig.clientId,
response_type: 'code',
redirect_uri: this.faydaConfig.redirectUri,
redirect_uri: args.redirectUri,
scope: this.faydaConfig.scope,
state: args.state,
code_challenge: args.codeChallenge,
@@ -344,6 +357,7 @@ export class VerifaydaService {
private async exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string,
): Promise<FaydaTokenResponse> {
const clientAssertion = await generateClientAssertion({
clientId: this.faydaConfig.clientId,
@@ -354,7 +368,7 @@ export class VerifaydaService {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.faydaConfig.redirectUri,
redirect_uri: redirectUri,
client_id: this.faydaConfig.clientId,
client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',

View File

@@ -131,9 +131,38 @@ export default function AuditLogsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
<ActionButton
icon={Download}
variant="secondary"
onClick={() => {
const items = data?.items || [];
if (!items.length) return;
const headers = ['Timestamp', 'Action', 'Entity Type', 'Entity ID', 'User ID', 'IP Address'];
const rows = items.map((l: any) => [
formatDateTime(l.createdAt),
l.action,
l.entityType,
l.entityId || '',
l.iamUserId || l.userId || '',
l.ipAddress || '',
]);
const csv = [headers, ...rows].map(r => r.map((v: string) => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}}
>
Export CSV
</ActionButton>
</div>
{/* Stats Cards */}

View File

@@ -14,32 +14,142 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null);
const startCamera = async () => {
try {
setIsInitializing(true);
setCameraError(null);
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera
width: { ideal: 1280 },
height: { ideal: 720 }
// Check if mediaDevices is supported
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.';
setCameraError(errorMsg);
onError(errorMsg);
setIsInitializing(false);
return;
}
// First, stop any existing stream
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
// Request camera access with simpler fallback
let mediaStream: MediaStream | null = null;
try {
// Try with environment (back) camera first
mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
});
} catch {
// Fallback to any available camera with simple constraints
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
});
} catch (fallbackErr) {
throw fallbackErr;
}
}
if (!mediaStream) {
throw new Error('Failed to get media stream');
}
if (!videoRef.current) {
throw new Error('Video element not found');
}
const video = videoRef.current;
video.srcObject = mediaStream;
// Wait for video to be ready with proper event handling
await new Promise<void>((resolve, reject) => {
let resolved = false;
const cleanup = () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('loadeddata', onLoadedData);
video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('error', onVideoError);
};
const finishResolve = () => {
if (!resolved) {
resolved = true;
cleanup();
resolve();
}
};
const onLoadedMetadata = () => finishResolve();
const onLoadedData = () => finishResolve();
const onCanPlay = () => finishResolve();
const onVideoError = (_e: Event) => {
cleanup();
reject(new Error('Video failed to load'));
};
// Add multiple event listeners for better compatibility
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('loadeddata', onLoadedData);
video.addEventListener('canplay', onCanPlay);
video.addEventListener('error', onVideoError);
setTimeout(() => finishResolve(), 2000);
});
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
await videoRef.current.play();
setStream(mediaStream);
setIsScanning(true);
try {
await video.play();
} catch {
await new Promise(resolve => setTimeout(resolve, 100));
try { await video.play(); } catch { /* continue */ }
}
// Set state to show video
setStream(mediaStream);
setIsScanning(true);
setIsInitializing(false);
} catch (error: any) {
const errorMsg = 'Camera access denied. Please enable camera permissions in browser settings.';
let errorMsg = 'Camera access failed. Please check permissions and try again.';
if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
errorMsg = 'Camera permission denied. Please allow camera access in your browser settings and try again.';
} else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
errorMsg = 'No camera found. Please connect a camera and try again.';
} else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
errorMsg = 'Camera is already in use by another application. Please close other apps using the camera.';
} else if (error.name === 'OverconstrainedError') {
errorMsg = 'Camera does not meet the requirements. Please try a different camera.';
} else if (error.name === 'SecurityError') {
errorMsg = 'Camera access blocked due to security settings. Please use HTTPS or check your browser security settings.';
}
setCameraError(errorMsg);
onError(errorMsg);
console.error('Camera error:', error);
// Clean up on error
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
setIsScanning(false);
setIsInitializing(false);
}
};
@@ -75,21 +185,17 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
try {
// Try to use jsqr if available
const jsQR = (window as any).jsQR;
if (jsQR) {
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
});
if (code) {
onScan(code.data);
stopCamera();
}
}
} catch (err) {
console.error('QR scan error:', err);
}
} catch { /* ignore scan errors */ }
}
}, [isScanning, onScan, stopCamera]);
@@ -120,7 +226,43 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
return (
<div className="space-y-4">
{!isScanning ? (
{/* Video viewer - always rendered, visibility controlled by display style */}
<div className={`space-y-3 ${!isScanning ? '!hidden' : ''}`}>
<div className="relative bg-black rounded-xl overflow-hidden" style={{ minHeight: '320px' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full min-h-[320px] object-cover block"
style={{ display: 'block', visibility: 'visible' }}
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg animate-pulse"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4 pointer-events-none">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
<p className="text-white/70 text-center text-xs mt-1">Scanning...</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<CameraOff className="w-5 h-5" />
Stop Camera
</button>
</div>
{/* Start button and loading state */}
{!isScanning && !isInitializing && (
<div className="space-y-3">
<button
onClick={startCamera}
@@ -135,36 +277,33 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
</div>
)}
</div>
) : (
)}
{/* Loading state */}
{isInitializing && (
<div className="space-y-3">
<div className="relative bg-black rounded-xl overflow-hidden">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-64 object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="text-blue-700 dark:text-blue-300 font-medium">Starting camera...</p>
<p className="text-blue-600 dark:text-blue-400 text-sm text-center">
Please allow camera access when prompted by your browser
</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
onClick={() => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
setIsInitializing(false);
setCameraError('Camera initialization cancelled by user');
}}
className="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold py-3 px-6 rounded-xl transition-colors"
>
<CameraOff className="w-5 h-5" />
Stop Camera
Cancel
</button>
</div>
)}
@@ -424,12 +563,19 @@ export default function BoardingPage() {
<h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3>
<ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm">
<li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> Allow camera access when your browser prompts you</li>
<li> Hold phone steady and position QR code within the frame</li>
<li> For manual option, type or paste booking reference</li>
<li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li>
<li> Red error shows validation issues</li>
</ul>
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-700">
<p className="text-blue-700 dark:text-blue-300 text-xs">
<strong>Tip:</strong> If camera doesn't open, check browser permissions in Settings Privacy Camera
</p>
</div>
</div>
{/* Quick Stats */}

View File

@@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches';
type Tab = 'types' | 'coaches' | 'utilization';
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
@@ -163,6 +163,12 @@ export default function CoachesPage() {
queryFn: () => fleetApi.getCoaches({}),
});
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
queryKey: ['coach-utilization'],
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
enabled: activeTab === 'utilization',
});
// Coach Type Mutations
const createCoachTypeMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
@@ -547,6 +553,16 @@ export default function CoachesPage() {
>
Coaches
</button>
<button
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
className={`px-4 py-3 font-medium transition-colors ${
activeTab === 'utilization'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Utilization Report
</button>
</div>
{/* Coach Types Tab */}
@@ -594,6 +610,44 @@ export default function CoachesPage() {
/>
</div>
)}
{/* Utilization Tab */}
{activeTab === 'utilization' && (() => {
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
return (
<div className="pt-6 space-y-4">
<DataTable
columns={[
{ key: 'sequence', label: 'Seq', render: (r: any) => <span className="font-mono">{r.sequence}</span> },
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
{
key: 'utilizationRate', label: 'Utilization',
render: (r: any) => (
<div className="flex items-center gap-2">
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
</div>
<span className="font-mono text-sm">{r.utilizationRate}%</span>
</div>
),
},
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
]}
data={rows}
actions={[]}
loading={utilizationLoading}
emptyMessage="No coach utilization data available"
/>
</div>
);
})()}
</div>
{/* Delete Confirmation */}

View File

@@ -3,13 +3,13 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react';
import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
@@ -18,7 +18,6 @@ const MOCK_STATS = {
totalBookings: 1247,
totalRevenue: 892450,
totalPassengers: 2156,
occupancyRate: 78
};
const MOCK_RECENT_BOOKINGS = [
@@ -50,12 +49,6 @@ function DashboardPageContent() {
staleTime: 60000, // 1 minute
});
const { data: revenueData, isLoading: revenueLoading } = useQuery({
queryKey: ['revenue-chart'],
queryFn: () => dashboardApi.getRevenueChart(30),
retry: 1,
});
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
@@ -68,12 +61,6 @@ function DashboardPageContent() {
retry: 1,
});
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
queryKey: ['occupancy-trend'],
queryFn: () => dashboardApi.getOccupancyTrend(7),
retry: 1,
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
@@ -170,7 +157,7 @@ function DashboardPageContent() {
)}
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
@@ -189,75 +176,6 @@ function DashboardPageContent() {
icon={Users}
color="purple"
/>
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${displayStats?.occupancyRate || 0}%`}
icon={Percent}
color="orange"
/>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Revenue Trend (Last 30 Days)
</h2>
{revenueLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : revenueData && revenueData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No revenue data available</p>
</div>
</div>
)}
</div>
{/* Occupancy Trend */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Percent className="h-5 w-5" />
Occupancy Trend (Last 7 Days)
</h2>
{occupancyLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : occupancyTrend && occupancyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyTrend}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<Percent className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No occupancy data available</p>
</div>
</div>
)}
</div>
</div>
{/* Payment Methods Distribution */}

View File

@@ -2,187 +2,121 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react';
import { Plus, Edit, 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 { apiClient } from '@/lib/api-client';
import { formatDateTime } from '@/lib/utils';
interface FareConfiguration {
id: string;
name: string;
description?: string;
effective_date: string;
expiry_date?: string;
is_active: boolean;
is_default: boolean;
created_by?: string;
approved_by?: string;
approved_at?: string;
created_at: string;
updated_at: string;
rate_rules_count: number;
components_count: number;
age_rules_count: number;
}
interface SystemStatus {
configurableFaresEnabled: boolean;
rolloutPercentage: number;
totalConfigurations: number;
activeConfiguration: string | null;
activeConfigurationName: string | null;
systemReady: boolean;
}
interface FareTestResult {
baseFareMinor: number;
componentsTotal: number;
finalTotalMinor: number;
breakdown?: Array<{
description: string;
runningTotal: number;
}>;
}
export default function ConfigurableFarePage() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showTestModal, setShowTestModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<FareConfiguration | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null });
export default function FareManagementPage() {
const [filters, setFilters] = useState({ scheduleId: '' });
const [showModal, setShowModal] = useState(false);
const [editingRule, setEditingRule] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null });
const [formError, setFormError] = useState<string | null>(null);
const queryClient = useQueryClient();
// Queries
const { data: configurations = [], isLoading: configsLoading } = useQuery<FareConfiguration[]>({
queryKey: ['fare-configurations'],
queryFn: () => apiClient.get('/admin/fare-configurations'),
});
const { data: systemStatus } = useQuery<SystemStatus>({
queryKey: ['fare-system-status'],
queryFn: () => apiClient.get('/admin/fare-migration/status'),
});
// Mutations
const activateMutation = useMutation({
mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
const { data: fareRules, isLoading } = useQuery<any[]>({
queryKey: ['fare-rules', filters],
queryFn: async () => {
const params = new URLSearchParams();
if (filters.scheduleId) params.append('scheduleId', filters.scheduleId);
const res = await apiClient.get<any>(`/schedules/fares?${params}`);
return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || [];
},
});
const { data: schedulesData } = useQuery({
queryKey: ['schedules'],
queryFn: () => apiClient.get<any>('/schedules'),
});
const { data: seatClassesData } = useQuery({
queryKey: ['seat-classes'],
queryFn: () => apiClient.get<any>('/fleet/classes'),
});
const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || [];
const seatClasses = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || [];
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/schedules/fares', data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); },
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save fare rule'),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/schedules/fares/${id}`, data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setShowModal(false); setEditingRule(null); setFormError(null); },
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update fare rule'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
setDeleteConfirm({ isOpen: false, config: null });
},
mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); },
onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })),
});
const toggleSystemMutation = useMutation({
mutationFn: (enabled: boolean) =>
enabled
? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 })
: apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setFormError(null);
const fd = new FormData(e.currentTarget);
const payload: any = {
seatClassId: fd.get('seatClassId') as string,
baseFareMinor: Math.round(parseFloat(fd.get('baseFareMinor') as string) * 100),
validFrom: new Date(fd.get('validFrom') as string).toISOString(),
};
const scheduleId = fd.get('scheduleId') as string;
const nationality = fd.get('nationality') as string;
const passengerCategory = fd.get('passengerCategory') as string;
const validUntil = fd.get('validUntil') as string;
if (scheduleId) payload.scheduleId = scheduleId;
if (nationality) payload.nationality = nationality;
if (passengerCategory) payload.passengerCategory = passengerCategory;
if (validUntil) payload.validUntil = new Date(validUntil).toISOString();
const setupSystemMutation = useMutation({
mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', {
activateNewFormula: true,
enableFeature: true
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
},
});
const handleActivate = async (config: FareConfiguration) => {
await activateMutation.mutateAsync(config.id);
};
const handleDelete = (config: FareConfiguration) => {
setDeleteConfirm({ isOpen: true, config });
};
const confirmDelete = async () => {
if (deleteConfirm.config) {
await deleteMutation.mutateAsync(deleteConfirm.config.id);
if (editingRule) {
await updateMutation.mutateAsync({ id: editingRule.id, data: payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const handleTest = (config: FareConfiguration) => {
setSelectedConfig(config);
setShowTestModal(true);
};
const columns = [
{
key: 'name',
label: 'Configuration Name',
sortable: true,
render: (config: FareConfiguration) => (
<div>
<div className="font-medium">{config.name}</div>
{config.description && (
<div className="text-sm text-muted-foreground">{config.description}</div>
)}
</div>
),
key: 'seatClass', label: 'Seat Class',
render: (r: any) => <span className="font-medium">{r.seatClass?.name || r.seatClassId}</span>,
},
{
key: 'status',
label: 'Status',
render: (config: FareConfiguration) => (
<div className="space-y-1">
<Badge variant="status" status={config.is_active ? 'CONFIRMED' : 'PENDING'}>
{config.is_active ? 'Active' : 'Inactive'}
</Badge>
{config.is_default && (
<Badge variant="status" status="INFO">Default</Badge>
)}
</div>
),
key: 'schedule', label: 'Schedule',
render: (r: any) => r.trip
? <span className="text-sm">{r.trip.originStation?.name} {r.trip.destinationStation?.name}<br /><span className="text-xs text-muted-foreground">{r.trip.departureAt ? new Date(r.trip.departureAt).toLocaleDateString() : ''}</span></span>
: <span className="text-xs text-muted-foreground">All schedules</span>,
},
{
key: 'rules',
label: 'Rules Count',
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>{config.rate_rules_count} rate rules</div>
<div>{config.components_count} components</div>
<div>{config.age_rules_count} age rules</div>
</div>
),
key: 'passengerCategory', label: 'Category',
render: (r: any) => r.passengerCategory
? <Badge variant="status" status={r.passengerCategory === 'ADULT' ? 'CONFIRMED' : 'INFO'}>{r.passengerCategory}</Badge>
: <span className="text-xs text-muted-foreground">All</span>,
},
{
key: 'dates',
label: 'Validity Period',
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>From: {new Date(config.effective_date).toLocaleDateString()}</div>
{config.expiry_date && (
<div>Until: {new Date(config.expiry_date).toLocaleDateString()}</div>
)}
</div>
),
key: 'nationality', label: 'Nationality',
render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
},
{
key: 'created_at',
label: 'Created',
sortable: true,
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>{new Date(config.created_at).toLocaleDateString()}</div>
{config.created_by && (
<div className="text-muted-foreground">by {config.created_by}</div>
)}
key: 'baseFareMinor', label: 'Base Fare (ETB)',
render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
},
{
key: 'validity', label: 'Validity',
render: (r: any) => (
<div className="text-xs">
<div>From: {formatDateTime(r.validFrom)}</div>
{r.validUntil && <div>Until: {formatDateTime(r.validUntil)}</div>}
{!r.validUntil && <div className="text-muted-foreground">No expiry</div>}
</div>
),
},
@@ -190,24 +124,12 @@ export default function ConfigurableFarePage() {
const actions = [
{
label: 'Activate',
onClick: handleActivate,
variant: 'secondary' as const,
icon: Play,
show: (config: FareConfiguration) => !config.is_active,
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); },
},
{
label: 'Test',
onClick: handleTest,
variant: 'secondary' as const,
icon: TestTube,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
show: (config: FareConfiguration) => !config.is_active,
label: 'Delete', icon: Trash2, variant: 'danger' as const,
onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }),
},
];
@@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Configurable Fare Management</h1>
<p className="text-muted-foreground mt-1">
Manage dynamic fare configurations with flexible rules, components, and pricing
</p>
</div>
<div className="flex gap-2">
<ActionButton
icon={Settings}
variant="secondary"
onClick={() => setupSystemMutation.mutate()}
loading={setupSystemMutation.isPending}
disabled={systemStatus?.systemReady}
>
{systemStatus?.systemReady ? 'System Ready' : 'Setup System'}
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => setShowCreateModal(true)}
>
New Configuration
</ActionButton>
<h1 className="text-2xl font-bold">Fare Management</h1>
<p className="text-muted-foreground">Configure fare rules by seat class, passenger category, and nationality</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule</ActionButton>
</div>
{/* System Status */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-muted-foreground">System Status</div>
<div className={`font-semibold ${systemStatus?.systemReady ? 'text-green-600' : 'text-yellow-600'}`}>
{systemStatus?.systemReady ? 'Ready' : 'Setup Required'}
</div>
</div>
<Badge variant="status" status={systemStatus?.configurableFaresEnabled ? 'CONFIRMED' : 'CANCELLED'}>
{systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Total Configurations</div>
<div className="text-2xl font-bold">{systemStatus?.totalConfigurations || 0}</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Rollout Percentage</div>
<div className="text-2xl font-bold">{systemStatus?.rolloutPercentage || 0}%</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Active Configuration</div>
<div className="font-medium">
{systemStatus?.activeConfigurationName || 'None'}
</div>
</div>
</div>
{/* System Controls */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">System Control</h3>
<p className="text-sm text-muted-foreground mt-1">
Enable or disable the configurable fare system globally
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">
{systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'}
</span>
<ActionButton
variant={systemStatus?.configurableFaresEnabled ? 'danger' : 'secondary'}
onClick={() => toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)}
loading={toggleSystemMutation.isPending}
icon={systemStatus?.configurableFaresEnabled ? Square : Play}
>
{systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'}
</ActionButton>
</div>
<div className="flex flex-wrap gap-3 mb-4">
<select className="input w-64" value={filters.scheduleId}
onChange={(e) => setFilters({ ...filters, scheduleId: e.target.value })}>
<option value="">All Schedules</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>
{s.originStation?.name} {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
</option>
))}
</select>
</div>
<DataTable data={fareRules || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No fare rules found" />
</div>
{/* Configurations Table */}
<div className="card">
<div className="mb-6">
<h3 className="text-lg font-semibold">Fare Configurations</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage fare calculation configurations with custom rates, components, and age-based pricing
</p>
</div>
<DataTable
data={configurations}
columns={columns}
actions={actions}
loading={configsLoading}
emptyMessage="No fare configurations found. Create your first configuration to get started."
/>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, config: null })}
onConfirm={confirmDelete}
title="Delete Configuration"
message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`}
confirmText="Delete"
isDanger={true}
isLoading={deleteMutation.isPending}
warning="Active configurations cannot be deleted. Deactivate first if needed."
onClose={() => setDeleteConfirm({ isOpen: false, rule: null })}
onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)}
title="Delete Fare Rule"
message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`}
confirmText="Delete" isDanger isLoading={deleteMutation.isPending}
error={deleteConfirm.error}
/>
{/* Test Modal */}
{showTestModal && selectedConfig && (
<FareTestModal
configuration={selectedConfig}
isOpen={showTestModal}
onClose={() => {
setShowTestModal(false);
setSelectedConfig(null);
}}
/>
)}
{/* Create/Edit Modal */}
{showCreateModal && (
<ConfigurationFormModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
}}
/>
)}
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingRule(null); }}
title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg">
<form onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">{formError}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Seat Class *</label>
<select name="seatClassId" className="input" defaultValue={editingRule?.seatClassId || ''} required>
<option value="">Select Seat Class</option>
{seatClasses.map((sc: any) => (
<option key={sc.id} value={sc.id}>{sc.name}</option>
))}
</select>
</div>
<div>
<label className="label">Schedule (optional)</label>
<select name="scheduleId" className="input" defaultValue={editingRule?.tripId || ''}>
<option value="">All Schedules</option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>
{s.originStation?.name} {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
</option>
))}
</select>
</div>
<div>
<label className="label">Passenger Category (optional)</label>
<select name="passengerCategory" className="input" defaultValue={editingRule?.passengerCategory || ''}>
<option value="">All Categories</option>
<option value="ADULT">Adult (5 years)</option>
<option value="CHILD">Child (&lt;5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Leave blank to apply to all passengers</p>
</div>
<div>
<label className="label">Nationality (optional)</label>
<select name="nationality" className="input" defaultValue={editingRule?.nationality || ''}>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other (International)</option>
</select>
</div>
<div>
<label className="label">Base Fare (ETB) *</label>
<input type="number" name="baseFareMinor" className="input" min="0" step="0.01"
defaultValue={editingRule ? (editingRule.baseFareMinor / 100).toFixed(2) : ''} required
placeholder="e.g. 350.00" />
</div>
<div>
<label className="label">Valid From *</label>
<input type="datetime-local" name="validFrom" className="input" required
defaultValue={editingRule?.validFrom ? new Date(editingRule.validFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} />
</div>
<div>
<label className="label">Valid Until (optional)</label>
<input type="datetime-local" name="validUntil" className="input"
defaultValue={editingRule?.validUntil ? new Date(editingRule.validUntil).toISOString().slice(0, 16) : ''} />
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={() => { setShowModal(false); setEditingRule(null); }}>Cancel</ActionButton>
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{editingRule ? 'Update' : 'Create'} Fare Rule
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}
// Test Modal Component
function FareTestModal({
configuration,
isOpen,
onClose
}: {
configuration: FareConfiguration;
isOpen: boolean;
onClose: () => void;
}) {
const [testData, setTestData] = useState({
distanceKm: 100,
nationality: 'Ethiopian',
coachType: 'REGULAR_SEAT',
bedPosition: '',
adultCount: 2,
childCount: 1,
});
const testMutation = useMutation<FareTestResult>({
mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData),
});
const handleTest = () => {
testMutation.mutate();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Test Configuration: ${configuration.name}`} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Distance (km) *</label>
<input
type="number"
className="input"
value={testData.distanceKm}
onChange={(e) => setTestData({ ...testData, distanceKm: +e.target.value })}
/>
</div>
<div>
<label className="label">Nationality *</label>
<select
className="input"
value={testData.nationality}
onChange={(e) => setTestData({ ...testData, nationality: e.target.value })}
>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">International</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={testData.coachType}
onChange={(e) => setTestData({ ...testData, coachType: e.target.value })}
>
<option value="REGULAR_SEAT">Regular Seat</option>
<option value="ECONOMY_BED">Economy Bed</option>
<option value="VIP_BED">VIP Bed</option>
</select>
</div>
{(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && (
<div>
<label className="label">Bed Position</label>
<select
className="input"
value={testData.bedPosition}
onChange={(e) => setTestData({ ...testData, bedPosition: e.target.value })}
>
<option value="">Select position</option>
<option value="UPPER">Upper</option>
<option value="MIDDLE">Middle</option>
<option value="LOWER">Lower</option>
</select>
</div>
)}
<div>
<label className="label">Adults *</label>
<input
type="number"
min="1"
className="input"
value={testData.adultCount}
onChange={(e) => setTestData({ ...testData, adultCount: +e.target.value })}
/>
</div>
<div>
<label className="label">Children</label>
<input
type="number"
min="0"
className="input"
value={testData.childCount}
onChange={(e) => setTestData({ ...testData, childCount: +e.target.value })}
/>
</div>
</div>
<ActionButton
onClick={handleTest}
loading={testMutation.isPending}
className="w-full"
>
Calculate Fare
</ActionButton>
{testMutation.data && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="font-semibold text-green-900 dark:text-green-200 mb-3">Calculation Result</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Base Fare:</span>
<span className="font-mono">{(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between">
<span>Components:</span>
<span className="font-mono">{(testMutation.data.componentsTotal / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between font-semibold border-t pt-2">
<span>Total:</span>
<span className="font-mono">{(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB</span>
</div>
</div>
{testMutation.data.breakdown && (
<div className="mt-4">
<h5 className="font-medium mb-2">Calculation Breakdown:</h5>
<div className="space-y-1 text-xs">
{testMutation.data.breakdown.map((step: any, index: number) => (
<div key={index} className="flex justify-between">
<span>{step.description}</span>
<span className="font-mono">{(step.runningTotal / 100).toFixed(2)} ETB</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{testMutation.error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200 text-sm">
{(testMutation.error as any)?.response?.data?.message || 'Test failed'}
</div>
)}
</div>
</Modal>
);
}
// Create Configuration Form Modal
function ConfigurationFormModal({
isOpen,
onClose,
onSuccess
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create Configuration" size="xl">
<div className="p-8 text-center">
<h3 className="text-lg font-semibold mb-2">Configuration Form</h3>
<p className="text-muted-foreground mb-4">
This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing.
</p>
<ActionButton onClick={onSuccess} variant="secondary">
Close for Now
</ActionButton>
</div>
</Modal>
);
}

View File

@@ -33,7 +33,6 @@ export default function RoutesPage() {
queryKey: ['routes'],
queryFn: async () => {
const result = await routesApi.getAll();
console.log('Routes query result:', result);
return result;
},
});
@@ -71,7 +70,7 @@ export default function RoutesPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (!originStationId || !destinationStationId) {
alert('Please select origin and destination stations');
return;
@@ -112,9 +111,7 @@ export default function RoutesPage() {
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray,
};
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
if (editingRoute) {
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
} else {
@@ -189,8 +186,8 @@ export default function RoutesPage() {
{ key: 'code', label: 'Route Code', sortable: true },
{ key: 'name', label: 'Route Name', sortable: true },
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
{
key: 'active',
{
key: 'active',
label: 'Status',
render: (route: any) => (
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
@@ -220,15 +217,20 @@ export default function RoutesPage() {
if (routeStops.length >= 2) {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Last stop's distanceKm is already cumulative from origin
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
// Last stop's distanceKm is segment distance from previous stop, so accumulate
let cumulative = 0;
const allStops = routeStops.map((stop: any) => {
cumulative += stop.distanceKm || 0;
return { ...stop, _cumulative: cumulative };
});
setDestinationDistance(allStops[allStops.length - 1]._cumulative);
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: stop.distanceKm || 0,
distanceFromOrigin: allStops[idx + 1]._cumulative,
}));
setStops(middleStops);
}
@@ -392,7 +394,7 @@ export default function RoutesPage() {
)}
</div>
</div>
<div>
<label className="label">Description</label>
<textarea
@@ -441,7 +443,7 @@ export default function RoutesPage() {
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
</div>
<div className="space-y-2">
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
@@ -483,8 +485,8 @@ export default function RoutesPage() {
required
>
<option value="">Select Station</option>
{stations?.items?.filter((s: any) =>
s.id !== originStationId &&
{stations?.items?.filter((s: any) =>
s.id !== originStationId &&
s.id !== destinationStationId &&
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
).map((station: any) => (
@@ -561,7 +563,7 @@ export default function RoutesPage() {
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"

View File

@@ -49,7 +49,7 @@ export default function SchedulesPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean }>(
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string }>(
{ isOpen: false, item: null }
);
const [error, setError] = useState<string | null>(null);
@@ -149,6 +149,10 @@ export default function SchedulesPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
});
const bulkDeleteMutation = useMutation({
@@ -159,6 +163,10 @@ export default function SchedulesPage() {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setSelectedSchedules(new Set());
},
onError: (err: any) => {
const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules';
setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg }));
},
});
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
@@ -227,13 +235,18 @@ export default function SchedulesPage() {
};
const confirmDelete = async () => {
if (deleteConfirm.isBulk) {
const ids = deleteConfirm.item as string[];
await bulkDeleteMutation.mutateAsync(ids);
} else if (deleteConfirm.item) {
await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id);
setDeleteConfirm(prev => ({ ...prev, error: undefined }));
try {
if (deleteConfirm.isBulk) {
const ids = deleteConfirm.item as string[];
await bulkDeleteMutation.mutateAsync(ids);
} else if (deleteConfirm.item) {
await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
} catch {
// error is set by onError handler
}
setDeleteConfirm({ isOpen: false, item: null });
};
const handleEditClick = (schedule: Schedule) => {
@@ -531,7 +544,9 @@ export default function SchedulesPage() {
}
confirmText="Delete"
isDanger={true}
warning="This schedule may have bookings. Deleting it may impact these systems."
isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending}
error={deleteConfirm.error}
warning="Schedules with existing bookings cannot be deleted."
/>
<Modal

View File

@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react';
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
export default function SeatsPage() {
const [selectedSchedule, setSelectedSchedule] = useState('');
@@ -19,6 +19,8 @@ export default function SeatsPage() {
const [blockCoachReason, setBlockCoachReason] = useState('');
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceReason, setMaintenanceReason] = useState('');
const queryClient = useQueryClient();
const { data: schedulesData } = useQuery({
@@ -70,6 +72,22 @@ export default function SeatsPage() {
},
});
const maintenanceMutation = useMutation({
mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) =>
seatsApi.setMaintenance(seatId, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowMaintenanceModal(false);
setSelectedSeat(null);
setMaintenanceReason('');
},
});
const clearMaintenanceMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seatmap'] }),
});
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
@@ -132,6 +150,17 @@ export default function SeatsPage() {
}
};
const handleSetMaintenance = (seat: any) => {
setSelectedSeat(seat);
setShowMaintenanceModal(true);
};
const handleClearMaintenance = async (seat: any) => {
if (confirm('Clear maintenance status for this seat?')) {
await clearMaintenanceMutation.mutateAsync(seat.id);
}
};
const handleBlockCoach = (coach: any) => {
setSelectedCoach(coach);
setShowBlockCoachModal(true);
@@ -182,6 +211,7 @@ export default function SeatsPage() {
};
const getSeatStatus = (seat: any) => {
if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE';
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
if (seat.status === 'HELD') return 'HELD';
@@ -194,6 +224,7 @@ export default function SeatsPage() {
case 'BOOKED': return 'bg-red-500';
case 'HELD': return 'bg-yellow-500';
case 'BLOCKED': return 'bg-gray-500';
case 'UNDER_MAINTENANCE': return 'bg-orange-500';
default: return 'bg-gray-300';
}
};
@@ -265,6 +296,8 @@ export default function SeatsPage() {
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
hideNumber={true}
/>
))}
@@ -358,6 +391,8 @@ export default function SeatsPage() {
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
hideNumber={true}
/>
))}
@@ -378,6 +413,8 @@ export default function SeatsPage() {
handleRemoveSeat={handleRemoveSeat}
handleUnblock={handleUnblock}
handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
hideNumber={true}
/>
))}
@@ -532,6 +569,10 @@ export default function SeatsPage() {
<div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-orange-500"></div>
<span className="text-sm text-muted-foreground">Under Maintenance</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span>
@@ -782,6 +823,44 @@ export default function SeatsPage() {
</div>
</div>
</Modal>
<Modal
isOpen={showMaintenanceModal}
onClose={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
title="Set Seat Under Maintenance"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Set seat <strong>{selectedSeat?.seatNumber}</strong> to Under Maintenance
</p>
<div>
<label className="label">Reason *</label>
<textarea
className="input"
rows={3}
value={maintenanceReason}
onChange={(e) => setMaintenanceReason(e.target.value)}
placeholder="e.g., Seat mechanism broken, Upholstery replacement"
/>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
>
Cancel
</ActionButton>
<ActionButton
onClick={() => maintenanceMutation.mutate({ seatId: selectedSeat.id, reason: maintenanceReason })}
loading={maintenanceMutation.isPending}
disabled={!maintenanceReason.trim()}
>
Set Maintenance
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
@@ -798,6 +877,8 @@ interface SeatIconProps {
handleRemoveSeat: (seat: any) => void;
handleUnblock: (seat: any) => void;
handleUndoRemove: (seat: any) => void;
handleSetMaintenance: (seat: any) => void;
handleClearMaintenance: (seat: any) => void;
}
function SeatIcon({
@@ -812,6 +893,8 @@ function SeatIcon({
handleRemoveSeat,
handleUnblock,
handleUndoRemove,
handleSetMaintenance,
handleClearMaintenance,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -845,6 +928,8 @@ function SeatIcon({
const color = getSeatColor(status);
const canBlock = status === 'AVAILABLE';
const canUnblock = status === 'BLOCKED';
const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
return (
<div className="relative group flex flex-col items-center">
@@ -872,7 +957,7 @@ function SeatIcon({
</div>
)}
{(canBlock || canUnblock) && (
{(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
{canBlock && (
<>
@@ -901,6 +986,24 @@ function SeatIcon({
<Unlock className="h-3 w-3 text-gray-700" />
</button>
)}
{canMaintenance && (
<button
onClick={() => handleSetMaintenance(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Set under maintenance"
>
<Wrench className="h-3 w-3 text-orange-600" />
</button>
)}
{canClearMaintenance && (
<button
onClick={() => handleClearMaintenance(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Clear maintenance"
>
<Unlock className="h-3 w-3 text-orange-600" />
</button>
)}
</div>
)}
</div>

View File

@@ -31,7 +31,6 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
alert('Train created successfully');
},
onError: (error: any) => {
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
@@ -44,7 +43,6 @@ export default function TrainsPage() {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
alert('Train updated successfully');
},
onError: (error: any) => {
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
@@ -55,7 +53,6 @@ export default function TrainsPage() {
mutationFn: (id: string) => fleetApi.deleteTrain(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
alert('Train deleted successfully');
},
});
@@ -63,7 +60,6 @@ export default function TrainsPage() {
mutationFn: (id: string) => fleetApi.restoreTrain(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
alert('Train restored successfully');
},
onError: (error: any) => {
alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error'));

View File

@@ -151,6 +151,8 @@ export const seatsApi = {
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
};
// Payments API

View File

@@ -512,6 +512,8 @@ export default function PassengersPage() {
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
const [saving, setSaving] = useState(false);
const [formInitialized, setFormInitialized] = useState(false);
const [faydaParams, setFaydaParams] = useState<{ code: string; state: string } | null>(null);
const [faydaCompleting, setFaydaCompleting] = useState(false);
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
@@ -576,6 +578,54 @@ export default function PassengersPage() {
checkFaydaStatus();
}, []);
// Read Fayda callback params from the URL on mount
useEffect(() => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (code && state) setFaydaParams({ code, state });
}, []);
// Complete Fayda verification once the form is ready and callback params are present
useEffect(() => {
if (!faydaParams || !formInitialized) return;
const complete = async () => {
setFaydaCompleting(true);
try {
const response: any = await apiClient.get(
`/fayda/verification/complete?code=${encodeURIComponent(faydaParams.code)}&state=${encodeURIComponent(faydaParams.state)}`
);
if (response?.success && response?.data?.verified) {
const d = response.data;
// Convert "1980/12/01" → "1980-12-01"
const dob = d.birthdate ? (d.birthdate as string).replace(/\//g, '-') : '';
setValue('passengers.0.name', d.fullName || '', { shouldValidate: true });
if (dob) setValue('passengers.0.dateOfBirth', dob, { shouldValidate: true });
if (d.email) setValue('passengers.0.email', d.email, { shouldValidate: true });
if (d.phoneNumber) setValue('passengers.0.phone', d.phoneNumber, { shouldValidate: true });
setValue('passengers.0.faydaVerified', true);
setValue('passengers.0.formExpanded', true);
setVerificationStatus((prev) => ({ ...prev, 0: 'success' }));
// Remove code/state from the URL so a refresh doesn't re-trigger
router.replace('/booking/passengers');
}
} catch (error) {
console.error('Failed to complete Fayda verification:', error);
} finally {
setFaydaCompleting(false);
setFaydaParams(null);
}
};
complete();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [faydaParams, formInitialized]);
useEffect(() => {
if (isAuthenticated && user?.faydaVerified) {
setVerificationStatus({ 0: 'success' });
@@ -752,13 +802,15 @@ export default function PassengersPage() {
if (!searchCriteria) return null;
if (!formInitialized) {
if (!formInitialized || faydaCompleting) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-lg mx-auto text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading passenger details...</p>
<p className="text-gray-600 dark:text-gray-400 mt-4">
{faydaCompleting ? 'Completing Fayda verification...' : 'Loading passenger details...'}
</p>
</div>
</div>
</div>

View File

@@ -259,20 +259,14 @@ export default function SeatsPage() {
})),
});
const coachesWithSeats = coaches.filter(
(c: any) => c.seats && c.seats.length > 0,
);
if (!currentSchedule?.selectedSeatClass) {
console.log(
"✅ No filter applied, returning all coaches:",
coachesWithSeats.length,
);
return coachesWithSeats;
}
const coachesWithSeats = coaches.filter((c: any) => {
// Bed coaches store occupants in rooms.beds, not seats
if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0);
return c.seats && c.seats.length > 0;
});
console.log(
"✅ No seat class filter - returning all coaches with seats:",
"✅ Returning all coaches with seats/beds:",
coachesWithSeats.length,
);
return coachesWithSeats;
@@ -333,6 +327,8 @@ export default function SeatsPage() {
return seatLabel && !seatLabel.startsWith("-");
});
const isBedCoach =
selectedCoachData?.isBedCoach === true ||
seats.some((s: any) => s.bedPosition) ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed");
@@ -1071,6 +1067,8 @@ export default function SeatsPage() {
const allSelected = selectedSeats.length === passengers.length;
const isBedCoach =
selectedCoachData?.isBedCoach === true ||
selectedCoachData?.rooms?.length > 0 ||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
selectedCoachData?.mode?.toLowerCase().includes("bed");
@@ -1314,6 +1312,8 @@ export default function SeatsPage() {
}
const isBed =
coach.isBedCoach === true ||
coach.rooms?.length > 0 ||
coach.seatClass?.toLowerCase().includes("bed") ||
coach.mode?.toLowerCase().includes("bed");

View File

@@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { LanguageSwitcher } from "./LanguageSwitcher";
export default function AppHeader() {
const [isOpen, setIsOpen] = useState(false);
@@ -72,9 +71,6 @@ export default function AppHeader() {
<HelpCircle className="w-5 h-5" />
</Link>
{/* Language Switcher */}
<LanguageSwitcher />
{/* Theme Toggler */}
<button
onClick={toggleTheme}

View File

@@ -0,0 +1,92 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import {
IsEnum,
IsIn,
IsInt,
IsOptional,
IsPositive,
IsString,
} from "class-validator";
import {
PaymentEventType,
PaymentReferenceType,
PaymentService,
ProviderMethod,
} from "@edr/types";
/**
* Body for the dev-only POST /test/payment-event endpoint. Every field is optional — the
* controller fills sensible defaults so an empty `{}` publishes a `payment.succeeded` to the
* passenger queue. Set `referenceId` to a real bookingId to exercise the consumer's side effects
* (seat confirm / ticket issue); leave it blank to only prove RabbitMQ delivery.
*/
export class TestPaymentEventDto {
@ApiPropertyOptional({
enum: ["payment.succeeded", "payment.failed"],
default: "payment.succeeded",
})
@IsOptional()
@IsIn(["payment.succeeded", "payment.failed"])
eventType?: PaymentEventType;
@ApiPropertyOptional({ enum: PaymentService, default: PaymentService.PASSENGER })
@IsOptional()
@IsEnum(PaymentService)
service?: PaymentService;
@ApiPropertyOptional({
enum: PaymentReferenceType,
default: PaymentReferenceType.BOOKING,
})
@IsOptional()
@IsEnum(PaymentReferenceType)
referenceType?: PaymentReferenceType;
@ApiPropertyOptional({
description: "Domain order id (e.g. bookingId). Defaults to a random uuid.",
})
@IsOptional()
@IsString()
referenceId?: string;
@ApiPropertyOptional({ description: "Defaults to a random uuid." })
@IsOptional()
@IsString()
intentId?: string;
@ApiPropertyOptional({ description: "Defaults to test-<random>." })
@IsOptional()
@IsString()
merchantOrderId?: string;
@ApiPropertyOptional({ enum: ProviderMethod, default: ProviderMethod.WAAFI })
@IsOptional()
@IsEnum(ProviderMethod)
provider?: ProviderMethod;
@ApiPropertyOptional({ default: 10000, description: "Amount in minor units." })
@IsOptional()
@IsInt()
@IsPositive()
amountMinor?: number;
@ApiPropertyOptional({ default: "ETB" })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: "Only used for payment.succeeded." })
@IsOptional()
@IsString()
providerTxnId?: string;
@ApiPropertyOptional({ description: "Only used for payment.failed." })
@IsOptional()
@IsString()
failureCode?: string;
@ApiPropertyOptional({ description: "Only used for payment.failed." })
@IsOptional()
@IsString()
failureMessage?: string;
}

View File

@@ -11,6 +11,12 @@ import { OutboxRepository } from "./outbox.repository";
import { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-payment-event-publisher";
import { TestEventsController } from "./test-events.controller";
// Dev-only harness to publish a synthetic payment event straight to the broker.
// Never registered in production, so the endpoint cannot exist there.
const testControllers =
process.env.NODE_ENV !== "production" ? [TestEventsController] : [];
const rabbitImports = isRabbitPublisher()
? [
@@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher()
HttpModule,
...rabbitImports,
],
controllers: testControllers,
providers: [
OutboxRepository,
OutboxRelayService,

View File

@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { Body, Controller, Inject, Logger, Post } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
PaymentEvent,
PaymentReferenceType,
PaymentService,
ProviderMethod,
paymentRoutingKey,
} from "@edr/types";
import {
PAYMENT_EVENT_PUBLISHER,
PaymentEventPublisher,
} from "./publisher/payment-event-publisher";
import { TestPaymentEventDto } from "./dto/test-payment-event.dto";
/**
* DEV-ONLY test harness. Publishes a synthetic payment event through the real
* PaymentEventPublisher (RabbitMQ in dev), so the passenger/freight consumer receives it
* exactly as in production — without creating an intent or going through a booking + provider
* flow. Registered only when NODE_ENV !== "production" (see OutboxModule); never reachable in prod.
*
* Quick check (no body): POST /test/payment-event -> publishes payment.passenger.succeeded.
* Real side effects: pass a real bookingId as `referenceId`.
*/
@ApiTags("Dev test (non-production)")
@Controller("test")
export class TestEventsController {
private readonly logger = new Logger(TestEventsController.name);
constructor(
@Inject(PAYMENT_EVENT_PUBLISHER)
private readonly publisher: PaymentEventPublisher,
) {}
@Post("payment-event")
@ApiOperation({
summary:
"DEV ONLY: publish a synthetic payment event to the broker (passenger/freight consumes it)",
description:
"Bypasses intents/booking. Empty body publishes a payment.succeeded for PASSENGER. " +
"Set referenceId to a real bookingId to trigger the consumer's seat/ticket side effects.",
})
async publishTestEvent(
@Body() dto: TestPaymentEventDto,
): Promise<{ published: true; routingKey: string; event: PaymentEvent }> {
const eventType = dto.eventType ?? "payment.succeeded";
const service = dto.service ?? PaymentService.PASSENGER;
const now = new Date().toISOString();
const base = {
version: 1 as const,
eventId: randomUUID(),
occurredAt: now,
service,
intentId: dto.intentId ?? randomUUID(),
referenceType: dto.referenceType ?? PaymentReferenceType.BOOKING,
referenceId: dto.referenceId ?? randomUUID(),
merchantOrderId: dto.merchantOrderId ?? `test-${randomUUID().slice(0, 8)}`,
provider: dto.provider ?? ProviderMethod.WAAFI,
amountMinor: dto.amountMinor ?? 10_000,
currency: dto.currency ?? "ETB",
};
const event: PaymentEvent =
eventType === "payment.failed"
? {
...base,
eventType: "payment.failed",
failureCode: dto.failureCode ?? "TEST_DECLINED",
failureMessage: dto.failureMessage ?? "Synthetic test failure",
}
: {
...base,
eventType: "payment.succeeded",
providerTxnId: dto.providerTxnId ?? `TEST-${randomUUID().slice(0, 8)}`,
paidAt: now,
};
await this.publisher.publish(event);
const routingKey = paymentRoutingKey(event.service, event.eventType);
this.logger.log(
`published TEST ${event.eventType} (${event.eventId}) ref=${event.referenceId} -> ${routingKey}`,
);
return { published: true, routingKey, event };
}
}

View File

@@ -10,9 +10,12 @@ export class DMoneyWebhookService {
) {}
async handle(payload: DMoneyWebhookPayload): Promise<void> {
const signatureValid = this.provider.verifyWebhookSignature(
payload as unknown as Record<string, unknown>,
);
// TODO: re-enable D-Money public-key signature verification — skipped for now.
// D-Money's onboarding pack only provides the merchant keypair (3072-bit); callbacks
// are signed with their separate platform notification key (4096-bit), which we don't
// have yet, so verifyWebhookSignature can never pass. Re-enable once that key is supplied.
const signatureValid = true;
const mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
const providerTxnId = payload.transId ?? payload.payment_order_id;

View File

@@ -20,7 +20,6 @@ services:
- apps/edr-freight-api/.env
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
passenger-api:
build:
context: .
@@ -31,7 +30,6 @@ services:
- apps/edr-passenger-api/.env
extra_hosts:
- "paymentcallback.triaplc.com:10.18.7.179"
freight-portal:
build:
context: .
@@ -39,14 +37,13 @@ services:
args:
TURBO_FILTER: "@edr/freight-portal"
APP_PATH: apps/edr-freight-web/portal
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
VITE_API_URL: ${VITE_API_URL:-}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
secrets:
- npmrc
ports:
- "${FREIGHT_PORTAL_PORT:-5173}:80"
freight-backoffice:
build:
context: .
@@ -54,14 +51,13 @@ services:
args:
TURBO_FILTER: "@edr/freight-backoffice"
APP_PATH: apps/edr-freight-web/backoffice
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
VITE_API_URL: ${VITE_API_URL:-}
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
secrets:
- npmrc
ports:
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
passenger-portal:
build:
context: .
@@ -69,14 +65,13 @@ services:
args:
APP_PACKAGE: "@edr/passenger-portal"
APP_PATH: apps/edr-passenger-web/portal
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
secrets:
- npmrc
ports:
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
env_file:
- apps/edr-passenger-web/portal/.env
passenger-backoffice:
build:
context: .
@@ -84,14 +79,14 @@ services:
args:
APP_PACKAGE: "@edr/passenger-backoffice"
APP_PATH: apps/edr-passenger-web/backoffice
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:4000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
secrets:
- npmrc
ports:
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
env_file:
- apps/edr-passenger-web/backoffice/.env
payment-api:
build:
context: .
@@ -105,7 +100,6 @@ services:
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
env_file:
- apps/edr-payment-api/.env
secrets:
npmrc:
file: .npmrc

View File

@@ -10,12 +10,10 @@
# --build-arg PORT=5174 \
# -f infrastructure/docker/Dockerfile.passenger-web .
#
ARG APP_PACKAGE=@edr/passenger-portal
ARG APP_PATH=apps/edr-passenger-web/portal
ARG PORT=5174
ARG NEXT_PUBLIC_API_URL
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
@@ -24,34 +22,35 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS pruner
ARG APP_PACKAGE
COPY . .
RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker
FROM base AS installer
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
--mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
FROM base AS builder
ARG APP_PACKAGE
ARG APP_PATH
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \
echo "ERROR: NEXT_PUBLIC_API_URL must be set" && \
exit 1; \
fi
COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter="${APP_PACKAGE}..."
FROM base AS deployer
ARG APP_PACKAGE
COPY --from=builder /app/ .
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
ARG APP_PATH
ARG PORT=5174

View File

@@ -2,17 +2,9 @@
ARG TURBO_FILTER=@edr/freight-portal
ARG APP_PATH=apps/edr-freight-web/portal
ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api
ARG VITE_BASE_API_URL=https://edrfreightapi.triaplc.com
ARG VITE_USER_MANAGEMENT_BASE=/_um
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
@@ -39,6 +31,12 @@ ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \
exit 1; \
fi
COPY --from=installer /app/ .
COPY --from=pruner /app/out/full/ .

View File

@@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider {
): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildPreOrderRequest(input);
this.logger.log(
`D-Money preOrder send request merchOrderId=${input.merchantOrderId} body=${JSON.stringify(this.sanitize(requestBody))}`,
);
const response = await this.postJson<DMoneyPreOrderResponse>(
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
requestBody,
@@ -208,7 +211,7 @@ export class DMoneyProvider implements PaymentProvider {
merch_order_id: input.merchantOrderId,
trade_type: "WebCheckout" as const,
business_type: "OnlineMerchant" as const,
title: `${input.orderRef}`,
title: "EDR booking payment",
total_amount: totalAmount,
// Charge the currency the caller already converted to; never relabel it provider-side.
trans_currency: input.currency,

1020
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Sync .env files from the self-hosted runner filesystem into the repo.
# Jenkins variant — exports variables as KEY=VALUE lines into $CI_ENV_FILE,
# which the Jenkinsfile loads with readProperties + withEnv. Jenkins has no
# equivalent of GitHub Actions' $GITHUB_ENV, and each `sh` step runs in its
# own process, so this file is the hand-off point between stages.
#
# Usage:
# PROJECT=edr-freight BRANCH=main CI_ENV_FILE=/tmp/passenger-api.env \
# ./scripts/deploy/sync-env-from-server-jenkins.sh passenger-api
#
# Server layout (one file per service):
# /home/user/environmen/<project>/<branch-slug>/freight-api.env
# /home/user/environmen/<project>/<branch-slug>/freight-portal.env
set -euo pipefail
DEPLOY_USER="${DEPLOY_USER:-tria}"
BRANCH="${BRANCH:?BRANCH is required}"
BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}"
ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/edr/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}"
CI_ENV_FILE="${CI_ENV_FILE:?CI_ENV_FILE is required (e.g. \${WORKSPACE}/.ci-env/<service>.env)}"
if [[ ! -d "${ENV_ROOT}" ]]; then
echo "Environment directory not found: ${ENV_ROOT}" >&2
exit 1
fi
echo "Using environment directory: ${ENV_ROOT}"
mkdir -p "$(dirname "${CI_ENV_FILE}")"
: > "${CI_ENV_FILE}"
declare -A SERVICE_ENV_TARGET=(
["freight-api"]="apps/edr-freight-api/.env"
["freight-portal"]="apps/edr-freight-web/portal/.env"
["freight-backoffice"]="apps/edr-freight-web/backoffice/.env"
["passenger-api"]="apps/edr-passenger-api/.env"
["passenger-portal"]="apps/edr-passenger-web/portal/.env"
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment-api"]="apps/edr-payment-api/.env"
)
for service in "$@"; do
src="${ENV_ROOT}/${service}.env"
dest="${SERVICE_ENV_TARGET[${service}]:-}"
if [[ -z "${dest}" ]]; then
echo "Unknown service: ${service}" >&2
exit 1
fi
if [[ ! -f "${src}" ]]; then
echo "Missing env file: ${src}" >&2
exit 1
fi
mkdir -p "$(dirname "${dest}")"
cp "${src}" "${dest}"
echo "Synced ${src} -> ${dest}"
port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]')
if [[ -z "${port_value}" ]]; then
echo "Missing required PORT in env file: ${src}" >&2
exit 1
fi
service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_')
echo "${service_var}_PORT=${port_value}" >> "${CI_ENV_FILE}"
echo "Exported ${service_var}_PORT from ${src}"
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
| sed -E 's/^[[:space:]]*//' >> "${CI_ENV_FILE}" || true
done

View File

@@ -7,7 +7,6 @@
# Server layout (one file per service):
# /home/user/environmen/<project>/<branch-slug>/freight-api.env
# /home/user/environmen/<project>/<branch-slug>/freight-portal.env
# /home/user/environmen/<project>/<branch-slug>/freight-web.build.env (optional, exports VITE_API_URL etc.)
set -euo pipefail
@@ -62,26 +61,8 @@ for service in "$@"; do
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
echo "Exported ${service_var}_PORT from ${src}"
# Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${src}" \
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
fi
done
# Optional build-time variables (VITE_API_URL, etc.)
# Set BUILD_ENV_FILE=freight-web.build.env or passenger-web.build.env per workflow.
build_env_file="${BUILD_ENV_FILE:-web.build.env}"
build_env="${ENV_ROOT}/${build_env_file}"
if [[ -f "${build_env}" ]]; then
echo "Loading build variables from ${build_env}"
set -a
# shellcheck disable=SC1090
source "${build_env}"
set +a
if [[ -n "${GITHUB_ENV:-}" ]]; then
grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \
| sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}"
echo "Wrote build variables to GITHUB_ENV"
fi
fi
done