mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/invoice
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -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$"
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
10
apps/edr-freight-api/src/scripts/cmds/index.ts
Normal file
10
apps/edr-freight-api/src/scripts/cmds/index.ts
Normal 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);
|
||||
}
|
||||
358
apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts
Normal file
358
apps/edr-freight-api/src/scripts/cmds/seed-test-contracts.cmd.ts
Normal 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;
|
||||
}
|
||||
298
apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts
Normal file
298
apps/edr-freight-api/src/scripts/cmds/seed-test-schedules.cmd.ts
Normal 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;
|
||||
}
|
||||
6
apps/edr-freight-api/src/scripts/cmds/types.ts
Normal file
6
apps/edr-freight-api/src/scripts/cmds/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type Vorpal from "vorpal";
|
||||
import type { INestApplicationContext } from "@nestjs/common";
|
||||
|
||||
export type CommandContext = {
|
||||
app: INestApplicationContext;
|
||||
};
|
||||
36
apps/edr-freight-api/src/scripts/main.ts
Normal file
36
apps/edr-freight-api/src/scripts/main.ts
Normal 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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 (<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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/ .
|
||||
|
||||
|
||||
@@ -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
1020
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
74
scripts/deploy/sync-env-from-server-jenkins.sh
Normal file
74
scripts/deploy/sync-env-from-server-jenkins.sh
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user