mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +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=()
|
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$"
|
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:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
@@ -84,13 +85,15 @@
|
|||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/pg": "^8.6.7",
|
"@types/pg": "^8.6.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/vorpal": "^1.12.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4",
|
||||||
|
"vorpal": "^1.12.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"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';
|
// export const API_BASE_URL = 'http://localhost:3001';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
* 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
|
* 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
|
* 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> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
if (context.getType() !== 'http') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
|
const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
|
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT),
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export interface FaydaConfig {
|
|||||||
authorizationEndpoint: string;
|
authorizationEndpoint: string;
|
||||||
tokenEndpoint: string;
|
tokenEndpoint: string;
|
||||||
userInfoEndpoint: string;
|
userInfoEndpoint: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
|
||||||
|
webRedirectUri: string;
|
||||||
privateJwk: FaydaJwk;
|
privateJwk: FaydaJwk;
|
||||||
scope: string;
|
scope: string;
|
||||||
acrValues: string;
|
acrValues: string;
|
||||||
@@ -73,6 +76,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||||
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||||
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||||
|
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -81,6 +85,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
||||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||||
redirectUri,
|
redirectUri,
|
||||||
|
webRedirectUri,
|
||||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
scope,
|
scope,
|
||||||
acrValues,
|
acrValues,
|
||||||
@@ -111,6 +116,7 @@ export default registerAs('fayda', (): FaydaConfig => {
|
|||||||
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
||||||
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
|
webRedirectUri,
|
||||||
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
scope,
|
scope,
|
||||||
acrValues,
|
acrValues,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString } from 'class-validator';
|
import { IsString, IsArray, ValidateNested, IsOptional, IsInt, IsEnum, IsDateString, MaxDate } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type, Transform } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Currency, IdDocumentType } from '@prisma/client';
|
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: '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;
|
@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: '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;
|
@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: '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;
|
@ApiPropertyOptional({ example: 'P1234567', description: 'Passport number for non-Ethiopian passengers (no verification)' }) @IsOptional() @IsString() passportNumber?: string;
|
||||||
@@ -38,9 +42,12 @@ export class RoundTripPassengerDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: '1990-05-15',
|
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)'
|
description: 'Date of birth (YYYY-MM-DD). Must not be a future date.'
|
||||||
})
|
})
|
||||||
@IsDateString() dateOfBirth: string;
|
@IsDateString()
|
||||||
|
@Transform(({ value }) => value)
|
||||||
|
@MaxDate(() => new Date(), { message: 'Date of birth cannot be in the future' })
|
||||||
|
dateOfBirth: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'NATIONAL_ID',
|
example: 'NATIONAL_ID',
|
||||||
|
|||||||
@@ -206,6 +206,13 @@ export class FleetController {
|
|||||||
return this.service.listCoaches(dto);
|
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')
|
@Get('coaches/:id')
|
||||||
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
@ApiOperation({ summary: 'Get single coach with seat layout' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
import { IsString, IsInt, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional, PartialType, OmitType } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class CreateTrainDto {
|
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: 'EDR', description: 'Operator ID (defaults to op_edr)' }) @IsOptional() @IsString() operatorId?: string;
|
||||||
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
|
@ApiPropertyOptional({ example: 'Ethiopian-Djibouti Railway' }) @IsOptional() @IsString() operatorName?: string;
|
||||||
@ApiPropertyOptional({ example: 'Addis-Djibouti Express' }) @IsOptional() @IsString() description?: 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 {
|
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() {
|
async getAnalytics() {
|
||||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||||
this.prisma.train.count(),
|
this.prisma.train.count(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||||
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import {
|
import {
|
||||||
PAYMENT_EVENTS_DLX,
|
PAYMENT_EVENTS_DLX,
|
||||||
PAYMENT_EVENTS_EXCHANGE,
|
PAYMENT_EVENTS_EXCHANGE,
|
||||||
@@ -19,6 +20,7 @@ export class PaymentEventsConsumer {
|
|||||||
|
|
||||||
constructor(private readonly paymentsService: PaymentsService) {}
|
constructor(private readonly paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
|
@IsPublic()
|
||||||
@RabbitSubscribe({
|
@RabbitSubscribe({
|
||||||
exchange: PAYMENT_EVENTS_EXCHANGE,
|
exchange: PAYMENT_EVENTS_EXCHANGE,
|
||||||
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
|
routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.*
|
||||||
@@ -29,6 +31,11 @@ export class PaymentEventsConsumer {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
async handle(event: PaymentEvent): Promise<Nack | void> {
|
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 {
|
try {
|
||||||
const result = await this.paymentsService.handlePaymentEvent(
|
const result = await this.paymentsService.handlePaymentEvent(
|
||||||
event as unknown as PaymentEventDto,
|
event as unknown as PaymentEventDto,
|
||||||
|
|||||||
@@ -42,4 +42,5 @@ export class UpdateRouteDto {
|
|||||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
@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) {
|
async updateRoute(id: string, dto: UpdateRouteDto) {
|
||||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||||
if (!route) throw new NotFoundException('Route not found');
|
if (!route) throw new NotFoundException('Route not found');
|
||||||
return this.prisma.route.update({
|
|
||||||
|
await this.prisma.route.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
@@ -89,6 +90,22 @@ export class RoutesService {
|
|||||||
active: dto.active,
|
active: dto.active,
|
||||||
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
|
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' } } },
|
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,6 +145,7 @@ export class RoutesService {
|
|||||||
|
|
||||||
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
|
const station = await this.prisma.station.findUnique({ where: { id: dto.stationId } });
|
||||||
if (!station) throw new NotFoundException(`Station ${dto.stationId} not found`);
|
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({
|
const existing = await this.prisma.routeStop.findUnique({
|
||||||
where: { routeId_sequence: { routeId, sequence: dto.sequence } },
|
where: { routeId_sequence: { routeId, sequence: dto.sequence } },
|
||||||
|
|||||||
@@ -9,6 +9,30 @@ import { Currency } from '@prisma/client';
|
|||||||
|
|
||||||
const POINTS_TO_MINOR = 10;
|
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()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -124,7 +148,7 @@ export class SearchService {
|
|||||||
if (windowStart < now) windowStart.setTime(now.getTime());
|
if (windowStart < now) windowStart.setTime(now.getTime());
|
||||||
|
|
||||||
const windowEnd = new Date(requestedDate);
|
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);
|
const totalPassengers = adultCount + (childCount ?? 0);
|
||||||
|
|
||||||
@@ -139,30 +163,16 @@ export class SearchService {
|
|||||||
],
|
],
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: 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 } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { departureAt: 'asc' },
|
orderBy: { departureAt: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: any[] = [];
|
const results = await Promise.all(
|
||||||
for (const schedule of schedules) {
|
schedules.map(schedule =>
|
||||||
const result = await this.buildScheduleResult(
|
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||||
schedule,
|
)
|
||||||
originStationId,
|
);
|
||||||
destinationStationId,
|
return results.filter(Boolean);
|
||||||
totalPassengers,
|
|
||||||
nationality,
|
|
||||||
);
|
|
||||||
if (result) results.push(result);
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchSchedules(
|
private async searchSchedules(
|
||||||
@@ -185,29 +195,18 @@ export class SearchService {
|
|||||||
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
departureAt: { gte: date < now ? now : date, lt: nextDay },
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
},
|
},
|
||||||
include: {
|
include: 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 } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const results: any[] = [];
|
const results = await Promise.all(
|
||||||
for (const schedule of schedules) {
|
schedules.map(schedule =>
|
||||||
const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality);
|
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
|
||||||
if (result) results.push(result);
|
)
|
||||||
}
|
);
|
||||||
return results;
|
return results.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Transit search ─────────────────────────────────────────────────────────
|
// ── 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 MIN_CONNECTION_MINUTES = 30;
|
||||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||||
|
|
||||||
@@ -219,82 +218,67 @@ export class SearchService {
|
|||||||
childCount?: number,
|
childCount?: number,
|
||||||
nationality?: string,
|
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 [y, m, d] = dateStr.split('-').map(Number);
|
||||||
const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0);
|
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 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);
|
const totalPassengers = adultCount + (childCount ?? 0);
|
||||||
|
|
||||||
// Load all schedules on this date that pass through origin
|
// Load leg1 and all potential leg2 candidates in one parallel round-trip
|
||||||
const leg1Schedules = await this.prisma.trainSchedule.findMany({
|
// instead of firing a separate DB query per transit stop.
|
||||||
where: {
|
const [leg1Schedules, allCandidates] = await Promise.all([
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
this.prisma.trainSchedule.findMany({
|
||||||
departureAt: { gte: dayStart, lt: dayEnd },
|
where: {
|
||||||
stopTimes: { some: { stationId: originStationId } },
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||||
},
|
departureAt: { gte: dayStart, lt: dayEnd },
|
||||||
include: {
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
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,
|
||||||
});
|
}),
|
||||||
|
this.prisma.trainSchedule.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['SCHEDULED', 'BOARDING'] },
|
||||||
|
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||||
|
},
|
||||||
|
include: SCHEDULE_INCLUDE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
for (const leg1 of leg1Schedules) {
|
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
|
||||||
const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId);
|
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
|
||||||
if (!originStop) continue;
|
if (!originStop) continue;
|
||||||
|
|
||||||
// Every stop after origin on leg1 is a candidate transit station
|
|
||||||
const candidateTransitStops = leg1.stopTimes.filter(
|
const candidateTransitStops = leg1.stopTimes.filter(
|
||||||
(s: any) => s.sequence > originStop.sequence,
|
s => s.sequence > originStop.sequence,
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const transitStop of candidateTransitStops) {
|
for (const transitStop of candidateTransitStops) {
|
||||||
// leg1 must NOT already contain the final destination
|
const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId);
|
||||||
const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId);
|
if (leg1HasDest) continue;
|
||||||
if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules
|
|
||||||
|
|
||||||
const transitStationId = transitStop.stationId;
|
const transitStationId = transitStop.stationId;
|
||||||
const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt;
|
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 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 connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000);
|
||||||
|
|
||||||
const leg2Schedules = await this.prisma.trainSchedule.findMany({
|
// Filter from pre-loaded candidates in memory — no extra DB query
|
||||||
where: {
|
const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => {
|
||||||
status: { in: ['SCHEDULED', 'BOARDING'] },
|
const dep = new Date(s.departureAt).getTime();
|
||||||
departureAt: { gte: connWindowStart, lte: connWindowEnd },
|
return dep >= connWindowStart.getTime()
|
||||||
stopTimes: { some: { stationId: transitStationId } },
|
&& dep <= connWindowEnd.getTime()
|
||||||
},
|
&& s.stopTimes.some(st => st.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 } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const leg2 of leg2Schedules) {
|
for (const leg2 of leg2Schedules) {
|
||||||
const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId);
|
const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId);
|
||||||
const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId);
|
||||||
|
|
||||||
if (!leg2TransitStop || !leg2DestStop) continue;
|
if (!leg2TransitStop || !leg2DestStop) continue;
|
||||||
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
|
if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;
|
||||||
|
|
||||||
// Build individual leg result objects (reuse existing per-schedule logic)
|
|
||||||
const [leg1Result, leg2Result] = await Promise.all([
|
const [leg1Result, leg2Result] = await Promise.all([
|
||||||
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
|
this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality),
|
||||||
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
|
this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality),
|
||||||
@@ -326,7 +310,6 @@ export class SearchService {
|
|||||||
displayCurrency,
|
displayCurrency,
|
||||||
combinedMinFareMinor,
|
combinedMinFareMinor,
|
||||||
combinedMinFareDisplay,
|
combinedMinFareDisplay,
|
||||||
// Convenience top-level fields so round-trip filter can read them uniformly
|
|
||||||
departureAt: leg1Result.departureAt,
|
departureAt: leg1Result.departureAt,
|
||||||
arrivalAt: leg2Result.arrivalAt,
|
arrivalAt: leg2Result.arrivalAt,
|
||||||
totalDurationMinutes:
|
totalDurationMinutes:
|
||||||
@@ -339,19 +322,37 @@ export class SearchService {
|
|||||||
return results;
|
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(
|
private async buildScheduleResult(
|
||||||
schedule: any,
|
schedule: ScheduleWithIncludes,
|
||||||
originStationId: string,
|
originStationId: string,
|
||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
totalPassengers: number,
|
totalPassengers: number,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
) {
|
) {
|
||||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId);
|
const originStop = schedule.stopTimes.find(s => s.stationId === originStationId);
|
||||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId);
|
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
|
||||||
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
|
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> = {};
|
const availabilityByClass: Record<string, number> = {};
|
||||||
for (const assignment of schedule.coachAssignments) {
|
for (const assignment of schedule.coachAssignments) {
|
||||||
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard'];
|
||||||
@@ -362,8 +363,7 @@ export class SearchService {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
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 (freeSeats.has(seat.id)) count++;
|
||||||
if (free) count++;
|
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition));
|
||||||
@@ -374,15 +374,13 @@ export class SearchService {
|
|||||||
let available = 0;
|
let available = 0;
|
||||||
for (const seat of assignment.coach.seats) {
|
for (const seat of assignment.coach.seats) {
|
||||||
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue;
|
||||||
const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence);
|
if (freeSeats.has(seat.id)) available++;
|
||||||
if (free) available++;
|
|
||||||
}
|
}
|
||||||
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
|
for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality);
|
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
|
||||||
const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass);
|
|
||||||
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
|
||||||
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
|
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),
|
durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
stops: schedule.stopTimes
|
stops: schedule.stopTimes
|
||||||
.filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence)
|
.filter(st => 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 })),
|
.map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })),
|
||||||
availabilityByClass,
|
availabilityByClass,
|
||||||
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers),
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
@@ -500,46 +498,31 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async calculateFaresForSegment(
|
private async calculateFaresForSegment(
|
||||||
schedule: any,
|
schedule: ScheduleWithIncludes,
|
||||||
originStationId: string,
|
originStationId: string,
|
||||||
destinationStationId: string,
|
destinationStationId: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||||
|
|
||||||
const seatClassIds: string[] = Array.from(
|
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
|
||||||
new Set(
|
const seatClassMap = new Map<string, any>();
|
||||||
schedule.coachAssignments
|
for (const a of schedule.coachAssignments) {
|
||||||
.flatMap((a: any) => a.coach.coachType?.seatClasses || [])
|
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
||||||
.map((sc: any) => sc.id)
|
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
|
||||||
.filter((id: any) => id)
|
}
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (seatClassIds.length === 0) {
|
|
||||||
console.log(`No seat classes assigned to schedule ${schedule.id}`);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
const seatClasses = Array.from(seatClassMap.values())
|
||||||
|
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
const seatClasses = await this.prisma.seatClass.findMany({
|
if (seatClasses.length === 0) return [];
|
||||||
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 (schedule.routeId) {
|
if (schedule.routeId) {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
seatClasses.map(async (sc) => {
|
seatClasses.map(async (sc) => {
|
||||||
try {
|
try {
|
||||||
const fare = await this.fareEngine.calculate({
|
const fare = await this.fareEngine.calculate({
|
||||||
routeId: schedule.routeId,
|
routeId: schedule.routeId!,
|
||||||
originStationId,
|
originStationId,
|
||||||
destinationStationId,
|
destinationStationId,
|
||||||
seatClassId: sc.id,
|
seatClassId: sc.id,
|
||||||
@@ -552,8 +535,7 @@ export class SearchService {
|
|||||||
displayCurrency: fare.billingCurrency as Currency,
|
displayCurrency: fare.billingCurrency as Currency,
|
||||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -562,36 +544,32 @@ export class SearchService {
|
|||||||
const validResults = results.filter(
|
const validResults = results.filter(
|
||||||
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
(r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null,
|
||||||
);
|
);
|
||||||
if (validResults.length > 0) {
|
if (validResults.length > 0) return validResults;
|
||||||
return validResults;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } });
|
// Fallback: use station codes from already-loaded stopTimes when available
|
||||||
const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } });
|
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) {
|
if (originCode && destCode) {
|
||||||
const segmentRoute = `${originStation.code}-${destStation.code}`;
|
const segmentRoute = `${originCode}-${destCode}`;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const fareRules = await this.prisma.fareRule.findMany({
|
const fareRules = await this.prisma.fareRule.findMany({
|
||||||
where: {
|
where: {
|
||||||
route: segmentRoute,
|
route: segmentRoute,
|
||||||
seatClassId: { in: seatClassIds },
|
seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
|
||||||
validFrom: { lte: now },
|
validFrom: { lte: now },
|
||||||
OR: [
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
{ validUntil: null },
|
|
||||||
{ validUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fareRules.length > 0) {
|
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);
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||||
return fareRules.map(rule => ({
|
return fareRules.map(rule => ({
|
||||||
seatClassName: seatClassMap[rule.seatClassId] || 'Unknown',
|
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
||||||
baseFareMinor: rule.baseFareMinor,
|
baseFareMinor: rule.baseFareMinor,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildCoachTypeDetails(
|
// buildCoachTypeDetails is pure in-memory — no async needed
|
||||||
schedule: any,
|
private buildCoachTypeDetails(
|
||||||
|
schedule: ScheduleWithIncludes,
|
||||||
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
|
||||||
): Promise<Array<{
|
): Array<{
|
||||||
coachTypeId: string;
|
coachTypeId: string;
|
||||||
coachTypeName: string;
|
coachTypeName: string;
|
||||||
coachTypeCode: string;
|
coachTypeCode: string;
|
||||||
coachId: string;
|
coachId: string;
|
||||||
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
classes: Array<{ name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>;
|
||||||
}>> {
|
}> {
|
||||||
const coachTypeMap = new Map<
|
const coachTypeMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ coachType: any; classNames: Set<string>; coachId: string }
|
{ coachType: any; classNames: Set<string>; coachId: string }
|
||||||
@@ -682,14 +660,6 @@ export class SearchService {
|
|||||||
return fare.baseFarePerPassengerMinor;
|
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(
|
private selectBestFareRule(
|
||||||
candidates: any[],
|
candidates: any[],
|
||||||
scheduleId: string,
|
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);
|
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 ────────────────────────────────────────────────────────────
|
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||||
@Patch(":seatId/remove")
|
@Patch(":seatId/remove")
|
||||||
@UseGuards(IamGuard)
|
@UseGuards(IamGuard)
|
||||||
|
|||||||
@@ -742,6 +742,23 @@ export class SeatsService {
|
|||||||
return { unblocked: true, seatId };
|
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) {
|
async removeSeat(seatId: string) {
|
||||||
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
|
||||||
if (!seat) throw new NotFoundException('Seat not found');
|
if (!seat) throw new NotFoundException('Seat not found');
|
||||||
|
|||||||
@@ -146,6 +146,96 @@ export class SegmentsService {
|
|||||||
return true;
|
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 */
|
/** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */
|
||||||
async getOverlappingReservations(
|
async getOverlappingReservations(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class StartVerificationDto {
|
|||||||
enum: ['WEB', 'MOBILE'],
|
enum: ['WEB', 'MOBILE'],
|
||||||
default: 'WEB',
|
default: 'WEB',
|
||||||
description:
|
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()
|
@IsOptional()
|
||||||
@IsIn(['WEB', 'MOBILE'])
|
@IsIn(['WEB', 'MOBILE'])
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
|
|||||||
tokenEndpoint: 'https://esignet.test/token',
|
tokenEndpoint: 'https://esignet.test/token',
|
||||||
userInfoEndpoint: 'https://esignet.test/userinfo',
|
userInfoEndpoint: 'https://esignet.test/userinfo',
|
||||||
redirectUri: 'http://localhost:4000/fayda/verification/complete',
|
redirectUri: 'http://localhost:4000/fayda/verification/complete',
|
||||||
|
webRedirectUri: 'http://localhost:5174/fayda/verification/complete',
|
||||||
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
acrValues: 'mosip:idp:acr:generated-code',
|
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.origin + parsed.pathname).toBe('https://esignet.test/authorize');
|
||||||
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
|
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
|
||||||
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
|
||||||
|
// Default platform is WEB → webRedirectUri.
|
||||||
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
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);
|
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({});
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
||||||
|
|
||||||
const url = await service.startVerification({
|
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 () => {
|
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
|
||||||
const disabledService = new VerifaydaService(
|
const disabledService = new VerifaydaService(
|
||||||
buildConfigService(buildConfig({ enabled: false })),
|
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'}`,
|
`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(
|
const tokens = await this.exchangeCodeForTokens(
|
||||||
query.code,
|
query.code,
|
||||||
session.codeVerifier,
|
session.codeVerifier,
|
||||||
|
this.redirectUriForPlatform(session.platform as FaydaPlatform),
|
||||||
);
|
);
|
||||||
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
||||||
const normalized = this.normalizeUserInfo(userInfo);
|
const normalized = this.normalizeUserInfo(userInfo);
|
||||||
@@ -307,11 +319,12 @@ export class VerifaydaService {
|
|||||||
private buildAuthorizationUrl(args: {
|
private buildAuthorizationUrl(args: {
|
||||||
state: string;
|
state: string;
|
||||||
codeChallenge: string;
|
codeChallenge: string;
|
||||||
|
redirectUri: string;
|
||||||
}): string {
|
}): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: this.faydaConfig.clientId,
|
client_id: this.faydaConfig.clientId,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
redirect_uri: this.faydaConfig.redirectUri,
|
redirect_uri: args.redirectUri,
|
||||||
scope: this.faydaConfig.scope,
|
scope: this.faydaConfig.scope,
|
||||||
state: args.state,
|
state: args.state,
|
||||||
code_challenge: args.codeChallenge,
|
code_challenge: args.codeChallenge,
|
||||||
@@ -344,6 +357,7 @@ export class VerifaydaService {
|
|||||||
private async exchangeCodeForTokens(
|
private async exchangeCodeForTokens(
|
||||||
code: string,
|
code: string,
|
||||||
codeVerifier: string,
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
): Promise<FaydaTokenResponse> {
|
): Promise<FaydaTokenResponse> {
|
||||||
const clientAssertion = await generateClientAssertion({
|
const clientAssertion = await generateClientAssertion({
|
||||||
clientId: this.faydaConfig.clientId,
|
clientId: this.faydaConfig.clientId,
|
||||||
@@ -354,7 +368,7 @@ export class VerifaydaService {
|
|||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: this.faydaConfig.redirectUri,
|
redirect_uri: redirectUri,
|
||||||
client_id: this.faydaConfig.clientId,
|
client_id: this.faydaConfig.clientId,
|
||||||
client_assertion_type:
|
client_assertion_type:
|
||||||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||||
|
|||||||
@@ -131,9 +131,38 @@ export default function AuditLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
|
<div>
|
||||||
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
|
|||||||
@@ -14,32 +14,142 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
|
const [isInitializing, setIsInitializing] = useState(false);
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||||
const scanIntervalRef = useRef<number | null>(null);
|
const scanIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const startCamera = async () => {
|
const startCamera = async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsInitializing(true);
|
||||||
setCameraError(null);
|
setCameraError(null);
|
||||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
video: {
|
// Check if mediaDevices is supported
|
||||||
facingMode: 'environment', // Use back camera
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
width: { ideal: 1280 },
|
const errorMsg = 'Camera not supported in this browser. Please use a modern browser like Chrome, Firefox, or Safari.';
|
||||||
height: { ideal: 720 }
|
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) {
|
try {
|
||||||
videoRef.current.srcObject = mediaStream;
|
await video.play();
|
||||||
await videoRef.current.play();
|
} catch {
|
||||||
setStream(mediaStream);
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
setIsScanning(true);
|
try { await video.play(); } catch { /* continue */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set state to show video
|
||||||
|
setStream(mediaStream);
|
||||||
|
setIsScanning(true);
|
||||||
|
setIsInitializing(false);
|
||||||
|
|
||||||
} catch (error: any) {
|
} 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);
|
setCameraError(errorMsg);
|
||||||
onError(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);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to use jsqr if available
|
|
||||||
const jsQR = (window as any).jsQR;
|
const jsQR = (window as any).jsQR;
|
||||||
if (jsQR) {
|
if (jsQR) {
|
||||||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||||
inversionAttempts: 'dontInvert',
|
inversionAttempts: 'dontInvert',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (code) {
|
if (code) {
|
||||||
onScan(code.data);
|
onScan(code.data);
|
||||||
stopCamera();
|
stopCamera();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch { /* ignore scan errors */ }
|
||||||
console.error('QR scan error:', err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [isScanning, onScan, stopCamera]);
|
}, [isScanning, onScan, stopCamera]);
|
||||||
|
|
||||||
@@ -120,7 +226,43 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={startCamera}
|
onClick={startCamera}
|
||||||
@@ -135,36 +277,33 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isInitializing && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="relative bg-black rounded-xl overflow-hidden">
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-6">
|
||||||
<video
|
<div className="flex flex-col items-center justify-center space-y-3">
|
||||||
ref={videoRef}
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
autoPlay
|
<p className="text-blue-700 dark:text-blue-300 font-medium">Starting camera...</p>
|
||||||
playsInline
|
<p className="text-blue-600 dark:text-blue-400 text-sm text-center">
|
||||||
muted
|
Please allow camera access when prompted by your browser
|
||||||
className="w-full h-64 object-cover"
|
</p>
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<canvas ref={canvasRef} className="hidden" />
|
|
||||||
<button
|
<button
|
||||||
onClick={stopCamera}
|
onClick={() => {
|
||||||
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"
|
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" />
|
Cancel
|
||||||
Stop Camera
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<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">
|
<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>• 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>• For manual option, type or paste booking reference</li>
|
||||||
<li>• Tickets can only be boarded on their departure date</li>
|
<li>• Tickets can only be boarded on their departure date</li>
|
||||||
<li>• First scan boards outbound leg for round trips</li>
|
<li>• First scan boards outbound leg for round trips</li>
|
||||||
<li>• Email & SMS sent automatically to passenger contacts</li>
|
<li>• Email & SMS sent automatically to passenger contacts</li>
|
||||||
<li>• Red error shows validation issues</li>
|
<li>• Red error shows validation issues</li>
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { fleetApi, apiClient } from '@/lib/api';
|
import { fleetApi, apiClient } from '@/lib/api';
|
||||||
|
|
||||||
type Tab = 'types' | 'coaches';
|
type Tab = 'types' | 'coaches' | 'utilization';
|
||||||
|
|
||||||
const getBedLabel = (bedPosition: string | null): string => {
|
const getBedLabel = (bedPosition: string | null): string => {
|
||||||
if (bedPosition === 'upper') return 'U';
|
if (bedPosition === 'upper') return 'U';
|
||||||
@@ -163,6 +163,12 @@ export default function CoachesPage() {
|
|||||||
queryFn: () => fleetApi.getCoaches({}),
|
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
|
// Coach Type Mutations
|
||||||
const createCoachTypeMutation = useMutation({
|
const createCoachTypeMutation = useMutation({
|
||||||
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
||||||
@@ -547,6 +553,16 @@ export default function CoachesPage() {
|
|||||||
>
|
>
|
||||||
Coaches
|
Coaches
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Coach Types Tab */}
|
{/* Coach Types Tab */}
|
||||||
@@ -594,6 +610,44 @@ export default function CoachesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
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 StatCard from '@/components/dashboard/StatCard';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import { dashboardApi } from '@/lib/api/dashboard';
|
import { dashboardApi } from '@/lib/api/dashboard';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
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'];
|
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||||
|
|
||||||
@@ -18,7 +18,6 @@ const MOCK_STATS = {
|
|||||||
totalBookings: 1247,
|
totalBookings: 1247,
|
||||||
totalRevenue: 892450,
|
totalRevenue: 892450,
|
||||||
totalPassengers: 2156,
|
totalPassengers: 2156,
|
||||||
occupancyRate: 78
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const MOCK_RECENT_BOOKINGS = [
|
const MOCK_RECENT_BOOKINGS = [
|
||||||
@@ -50,12 +49,6 @@ function DashboardPageContent() {
|
|||||||
staleTime: 60000, // 1 minute
|
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[]>({
|
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
|
||||||
queryKey: ['recent-bookings'],
|
queryKey: ['recent-bookings'],
|
||||||
queryFn: () => dashboardApi.getRecentBookings(10),
|
queryFn: () => dashboardApi.getRecentBookings(10),
|
||||||
@@ -68,12 +61,6 @@ function DashboardPageContent() {
|
|||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
|
|
||||||
queryKey: ['occupancy-trend'],
|
|
||||||
queryFn: () => dashboardApi.getOccupancyTrend(7),
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
|
||||||
queryKey: ['upcoming-trips'],
|
queryKey: ['upcoming-trips'],
|
||||||
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
queryFn: () => dashboardApi.getUpcomingTrips(5),
|
||||||
@@ -170,7 +157,7 @@ function DashboardPageContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary Metrics */}
|
{/* 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
|
<StatCard
|
||||||
title="Total Bookings"
|
title="Total Bookings"
|
||||||
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
|
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
|
||||||
@@ -189,75 +176,6 @@ function DashboardPageContent() {
|
|||||||
icon={Users}
|
icon={Users}
|
||||||
color="purple"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Payment Methods Distribution */}
|
{/* Payment Methods Distribution */}
|
||||||
|
|||||||
@@ -2,187 +2,121 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
interface FareConfiguration {
|
export default function FareManagementPage() {
|
||||||
id: string;
|
const [filters, setFilters] = useState({ scheduleId: '' });
|
||||||
name: string;
|
const [showModal, setShowModal] = useState(false);
|
||||||
description?: string;
|
const [editingRule, setEditingRule] = useState<any>(null);
|
||||||
effective_date: string;
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; rule: any | null; error?: string }>({ isOpen: false, rule: null });
|
||||||
expiry_date?: string;
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
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 });
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Queries
|
const { data: fareRules, isLoading } = useQuery<any[]>({
|
||||||
const { data: configurations = [], isLoading: configsLoading } = useQuery<FareConfiguration[]>({
|
queryKey: ['fare-rules', filters],
|
||||||
queryKey: ['fare-configurations'],
|
queryFn: async () => {
|
||||||
queryFn: () => apiClient.get('/admin/fare-configurations'),
|
const params = new URLSearchParams();
|
||||||
});
|
if (filters.scheduleId) params.append('scheduleId', filters.scheduleId);
|
||||||
|
const res = await apiClient.get<any>(`/schedules/fares?${params}`);
|
||||||
const { data: systemStatus } = useQuery<SystemStatus>({
|
return Array.isArray(res) ? res : (res as any)?.items || (res as any)?.data || [];
|
||||||
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: 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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['fare-rules'] }); setDeleteConfirm({ isOpen: false, rule: null }); },
|
||||||
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
|
onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Failed to delete' })),
|
||||||
setDeleteConfirm({ isOpen: false, config: null });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleSystemMutation = useMutation({
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
mutationFn: (enabled: boolean) =>
|
e.preventDefault();
|
||||||
enabled
|
setFormError(null);
|
||||||
? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 })
|
const fd = new FormData(e.currentTarget);
|
||||||
: apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'),
|
const payload: any = {
|
||||||
onSuccess: () => {
|
seatClassId: fd.get('seatClassId') as string,
|
||||||
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
|
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({
|
if (editingRule) {
|
||||||
mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', {
|
await updateMutation.mutateAsync({ id: editingRule.id, data: payload });
|
||||||
activateNewFormula: true,
|
} else {
|
||||||
enableFeature: true
|
await createMutation.mutateAsync(payload);
|
||||||
}),
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = (config: FareConfiguration) => {
|
|
||||||
setSelectedConfig(config);
|
|
||||||
setShowTestModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'seatClass', label: 'Seat Class',
|
||||||
label: 'Configuration Name',
|
render: (r: any) => <span className="font-medium">{r.seatClass?.name || r.seatClassId}</span>,
|
||||||
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: 'status',
|
key: 'schedule', label: 'Schedule',
|
||||||
label: 'Status',
|
render: (r: any) => r.trip
|
||||||
render: (config: FareConfiguration) => (
|
? <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>
|
||||||
<div className="space-y-1">
|
: <span className="text-xs text-muted-foreground">All schedules</span>,
|
||||||
<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: 'rules',
|
key: 'passengerCategory', label: 'Category',
|
||||||
label: 'Rules Count',
|
render: (r: any) => r.passengerCategory
|
||||||
render: (config: FareConfiguration) => (
|
? <Badge variant="status" status={r.passengerCategory === 'ADULT' ? 'CONFIRMED' : 'INFO'}>{r.passengerCategory}</Badge>
|
||||||
<div className="text-sm">
|
: <span className="text-xs text-muted-foreground">All</span>,
|
||||||
<div>{config.rate_rules_count} rate rules</div>
|
|
||||||
<div>{config.components_count} components</div>
|
|
||||||
<div>{config.age_rules_count} age rules</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'dates',
|
key: 'nationality', label: 'Nationality',
|
||||||
label: 'Validity Period',
|
render: (r: any) => <span className="text-sm">{r.nationality || 'All'}</span>,
|
||||||
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: 'created_at',
|
key: 'baseFareMinor', label: 'Base Fare (ETB)',
|
||||||
label: 'Created',
|
render: (r: any) => <span className="font-mono">{(r.baseFareMinor / 100).toFixed(2)}</span>,
|
||||||
sortable: true,
|
},
|
||||||
render: (config: FareConfiguration) => (
|
{
|
||||||
<div className="text-sm">
|
key: 'validity', label: 'Validity',
|
||||||
<div>{new Date(config.created_at).toLocaleDateString()}</div>
|
render: (r: any) => (
|
||||||
{config.created_by && (
|
<div className="text-xs">
|
||||||
<div className="text-muted-foreground">by {config.created_by}</div>
|
<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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -190,24 +124,12 @@ export default function ConfigurableFarePage() {
|
|||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Activate',
|
label: 'Edit', icon: Edit, variant: 'secondary' as const,
|
||||||
onClick: handleActivate,
|
onClick: (r: any) => { setEditingRule(r); setFormError(null); setShowModal(true); },
|
||||||
variant: 'secondary' as const,
|
|
||||||
icon: Play,
|
|
||||||
show: (config: FareConfiguration) => !config.is_active,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Test',
|
label: 'Delete', icon: Trash2, variant: 'danger' as const,
|
||||||
onClick: handleTest,
|
onClick: (r: any) => setDeleteConfirm({ isOpen: true, rule: r }),
|
||||||
variant: 'secondary' as const,
|
|
||||||
icon: TestTube,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
onClick: handleDelete,
|
|
||||||
variant: 'danger' as const,
|
|
||||||
icon: Trash2,
|
|
||||||
show: (config: FareConfiguration) => !config.is_active,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -215,321 +137,107 @@ export default function ConfigurableFarePage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Configurable Fare Management</h1>
|
<h1 className="text-2xl font-bold">Fare Management</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground">Configure fare rules by seat class, passenger category, and nationality</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ActionButton icon={Plus} onClick={() => { setEditingRule(null); setFormError(null); setShowModal(true); }}>Add Fare Rule</ActionButton>
|
||||||
</div>
|
</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="card">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
<div>
|
<select className="input w-64" value={filters.scheduleId}
|
||||||
<h3 className="font-semibold">System Control</h3>
|
onChange={(e) => setFilters({ ...filters, scheduleId: e.target.value })}>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<option value="">All Schedules</option>
|
||||||
Enable or disable the configurable fare system globally
|
{schedules.map((s: any) => (
|
||||||
</p>
|
<option key={s.id} value={s.id}>
|
||||||
</div>
|
{s.originStation?.name} → {s.destinationStation?.name} ({s.departureAt ? new Date(s.departureAt).toLocaleDateString() : s.id.slice(0, 8)})
|
||||||
<div className="flex items-center gap-4">
|
</option>
|
||||||
<span className="text-sm">
|
))}
|
||||||
{systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'}
|
</select>
|
||||||
</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>
|
</div>
|
||||||
|
<DataTable data={fareRules || []} columns={columns} actions={actions} loading={isLoading} emptyMessage="No fare rules found" />
|
||||||
</div>
|
</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
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, config: null })}
|
onClose={() => setDeleteConfirm({ isOpen: false, rule: null })}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={() => deleteMutation.mutate(deleteConfirm.rule?.id)}
|
||||||
title="Delete Configuration"
|
title="Delete Fare Rule"
|
||||||
message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`}
|
message={`Delete fare rule for ${deleteConfirm.rule?.seatClass?.name || 'this class'}?`}
|
||||||
confirmText="Delete"
|
confirmText="Delete" isDanger isLoading={deleteMutation.isPending}
|
||||||
isDanger={true}
|
error={deleteConfirm.error}
|
||||||
isLoading={deleteMutation.isPending}
|
|
||||||
warning="Active configurations cannot be deleted. Deactivate first if needed."
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Test Modal */}
|
<Modal isOpen={showModal} onClose={() => { setShowModal(false); setEditingRule(null); }}
|
||||||
{showTestModal && selectedConfig && (
|
title={`${editingRule ? 'Edit' : 'Add'} Fare Rule`} size="lg">
|
||||||
<FareTestModal
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
configuration={selectedConfig}
|
{formError && (
|
||||||
isOpen={showTestModal}
|
<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>
|
||||||
onClose={() => {
|
)}
|
||||||
setShowTestModal(false);
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
setSelectedConfig(null);
|
<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) => (
|
||||||
{/* Create/Edit Modal */}
|
<option key={sc.id} value={sc.id}>{sc.name}</option>
|
||||||
{showCreateModal && (
|
))}
|
||||||
<ConfigurationFormModal
|
</select>
|
||||||
isOpen={showCreateModal}
|
</div>
|
||||||
onClose={() => setShowCreateModal(false)}
|
<div>
|
||||||
onSuccess={() => {
|
<label className="label">Schedule (optional)</label>
|
||||||
setShowCreateModal(false);
|
<select name="scheduleId" className="input" defaultValue={editingRule?.tripId || ''}>
|
||||||
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
|
<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>
|
</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'],
|
queryKey: ['routes'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const result = await routesApi.getAll();
|
const result = await routesApi.getAll();
|
||||||
console.log('Routes query result:', result);
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -71,7 +70,7 @@ export default function RoutesPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
||||||
if (!originStationId || !destinationStationId) {
|
if (!originStationId || !destinationStationId) {
|
||||||
alert('Please select origin and destination stations');
|
alert('Please select origin and destination stations');
|
||||||
return;
|
return;
|
||||||
@@ -112,9 +111,7 @@ export default function RoutesPage() {
|
|||||||
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
|
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
|
||||||
stops: stopsArray,
|
stops: stopsArray,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
|
|
||||||
|
|
||||||
if (editingRoute) {
|
if (editingRoute) {
|
||||||
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
|
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
|
||||||
} else {
|
} else {
|
||||||
@@ -189,8 +186,8 @@ export default function RoutesPage() {
|
|||||||
{ key: 'code', label: 'Route Code', sortable: true },
|
{ key: 'code', label: 'Route Code', sortable: true },
|
||||||
{ key: 'name', label: 'Route Name', sortable: true },
|
{ key: 'name', label: 'Route Name', sortable: true },
|
||||||
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
|
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
|
||||||
{
|
{
|
||||||
key: 'active',
|
key: 'active',
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
render: (route: any) => (
|
render: (route: any) => (
|
||||||
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
|
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
|
||||||
@@ -220,15 +217,20 @@ export default function RoutesPage() {
|
|||||||
if (routeStops.length >= 2) {
|
if (routeStops.length >= 2) {
|
||||||
setOriginStationId(routeStops[0].stationId);
|
setOriginStationId(routeStops[0].stationId);
|
||||||
setDestinationStationId(routeStops[routeStops.length - 1].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,
|
stationId: stop.stationId,
|
||||||
sequence: stop.sequence,
|
sequence: stop.sequence,
|
||||||
distanceKm: stop.distanceKm,
|
distanceKm: stop.distanceKm,
|
||||||
distanceFromOrigin: stop.distanceKm || 0,
|
distanceFromOrigin: allStops[idx + 1]._cumulative,
|
||||||
}));
|
}));
|
||||||
setStops(middleStops);
|
setStops(middleStops);
|
||||||
}
|
}
|
||||||
@@ -392,7 +394,7 @@ export default function RoutesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Description</label>
|
<label className="label">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -441,7 +443,7 @@ export default function RoutesPage() {
|
|||||||
<label className="label mb-0">Route Stops</label>
|
<label className="label mb-0">Route Stops</label>
|
||||||
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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 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">
|
<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
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select Station</option>
|
<option value="">Select Station</option>
|
||||||
{stations?.items?.filter((s: any) =>
|
{stations?.items?.filter((s: any) =>
|
||||||
s.id !== originStationId &&
|
s.id !== originStationId &&
|
||||||
s.id !== destinationStationId &&
|
s.id !== destinationStationId &&
|
||||||
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
|
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
|
||||||
).map((station: any) => (
|
).map((station: any) => (
|
||||||
@@ -561,7 +563,7 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function SchedulesPage() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||||
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
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 }
|
{ isOpen: false, item: null }
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -149,6 +149,10 @@ export default function SchedulesPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
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({
|
const bulkDeleteMutation = useMutation({
|
||||||
@@ -159,6 +163,10 @@ export default function SchedulesPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||||
setSelectedSchedules(new Set());
|
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>) => {
|
const handleBulkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -227,13 +235,18 @@ export default function SchedulesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
if (deleteConfirm.isBulk) {
|
setDeleteConfirm(prev => ({ ...prev, error: undefined }));
|
||||||
const ids = deleteConfirm.item as string[];
|
try {
|
||||||
await bulkDeleteMutation.mutateAsync(ids);
|
if (deleteConfirm.isBulk) {
|
||||||
} else if (deleteConfirm.item) {
|
const ids = deleteConfirm.item as string[];
|
||||||
await deleteScheduleMutation.mutateAsync(deleteConfirm.item.id);
|
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) => {
|
const handleEditClick = (schedule: Schedule) => {
|
||||||
@@ -531,7 +544,9 @@ export default function SchedulesPage() {
|
|||||||
}
|
}
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
isDanger={true}
|
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
|
<Modal
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
|
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton'
|
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() {
|
export default function SeatsPage() {
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||||
@@ -19,6 +19,8 @@ export default function SeatsPage() {
|
|||||||
const [blockCoachReason, setBlockCoachReason] = useState('');
|
const [blockCoachReason, setBlockCoachReason] = useState('');
|
||||||
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
|
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
|
||||||
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
||||||
|
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||||
|
const [maintenanceReason, setMaintenanceReason] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: schedulesData } = useQuery({
|
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 schedules = schedulesData?.items || schedulesData?.data || [];
|
||||||
const coaches = seatMapData?.coaches || [];
|
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) => {
|
const handleBlockCoach = (coach: any) => {
|
||||||
setSelectedCoach(coach);
|
setSelectedCoach(coach);
|
||||||
setShowBlockCoachModal(true);
|
setShowBlockCoachModal(true);
|
||||||
@@ -182,6 +211,7 @@ export default function SeatsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSeatStatus = (seat: any) => {
|
const getSeatStatus = (seat: any) => {
|
||||||
|
if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE';
|
||||||
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
||||||
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
||||||
if (seat.status === 'HELD') return 'HELD';
|
if (seat.status === 'HELD') return 'HELD';
|
||||||
@@ -194,6 +224,7 @@ export default function SeatsPage() {
|
|||||||
case 'BOOKED': return 'bg-red-500';
|
case 'BOOKED': return 'bg-red-500';
|
||||||
case 'HELD': return 'bg-yellow-500';
|
case 'HELD': return 'bg-yellow-500';
|
||||||
case 'BLOCKED': return 'bg-gray-500';
|
case 'BLOCKED': return 'bg-gray-500';
|
||||||
|
case 'UNDER_MAINTENANCE': return 'bg-orange-500';
|
||||||
default: return 'bg-gray-300';
|
default: return 'bg-gray-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -265,6 +296,8 @@ export default function SeatsPage() {
|
|||||||
handleRemoveSeat={handleRemoveSeat}
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
handleUnblock={handleUnblock}
|
handleUnblock={handleUnblock}
|
||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -358,6 +391,8 @@ export default function SeatsPage() {
|
|||||||
handleRemoveSeat={handleRemoveSeat}
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
handleUnblock={handleUnblock}
|
handleUnblock={handleUnblock}
|
||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -378,6 +413,8 @@ export default function SeatsPage() {
|
|||||||
handleRemoveSeat={handleRemoveSeat}
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
handleUnblock={handleUnblock}
|
handleUnblock={handleUnblock}
|
||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -532,6 +569,10 @@ export default function SeatsPage() {
|
|||||||
<div className="w-5 h-5 rounded bg-gray-500"></div>
|
<div className="w-5 h-5 rounded bg-gray-500"></div>
|
||||||
<span className="text-sm text-muted-foreground">Blocked</span>
|
<span className="text-sm text-muted-foreground">Blocked</span>
|
||||||
</div>
|
</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="flex items-center gap-3">
|
||||||
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
|
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
|
||||||
<span className="text-sm text-muted-foreground">Removed</span>
|
<span className="text-sm text-muted-foreground">Removed</span>
|
||||||
@@ -782,6 +823,44 @@ export default function SeatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -798,6 +877,8 @@ interface SeatIconProps {
|
|||||||
handleRemoveSeat: (seat: any) => void;
|
handleRemoveSeat: (seat: any) => void;
|
||||||
handleUnblock: (seat: any) => void;
|
handleUnblock: (seat: any) => void;
|
||||||
handleUndoRemove: (seat: any) => void;
|
handleUndoRemove: (seat: any) => void;
|
||||||
|
handleSetMaintenance: (seat: any) => void;
|
||||||
|
handleClearMaintenance: (seat: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeatIcon({
|
function SeatIcon({
|
||||||
@@ -812,6 +893,8 @@ function SeatIcon({
|
|||||||
handleRemoveSeat,
|
handleRemoveSeat,
|
||||||
handleUnblock,
|
handleUnblock,
|
||||||
handleUndoRemove,
|
handleUndoRemove,
|
||||||
|
handleSetMaintenance,
|
||||||
|
handleClearMaintenance,
|
||||||
}: SeatIconProps) {
|
}: SeatIconProps) {
|
||||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||||
@@ -845,6 +928,8 @@ function SeatIcon({
|
|||||||
const color = getSeatColor(status);
|
const color = getSeatColor(status);
|
||||||
const canBlock = status === 'AVAILABLE';
|
const canBlock = status === 'AVAILABLE';
|
||||||
const canUnblock = status === 'BLOCKED';
|
const canUnblock = status === 'BLOCKED';
|
||||||
|
const canMaintenance = status === 'AVAILABLE' || status === 'BLOCKED';
|
||||||
|
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group flex flex-col items-center">
|
<div className="relative group flex flex-col items-center">
|
||||||
@@ -872,7 +957,7 @@ function SeatIcon({
|
|||||||
</div>
|
</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">
|
<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 && (
|
{canBlock && (
|
||||||
<>
|
<>
|
||||||
@@ -901,6 +986,24 @@ function SeatIcon({
|
|||||||
<Unlock className="h-3 w-3 text-gray-700" />
|
<Unlock className="h-3 w-3 text-gray-700" />
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export default function TrainsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingTrain(null);
|
setEditingTrain(null);
|
||||||
alert('Train created successfully');
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
alert('Error creating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
||||||
@@ -44,7 +43,6 @@ export default function TrainsPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setEditingTrain(null);
|
setEditingTrain(null);
|
||||||
alert('Train updated successfully');
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
alert('Error updating train: ' + (error?.response?.data?.message || 'Unknown error'));
|
||||||
@@ -55,7 +53,6 @@ export default function TrainsPage() {
|
|||||||
mutationFn: (id: string) => fleetApi.deleteTrain(id),
|
mutationFn: (id: string) => fleetApi.deleteTrain(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||||
alert('Train deleted successfully');
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,7 +60,6 @@ export default function TrainsPage() {
|
|||||||
mutationFn: (id: string) => fleetApi.restoreTrain(id),
|
mutationFn: (id: string) => fleetApi.restoreTrain(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
queryClient.invalidateQueries({ queryKey: ['trains'] });
|
||||||
alert('Train restored successfully');
|
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
alert('Error restoring train: ' + (error?.response?.data?.message || 'Unknown error'));
|
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`),
|
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
|
||||||
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
|
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
|
||||||
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-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
|
// Payments API
|
||||||
|
|||||||
@@ -512,6 +512,8 @@ export default function PassengersPage() {
|
|||||||
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
const [verificationStatus, setVerificationStatus] = useState<Record<number, 'success' | 'error'>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [formInitialized, setFormInitialized] = 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);
|
const totalPassengers = (searchCriteria?.adultCount || 1) + (searchCriteria?.childCount || 0);
|
||||||
@@ -576,6 +578,54 @@ export default function PassengersPage() {
|
|||||||
checkFaydaStatus();
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated && user?.faydaVerified) {
|
if (isAuthenticated && user?.faydaVerified) {
|
||||||
setVerificationStatus({ 0: 'success' });
|
setVerificationStatus({ 0: 'success' });
|
||||||
@@ -752,13 +802,15 @@ export default function PassengersPage() {
|
|||||||
|
|
||||||
if (!searchCriteria) return null;
|
if (!searchCriteria) return null;
|
||||||
|
|
||||||
if (!formInitialized) {
|
if (!formInitialized || faydaCompleting) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="max-w-lg mx-auto text-center">
|
<div className="max-w-lg mx-auto text-center">
|
||||||
<Loader2 className="w-8 h-8 animate-spin mx-auto text-blue-600" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,20 +259,14 @@ export default function SeatsPage() {
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
const coachesWithSeats = coaches.filter(
|
const coachesWithSeats = coaches.filter((c: any) => {
|
||||||
(c: any) => c.seats && c.seats.length > 0,
|
// 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;
|
||||||
if (!currentSchedule?.selectedSeatClass) {
|
});
|
||||||
console.log(
|
|
||||||
"✅ No filter applied, returning all coaches:",
|
|
||||||
coachesWithSeats.length,
|
|
||||||
);
|
|
||||||
return coachesWithSeats;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"✅ No seat class filter - returning all coaches with seats:",
|
"✅ Returning all coaches with seats/beds:",
|
||||||
coachesWithSeats.length,
|
coachesWithSeats.length,
|
||||||
);
|
);
|
||||||
return coachesWithSeats;
|
return coachesWithSeats;
|
||||||
@@ -333,6 +327,8 @@ export default function SeatsPage() {
|
|||||||
return seatLabel && !seatLabel.startsWith("-");
|
return seatLabel && !seatLabel.startsWith("-");
|
||||||
});
|
});
|
||||||
const isBedCoach =
|
const isBedCoach =
|
||||||
|
selectedCoachData?.isBedCoach === true ||
|
||||||
|
seats.some((s: any) => s.bedPosition) ||
|
||||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||||
|
|
||||||
@@ -1071,6 +1067,8 @@ export default function SeatsPage() {
|
|||||||
|
|
||||||
const allSelected = selectedSeats.length === passengers.length;
|
const allSelected = selectedSeats.length === passengers.length;
|
||||||
const isBedCoach =
|
const isBedCoach =
|
||||||
|
selectedCoachData?.isBedCoach === true ||
|
||||||
|
selectedCoachData?.rooms?.length > 0 ||
|
||||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||||
|
|
||||||
@@ -1314,6 +1312,8 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isBed =
|
const isBed =
|
||||||
|
coach.isBedCoach === true ||
|
||||||
|
coach.rooms?.length > 0 ||
|
||||||
coach.seatClass?.toLowerCase().includes("bed") ||
|
coach.seatClass?.toLowerCase().includes("bed") ||
|
||||||
coach.mode?.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 Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
|
||||||
|
|
||||||
export default function AppHeader() {
|
export default function AppHeader() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -72,9 +71,6 @@ export default function AppHeader() {
|
|||||||
<HelpCircle className="w-5 h-5" />
|
<HelpCircle className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Language Switcher */}
|
|
||||||
<LanguageSwitcher />
|
|
||||||
|
|
||||||
{/* Theme Toggler */}
|
{/* Theme Toggler */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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 { HttpPaymentEventPublisher } from "./publisher/http-payment-event-publisher";
|
||||||
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
|
import { PAYMENT_EVENT_PUBLISHER } from "./publisher/payment-event-publisher";
|
||||||
import { RabbitMqPaymentEventPublisher } from "./publisher/rabbitmq-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()
|
const rabbitImports = isRabbitPublisher()
|
||||||
? [
|
? [
|
||||||
@@ -42,6 +48,7 @@ const rabbitImports = isRabbitPublisher()
|
|||||||
HttpModule,
|
HttpModule,
|
||||||
...rabbitImports,
|
...rabbitImports,
|
||||||
],
|
],
|
||||||
|
controllers: testControllers,
|
||||||
providers: [
|
providers: [
|
||||||
OutboxRepository,
|
OutboxRepository,
|
||||||
OutboxRelayService,
|
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> {
|
async handle(payload: DMoneyWebhookPayload): Promise<void> {
|
||||||
const signatureValid = this.provider.verifyWebhookSignature(
|
// TODO: re-enable D-Money public-key signature verification — skipped for now.
|
||||||
payload as unknown as Record<string, unknown>,
|
// 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 mapped = this.provider.mapWebhookTradeStatus(payload.trade_status);
|
||||||
const providerTxnId = payload.transId ?? payload.payment_order_id;
|
const providerTxnId = payload.transId ?? payload.payment_order_id;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ services:
|
|||||||
- apps/edr-freight-api/.env
|
- apps/edr-freight-api/.env
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||||
|
|
||||||
passenger-api:
|
passenger-api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -31,7 +30,6 @@ services:
|
|||||||
- apps/edr-passenger-api/.env
|
- apps/edr-passenger-api/.env
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||||
|
|
||||||
freight-portal:
|
freight-portal:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -39,14 +37,13 @@ services:
|
|||||||
args:
|
args:
|
||||||
TURBO_FILTER: "@edr/freight-portal"
|
TURBO_FILTER: "@edr/freight-portal"
|
||||||
APP_PATH: apps/edr-freight-web/portal
|
APP_PATH: apps/edr-freight-web/portal
|
||||||
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
|
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
||||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
|
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
||||||
secrets:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
- "${FREIGHT_PORTAL_PORT:-5173}:80"
|
||||||
|
|
||||||
freight-backoffice:
|
freight-backoffice:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -54,14 +51,13 @@ services:
|
|||||||
args:
|
args:
|
||||||
TURBO_FILTER: "@edr/freight-backoffice"
|
TURBO_FILTER: "@edr/freight-backoffice"
|
||||||
APP_PATH: apps/edr-freight-web/backoffice
|
APP_PATH: apps/edr-freight-web/backoffice
|
||||||
VITE_API_URL: ${VITE_API_URL:-https://edrfreightapi.triaplc.com/api}
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-https://edrfreightapi.triaplc.com}
|
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
||||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-/_um}
|
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
||||||
secrets:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
- "${FREIGHT_BACKOFFICE_PORT:-5183}:80"
|
||||||
|
|
||||||
passenger-portal:
|
passenger-portal:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -69,14 +65,13 @@ services:
|
|||||||
args:
|
args:
|
||||||
APP_PACKAGE: "@edr/passenger-portal"
|
APP_PACKAGE: "@edr/passenger-portal"
|
||||||
APP_PATH: apps/edr-passenger-web/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:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
|
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
|
||||||
env_file:
|
env_file:
|
||||||
- apps/edr-passenger-web/portal/.env
|
- apps/edr-passenger-web/portal/.env
|
||||||
|
|
||||||
passenger-backoffice:
|
passenger-backoffice:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -84,14 +79,14 @@ services:
|
|||||||
args:
|
args:
|
||||||
APP_PACKAGE: "@edr/passenger-backoffice"
|
APP_PACKAGE: "@edr/passenger-backoffice"
|
||||||
APP_PATH: apps/edr-passenger-web/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:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
|
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
|
||||||
env_file:
|
env_file:
|
||||||
- apps/edr-passenger-web/backoffice/.env
|
- apps/edr-passenger-web/backoffice/.env
|
||||||
|
|
||||||
payment-api:
|
payment-api:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -105,7 +100,6 @@ services:
|
|||||||
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
|
- "${PAYMENT_API_PORT:-3008}:${PAYMENT_API_PORT:-3008}"
|
||||||
env_file:
|
env_file:
|
||||||
- apps/edr-payment-api/.env
|
- apps/edr-payment-api/.env
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
npmrc:
|
npmrc:
|
||||||
file: .npmrc
|
file: .npmrc
|
||||||
|
|||||||
@@ -10,12 +10,10 @@
|
|||||||
# --build-arg PORT=5174 \
|
# --build-arg PORT=5174 \
|
||||||
# -f infrastructure/docker/Dockerfile.passenger-web .
|
# -f infrastructure/docker/Dockerfile.passenger-web .
|
||||||
#
|
#
|
||||||
|
|
||||||
ARG APP_PACKAGE=@edr/passenger-portal
|
ARG APP_PACKAGE=@edr/passenger-portal
|
||||||
ARG APP_PATH=apps/edr-passenger-web/portal
|
ARG APP_PATH=apps/edr-passenger-web/portal
|
||||||
ARG PORT=5174
|
ARG PORT=5174
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
|
|
||||||
FROM node:24.15.0-alpine AS base
|
FROM node:24.15.0-alpine AS base
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
|
# 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"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
ARG APP_PACKAGE
|
ARG APP_PACKAGE
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker
|
RUN pnpm dlx turbo prune "${APP_PACKAGE}" --docker
|
||||||
|
|
||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
ARG APP_PACKAGE
|
ARG APP_PACKAGE
|
||||||
ARG APP_PATH
|
ARG APP_PATH
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ENV NEXT_PUBLIC_API_URL=${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=installer /app/ .
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
RUN pnpm turbo build --filter="${APP_PACKAGE}..."
|
RUN pnpm turbo build --filter="${APP_PACKAGE}..."
|
||||||
|
|
||||||
FROM base AS deployer
|
FROM base AS deployer
|
||||||
ARG APP_PACKAGE
|
ARG APP_PACKAGE
|
||||||
COPY --from=builder /app/ .
|
COPY --from=builder /app/ .
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
|
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
|
||||||
|
|
||||||
FROM node:24.15.0-alpine AS runner
|
FROM node:24.15.0-alpine AS runner
|
||||||
ARG APP_PATH
|
ARG APP_PATH
|
||||||
ARG PORT=5174
|
ARG PORT=5174
|
||||||
|
|||||||
@@ -2,17 +2,9 @@
|
|||||||
|
|
||||||
ARG TURBO_FILTER=@edr/freight-portal
|
ARG TURBO_FILTER=@edr/freight-portal
|
||||||
ARG APP_PATH=apps/edr-freight-web/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
|
FROM node:24.15.0-alpine AS base
|
||||||
RUN apk add --no-cache libc6-compat
|
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
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -39,6 +31,12 @@ ENV VITE_API_URL=${VITE_API_URL}
|
|||||||
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
|
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
|
||||||
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
|
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
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=installer /app/ .
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
): Promise<ProviderInitiationResult> {
|
): Promise<ProviderInitiationResult> {
|
||||||
const fabricToken = await this.applyFabricToken();
|
const fabricToken = await this.applyFabricToken();
|
||||||
const requestBody = this.buildPreOrderRequest(input);
|
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>(
|
const response = await this.postJson<DMoneyPreOrderResponse>(
|
||||||
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
|
`${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`,
|
||||||
requestBody,
|
requestBody,
|
||||||
@@ -208,7 +211,7 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
merch_order_id: input.merchantOrderId,
|
merch_order_id: input.merchantOrderId,
|
||||||
trade_type: "WebCheckout" as const,
|
trade_type: "WebCheckout" as const,
|
||||||
business_type: "OnlineMerchant" as const,
|
business_type: "OnlineMerchant" as const,
|
||||||
title: `${input.orderRef}`,
|
title: "EDR booking payment",
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
// Charge the currency the caller already converted to; never relabel it provider-side.
|
// Charge the currency the caller already converted to; never relabel it provider-side.
|
||||||
trans_currency: input.currency,
|
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):
|
# Server layout (one file per service):
|
||||||
# /home/user/environmen/<project>/<branch-slug>/freight-api.env
|
# /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-portal.env
|
||||||
# /home/user/environmen/<project>/<branch-slug>/freight-web.build.env (optional, exports VITE_API_URL etc.)
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -62,26 +61,8 @@ for service in "$@"; do
|
|||||||
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
|
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
|
||||||
echo "Exported ${service_var}_PORT from ${src}"
|
echo "Exported ${service_var}_PORT from ${src}"
|
||||||
|
|
||||||
# Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args.
|
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
|
||||||
grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${src}" \
|
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${src}" \
|
||||||
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
|
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
|
||||||
fi
|
fi
|
||||||
done
|
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
|
|
||||||
Reference in New Issue
Block a user