mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
23
.github/workflows/deploy.yml
vendored
23
.github/workflows/deploy.yml
vendored
@@ -170,13 +170,36 @@ jobs:
|
||||
- name: Build ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}"
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
|
||||
# Tag with git SHA for rollback capability
|
||||
CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1)
|
||||
docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true
|
||||
echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Deploy ${{ matrix.service }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||
|
||||
- name: Verify deployment health
|
||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2)
|
||||
echo "Waiting for service to become healthy on port ${PORT}..."
|
||||
for i in $(seq 1 12); do
|
||||
if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then
|
||||
echo "Service is healthy."
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt ${i}/12 — not ready yet, waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
echo "Service failed health check after 120s — rolling back"
|
||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true
|
||||
exit 1
|
||||
|
||||
- name: Remove npm credentials from workspace
|
||||
if: always()
|
||||
run: rm -f .npmrc .npmrc_temp
|
||||
|
||||
@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/tcp` with matching ports.
|
||||
|
||||
### Runtime
|
||||
|
||||
The final image runs:
|
||||
The final image uses Next.js `output: 'standalone'` and runs:
|
||||
|
||||
```bash
|
||||
npx next start
|
||||
node server.js
|
||||
```
|
||||
|
||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on.
|
||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).
|
||||
|
||||
### Rollback a single service
|
||||
|
||||
```bash
|
||||
# 1. Find the last known-good image tag
|
||||
docker images | grep passenger-api
|
||||
|
||||
# 2. Re-tag it as the current image
|
||||
docker tag edr-passenger-main-passenger-api:<previous-sha> edr-passenger-main-passenger-api:latest
|
||||
|
||||
# 3. Restart the container from the previous image
|
||||
docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate
|
||||
```
|
||||
|
||||
### Rollback via re-run
|
||||
|
||||
Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit.
|
||||
|
||||
## Production Security Checklist
|
||||
|
||||
Before deploying to production, verify:
|
||||
|
||||
- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`)
|
||||
- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10`
|
||||
- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production)
|
||||
- [ ] `NODE_ENV=production` is set
|
||||
- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token
|
||||
- [ ] No `.env` files are committed to the repository (`git status` should show none)
|
||||
|
||||
## Data Retention Policy
|
||||
|
||||
The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes:
|
||||
|
||||
| Table | Retention |
|
||||
|---|---|
|
||||
| `OtpCode` | 1 hour after expiry or verification |
|
||||
| `FaydaVerificationSession` | 1 hour after expiry or completion |
|
||||
| `AuditLog` | 365 days |
|
||||
| `PaymentWebhookEvent` | 90 days |
|
||||
| `GateValidationLog` | 180 days |
|
||||
|
||||
No manual intervention is required. Monitor the `TasksService` log output for purge counts.
|
||||
|
||||
## GitHub Actions Deployment Flow
|
||||
|
||||
@@ -147,9 +193,10 @@ For each service:
|
||||
- Computes branch slug and sets:
|
||||
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
|
||||
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
|
||||
- Runs:
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
|
||||
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
|
||||
- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image.
|
||||
- Builds the service image and tags it with the short git SHA.
|
||||
- Runs `docker compose up -d <service> --force-recreate`.
|
||||
- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy.
|
||||
- Cleans `.npmrc`/`.npmrc_temp`.
|
||||
|
||||
## Branch/Environment Isolation
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop
|
||||
ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RouteCoachTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"routeId" TEXT NOT NULL,
|
||||
"coachId" TEXT NOT NULL,
|
||||
"positionNumber" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT,
|
||||
ADD COLUMN "nationalityType" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition");
|
||||
@@ -88,7 +88,9 @@ model SeatClass {
|
||||
coachTypeId String
|
||||
name String
|
||||
description String?
|
||||
baseFareMinor Int @default(0) // per-km rate
|
||||
nationalityType String? // 'LOCAL' | 'INTERNATIONAL'
|
||||
bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat
|
||||
baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000)
|
||||
premiumMinor Int @default(0) // flat fee per passenger
|
||||
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
||||
isActive Boolean @default(true)
|
||||
@@ -100,6 +102,7 @@ model SeatClass {
|
||||
segmentFares SegmentFareRule[]
|
||||
@@unique([coachTypeId, name])
|
||||
@@index([coachTypeId])
|
||||
@@index([coachTypeId, nationalityType, bedPosition])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -420,9 +423,10 @@ model Coach {
|
||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
routeTemplates RouteCoachTemplate[]
|
||||
@@index([coachTypeId])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
@@ -998,6 +1002,7 @@ model Route {
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
schedules TrainSchedule[]
|
||||
coachTemplates RouteCoachTemplate[]
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1015,6 +1020,20 @@ model RouteStop {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model RouteCoachTemplate {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
coachId String
|
||||
positionNumber Int
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
|
||||
@@unique([routeId, positionNumber])
|
||||
@@index([routeId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model RouteFareRule {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
|
||||
@@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() {
|
||||
});
|
||||
}
|
||||
|
||||
// Tariff rates: baseFareMinor = tariff_decimal × 100000
|
||||
// Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate
|
||||
// LOCAL = Ethiopian or Djiboutian nationals
|
||||
// INTERNATIONAL = all other nationalities
|
||||
const seatClasses = [
|
||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
|
||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
|
||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
|
||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
|
||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
|
||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
|
||||
// LOCAL rates
|
||||
{ name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
// INTERNATIONAL rates
|
||||
{ name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
];
|
||||
|
||||
for (const sc of seatClasses) {
|
||||
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
|
||||
await prisma.seatClass.upsert({
|
||||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||||
update: {},
|
||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
|
||||
update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
|
||||
create: {
|
||||
coachTypeId: ct!.id,
|
||||
name: sc.name,
|
||||
nationalityType: sc.nationalityType,
|
||||
bedPosition: sc.bedPosition,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
premiumMinor: sc.premiumMinor,
|
||||
insuranceFeeMinor: sc.insuranceFeeMinor,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
||||
@@ -238,6 +258,58 @@ async function seedRoute() {
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
||||
|
||||
// Full cross-border route: Sebeta → Nagad (all 15 stations)
|
||||
const fullRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-201' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-201',
|
||||
name: 'Sebeta - Nagad (Full Cross-Border)',
|
||||
description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Cumulative distances from Sebeta (km) for all 15 stations
|
||||
const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
|
||||
const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0];
|
||||
for (let i = 0; i < fullStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } },
|
||||
update: { distanceKm: fullDistancesKm[i] },
|
||||
create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
|
||||
// Full cross-border return route: Nagad → Sebeta
|
||||
const fullReturnRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-202' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-202',
|
||||
name: 'Nagad - Sebeta (Full Cross-Border Return)',
|
||||
description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||
const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0];
|
||||
for (let i = 0; i < fullReturnStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } },
|
||||
update: { distanceKm: fullReturnDistancesKm[i] },
|
||||
create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`);
|
||||
}
|
||||
|
||||
async function seedCoaches() {
|
||||
@@ -431,34 +503,61 @@ async function seedTrips() {
|
||||
async function seedFareRules() {
|
||||
console.log('\n💰 Seeding fare rules...');
|
||||
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
|
||||
const fareRules = [];
|
||||
for (const sc of seatClasses) {
|
||||
fareRules.push({
|
||||
routeId: route!.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'ADULT' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
fareRules.push({
|
||||
routeId: route!.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
||||
discountPercent: 10,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
// Delete existing FareRule rows so re-seed is idempotent
|
||||
await prisma.fareRule.deleteMany({});
|
||||
|
||||
const fareRules: any[] = [];
|
||||
for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
|
||||
for (const sc of seatClasses) {
|
||||
fareRules.push({
|
||||
route: route.code,
|
||||
seatClassId: sc.id,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
||||
fareRules.map(fr => prisma.fareRule.create({ data: fr }))
|
||||
);
|
||||
console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`);
|
||||
console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`);
|
||||
|
||||
const allRoutes = await prisma.route.findMany({
|
||||
where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } },
|
||||
});
|
||||
const routeFareRules: any[] = [];
|
||||
for (const r of allRoutes) {
|
||||
for (const sc of seatClasses) {
|
||||
routeFareRules.push({
|
||||
routeId: r.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'ADULT' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
// CHILD: same per-km rate as ADULT — age-based free/paid logic is handled
|
||||
// at booking time (first child free, subsequent children full fare).
|
||||
routeFareRules.push({
|
||||
routeId: r.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {}))
|
||||
);
|
||||
console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`);
|
||||
}
|
||||
|
||||
async function seedCurrency() {
|
||||
@@ -759,6 +858,22 @@ async function main() {
|
||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['System Users', seedSystemUsers],
|
||||
['Stations', seedStations],
|
||||
['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||
['Route', seedRoute],
|
||||
['Coaches', seedCoaches],
|
||||
['Trips', seedTrips],
|
||||
['Fare Rules', seedFareRules],
|
||||
['Currency', seedCurrency],
|
||||
['Payment Methods', seedPaymentMethods],
|
||||
['Segment Fares', seedSegmentFares],
|
||||
['Notification Templates', seedNotificationTemplates],
|
||||
['Menu & Food', seedMenuAndFood],
|
||||
['Promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['Fraud Rules', seedFraudRules],
|
||||
['Kulubbi Package', seedKulubbiPackage],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
NestModule,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import {Logger, Module, OnApplicationBootstrap} from '@nestjs/common';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
|
||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||
@@ -66,7 +61,6 @@ import { PackagesModule } from './modules/packages/packages.module';
|
||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -136,7 +130,6 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
|
||||
ExcessBaggageModule,
|
||||
HealthModule,
|
||||
TasksModule,
|
||||
ConfigurableFareModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||
@@ -147,6 +140,7 @@ import { ConfigurableFareModule } from './modules/configurable-fare/configurable
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AppModule.name);
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder,
|
||||
@@ -157,17 +151,17 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
try {
|
||||
await this.seeder.run();
|
||||
} catch (err) {
|
||||
console.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
this.logger.error('[DataSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await this.edrPassengerOrgSeeder.run();
|
||||
} catch (err) {
|
||||
console.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
this.logger.error('[EdrPassengerOrgSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await this.passengerStaffUsersSeeder.run();
|
||||
} catch (err) {
|
||||
console.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
this.logger.error('[PassengerStaffUsersSeeder] Seed failed (non-fatal):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,17 @@ import { Reflector } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerStorage, getOptionsToken, getStorageToken } from '@nestjs/throttler';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../modules/system-config/system-config.service';
|
||||
|
||||
// Route-prefix → throttler tier mapping.
|
||||
// Evaluated in order; first match wins.
|
||||
const ROUTE_TIERS: Array<{ prefix: string; tier: 'auth' | 'strict' | 'default' }> = [
|
||||
{ prefix: '/auth', tier: 'auth' },
|
||||
{ prefix: '/fayda/verification',tier: 'auth' },
|
||||
{ prefix: '/bookings', tier: 'strict' },
|
||||
{ prefix: '/passengers', tier: 'strict' },
|
||||
{ prefix: '/payments', tier: 'strict' },
|
||||
{ prefix: '/wallet', tier: 'strict' },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class DynamicThrottlerGuard extends ThrottlerGuard {
|
||||
constructor(
|
||||
@@ -29,9 +40,17 @@ export class DynamicThrottlerGuard extends ThrottlerGuard {
|
||||
this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_DEFAULT_TTL_MS),
|
||||
]);
|
||||
|
||||
this.throttlers = [
|
||||
{ name: 'default', ttl: defaultTtl, limit: defaultLimit },
|
||||
];
|
||||
const url: string = context.switchToHttp().getRequest<{ url: string }>().url ?? '';
|
||||
const matched = ROUTE_TIERS.find(({ prefix }) => url.startsWith(prefix));
|
||||
const tier = matched?.tier ?? 'default';
|
||||
|
||||
if (tier === 'auth') {
|
||||
this.throttlers = [{ name: 'auth', ttl: authTtl, limit: authLimit }];
|
||||
} else if (tier === 'strict') {
|
||||
this.throttlers = [{ name: 'strict', ttl: strictTtl, limit: strictLimit }];
|
||||
} else {
|
||||
this.throttlers = [{ name: 'default', ttl: defaultTtl, limit: defaultLimit }];
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// In production set connection_limit and pool_timeout in DATABASE_URL:
|
||||
// ?connection_limit=10&pool_timeout=20&sslmode=require
|
||||
log:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? [{ emit: 'event', level: 'query' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }]
|
||||
: [{ emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
(this as any).$on('query', (e: { query: string; duration: number }) => {
|
||||
if (e.duration > 500) {
|
||||
this.logger.warn(`Slow query (${e.duration}ms): ${e.query}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleInit() { await this.$connect(); }
|
||||
async onModuleDestroy() { await this.$disconnect(); }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT ?? '4000', 10),
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
|
||||
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
|
||||
}));
|
||||
export default registerAs('app', () => {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && !process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is required in production');
|
||||
}
|
||||
if (isProd && !process.env.JWT_ACCESS_TOKEN_SECRET) {
|
||||
throw new Error('JWT_ACCESS_TOKEN_SECRET environment variable is required in production');
|
||||
}
|
||||
if (isProd && !process.env.JWT_REFRESH_TOKEN_SECRET) {
|
||||
throw new Error('JWT_REFRESH_TOKEN_SECRET environment variable is required in production');
|
||||
}
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT ?? '4000', 10),
|
||||
jwtSecret: process.env.JWT_SECRET ?? 'dev-secret',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
frontendUrl: process.env.PORTAL_URL ?? 'http://localhost:3000',
|
||||
portalUrl: process.env.BACK_OFFICE_URL ?? 'http://localhost:3001',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// Load .env into process.env BEFORE the module graph is built. Required because the @tria-plc IAM
|
||||
// modules read process.env at module-load time (e.g. MinioModule.register reads MINIO_ENDPOINT),
|
||||
// which happens before ConfigModule.forRoot() would populate it. Must be the very first import.
|
||||
import "dotenv/config";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import helmet from "helmet";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
|
||||
@@ -14,14 +15,22 @@ import { SessionActivityInterceptor } from "./common/interceptors/session-activi
|
||||
// Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations
|
||||
process.env.TZ = 'Africa/Addis_Ababa';
|
||||
|
||||
// Safety guard: prevent insecure TLS from being enabled in production
|
||||
if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS === 'true') {
|
||||
throw new Error('WAAFI_INSECURE_TLS=true is not allowed in production');
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
// version-neutral at their existing paths (e.g. /search, /bookings) — unchanged for the frontend.
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
|
||||
app.enableCors({
|
||||
@@ -29,6 +38,9 @@ async function bootstrap() {
|
||||
process.env.PORTAL_URL ?? "http://localhost:5174",
|
||||
process.env.BACK_OFFICE_URL ?? "http://localhost:5184",
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept-Language', 'X-Request-ID'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
@@ -38,6 +50,7 @@ async function bootstrap() {
|
||||
);
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidUnknownValues: false }));
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR Passenger API")
|
||||
.setDescription(
|
||||
@@ -130,7 +143,7 @@ Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and m
|
||||
- Ticket lifecycle tracking (validatedAt, outboundBoardedAt, returnBoardedAt timestamps)
|
||||
- Gate validation accepts leg (OUTBOUND or RETURN) for round-trip tickets
|
||||
- Complete audit trail per leg for compliance and reporting
|
||||
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
|
||||
- **Boarding pass delivered via email + SMS on every successful gate validation** — includes route, train, departure/arrival, QR code (email), seat assignments per passenger, and barcode
|
||||
|
||||
### Booking Type Matrix
|
||||
|
||||
@@ -240,28 +253,28 @@ For round-trips also pass \`returnScheduleId\`, \`returnOriginStationId\`, \`ret
|
||||
|
||||
### Step 3: Passenger Information & Verification
|
||||
**For Ethiopian Passengers:**
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
\`POST /passengers/verify-fayda\` — Automatic Fayda verification for adults (5+ years)
|
||||
|
||||
**For International Passengers:**
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
\`POST /passengers/register-international\` — Passport information collection
|
||||
|
||||
### Step 4: View Seat Map
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
\`GET /seats/seatmap/{scheduleId}\` — Show available coaches and seats.
|
||||
For round-trips, call this twice: once for outbound scheduleId, once for return scheduleId.
|
||||
|
||||
### Step 5: Hold Seats
|
||||
\`POST /seats/hold\` to reserve seats for 15 minutes.
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
- ONE_WAY / TRANSIT outbound leg: one hold call → \`holdId\`
|
||||
- TRANSIT leg-2: second hold call → \`leg2HoldId\`
|
||||
- ROUND_TRIP return: second hold call → \`returnHoldId\`
|
||||
- ROUND_TRIP_TRANSIT: four hold calls → \`holdId\`, \`leg2HoldId\`, \`returnHoldId\`, \`returnLeg2HoldId\`
|
||||
|
||||
### Step 6: Create Booking
|
||||
Choose the right endpoint and bookingType:
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
- **ONE_WAY** → \`POST /bookings/guest\` or \`POST /bookings\` with \`bookingType: ONE_WAY\`, passenger \`seatId\`
|
||||
- **ROUND_TRIP** → same endpoint with \`bookingType: ROUND_TRIP\`, \`returnScheduleId/returnHoldId/returnOriginStationId/returnDestinationStationId\`, passenger \`seatId + returnSeatId\`
|
||||
- **TRANSIT** → same endpoint with \`bookingType: TRANSIT\`, \`leg2ScheduleId/leg2HoldId/transitStationId/leg2DestinationStationId\`, passenger \`seatId + leg2SeatId\`
|
||||
- **ROUND_TRIP_TRANSIT** → same endpoint with \`bookingType: ROUND_TRIP_TRANSIT\`, all 4 sets of schedule/hold/station fields, passenger \`seatId + leg2SeatId + returnSeatId + returnLeg2SeatId\`
|
||||
|
||||
### Step 7: Process Payment
|
||||
\`POST /payments/telebirr\` (Ethiopian) or \`POST /payments/waafi\` (Djiboutian)
|
||||
@@ -406,10 +419,12 @@ Payment providers send notifications to:
|
||||
operationsSorter: "alpha",
|
||||
},
|
||||
});
|
||||
} // end if (NODE_ENV !== 'production')
|
||||
|
||||
const port = process.env.PORT ?? 4000;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 EDR Passenger API running on port ${port}`);
|
||||
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||
const logger = new Logger('Bootstrap');
|
||||
logger.log(`EDR Passenger API running on port ${port}`);
|
||||
logger.log(`Swagger: http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -180,6 +180,9 @@ export class PassengerAuthService {
|
||||
|
||||
return {
|
||||
iamUserId,
|
||||
// Top-level passengerId keeps the profile shape consistent with the login
|
||||
// response so the web User object always carries it (the JWT does not).
|
||||
passengerId: passenger.id,
|
||||
email: iam?.email ?? null,
|
||||
phone: iam?.phone_number ?? null,
|
||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CurrenciesController } from './currencies.controller';
|
||||
import { CurrenciesService } from './currencies.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
imports: [HttpModule, PrismaModule, CurrencyModule],
|
||||
controllers: [CurrenciesController],
|
||||
providers: [CurrenciesService],
|
||||
exports: [CurrenciesService],
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CurrenciesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
async getAllCurrencies() {
|
||||
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||
@@ -108,7 +112,12 @@ export class CurrenciesService {
|
||||
}
|
||||
|
||||
async syncExchangeRates() {
|
||||
return { message: 'Exchange rates synced successfully', synced: 0 };
|
||||
await this.currencyService.syncExchangeRates();
|
||||
const rates = await this.prisma.currencyExchangeRate.findMany({
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
return { message: 'Exchange rates synced successfully', synced: rates.length };
|
||||
}
|
||||
|
||||
private getCurrencyName(code: string): string {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { CurrencyService } from './currency.service';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, HttpModule],
|
||||
providers: [CurrencyService],
|
||||
exports: [CurrencyService],
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
@@ -21,7 +24,11 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
|
||||
export class CurrencyService {
|
||||
private readonly logger = new Logger(CurrencyService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async convertEtbMinorToChargeMajor(
|
||||
amountMinorEtb: number,
|
||||
@@ -81,14 +88,11 @@ export class CurrencyService {
|
||||
fromCurrency: Currency,
|
||||
toCurrency: Currency,
|
||||
): Promise<number> {
|
||||
if (fromCurrency === toCurrency) return 1;
|
||||
|
||||
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||
where: {
|
||||
fromCurrency,
|
||||
toCurrency,
|
||||
},
|
||||
orderBy: {
|
||||
effectiveDate: 'desc',
|
||||
},
|
||||
where: { fromCurrency, toCurrency },
|
||||
orderBy: { effectiveDate: 'desc' },
|
||||
});
|
||||
|
||||
if (!exchangeRate) {
|
||||
@@ -98,26 +102,61 @@ export class CurrencyService {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - exchangeRate.effectiveDate.getTime();
|
||||
if (ageMs > 2 * 24 * 60 * 60 * 1000) {
|
||||
this.logger.warn(
|
||||
`Stale exchange rate for ${fromCurrency}->${toCurrency}: last updated ${exchangeRate.effectiveDate.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return Number(exchangeRate.rate);
|
||||
}
|
||||
|
||||
async syncExchangeRates(): Promise<void> {
|
||||
this.logger.log('Syncing exchange rates from external provider');
|
||||
this.logger.log('Syncing exchange rates from central bank API');
|
||||
|
||||
const today = this.todayUtc();
|
||||
const rates = [
|
||||
{ from: 'ETB', to: 'ETB', rate: 1.0 },
|
||||
{ from: 'ETB', to: 'DJF', rate: 3.25 },
|
||||
{ from: 'ETB', to: 'USD', rate: 0.018 },
|
||||
{ from: 'DJF', to: 'ETB', rate: 0.3077 },
|
||||
{ from: 'USD', to: 'ETB', rate: 55.56 },
|
||||
// Fallback rates used when the API is unreachable
|
||||
const fallbackRates = [
|
||||
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
|
||||
{ from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
|
||||
{ from: Currency.ETB, to: Currency.USD, rate: 0.018 },
|
||||
{ from: Currency.DJF, to: Currency.ETB, rate: 0.3077 },
|
||||
{ from: Currency.USD, to: Currency.ETB, rate: 55.56 },
|
||||
];
|
||||
|
||||
for (const { from, to, rate } of rates) {
|
||||
await this.upsertRate(from as Currency, to as Currency, rate, today, 'EXTERNAL_API');
|
||||
const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
|
||||
if (apiUrl) {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<Record<string, number>>(apiUrl, { timeout: 5000 }),
|
||||
);
|
||||
// Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... }
|
||||
const data = response.data;
|
||||
const apiRates = [
|
||||
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
|
||||
{ from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate },
|
||||
{ from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate },
|
||||
{ from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate },
|
||||
{ from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate },
|
||||
];
|
||||
for (const { from, to, rate } of apiRates) {
|
||||
await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API');
|
||||
}
|
||||
this.logger.log('Exchange rates synced from central bank API');
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Central bank API unreachable (${(err as Error).message}), falling back to configured rates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Exchange rates synced successfully');
|
||||
// Fallback: persist the static rates so the DB always has a current row
|
||||
for (const { from, to, rate } of fallbackRates) {
|
||||
await this.upsertRate(from, to, rate, today, 'FALLBACK');
|
||||
}
|
||||
this.logger.log('Exchange rates synced using fallback values');
|
||||
}
|
||||
|
||||
async listRates() {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareCalculateDto, resolveCurrencyFromNationality } from './fare-engine.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
const TAX_RATE = 0.05;
|
||||
|
||||
@Injectable()
|
||||
export class FareEngineService {
|
||||
constructor(
|
||||
@@ -32,6 +30,22 @@ export class FareEngineService {
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
// Resolve nationality type: Ethiopian and Djiboutian are LOCAL, everyone else INTERNATIONAL
|
||||
const nationalityUpper = (dto.nationality ?? '').toUpperCase();
|
||||
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
|
||||
? 'LOCAL' : 'INTERNATIONAL';
|
||||
|
||||
// Find the nationality-specific seat class for the same coach type and bed position.
|
||||
// Falls back to the requested seatClass if no nationality-specific one exists.
|
||||
const nationalitySeatClass = await this.prisma.seatClass.findFirst({
|
||||
where: {
|
||||
coachTypeId: seatClass.coachTypeId,
|
||||
nationalityType,
|
||||
bedPosition: seatClass.bedPosition ?? null,
|
||||
isActive: true,
|
||||
},
|
||||
}) ?? seatClass;
|
||||
|
||||
// Calculate distance: distanceKm represents cumulative distance from route origin
|
||||
// For a segment, distance = destination.distanceKm - origin.distanceKm
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
@@ -68,16 +82,45 @@ export class FareEngineService {
|
||||
let ratePerKmMinor: number;
|
||||
let fareSource: string;
|
||||
|
||||
if (fareRule) {
|
||||
// Flat fare from FareRule — distance is informational only
|
||||
// 1. Segment override: exact origin→destination stop pair on this route
|
||||
const segmentOverride = await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: route.id,
|
||||
seatClassId: dto.seatClassId,
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
nationality: dto.nationality ?? null,
|
||||
},
|
||||
}) ?? await this.prisma.segmentFareRule.findFirst({
|
||||
where: {
|
||||
routeId: route.id,
|
||||
seatClassId: dto.seatClassId,
|
||||
originStopSequence: originStop.sequence,
|
||||
destinationStopSequence: destStop.sequence,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
nationality: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentOverride) {
|
||||
// Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate
|
||||
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = 'SEGMENT_FARE_RULE';
|
||||
} else if (fareRule?.tripId) {
|
||||
// Schedule-scoped flat override
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
||||
fareSource = 'SCHEDULE_FARE_RULE';
|
||||
} else {
|
||||
// Distance × rate fallback
|
||||
ratePerKmMinor = seatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
fareSource = 'DISTANCE_RATE';
|
||||
// Default: distance-based using tariff formula: km × rate × 1.02
|
||||
// baseFareMinor stores the per-km rate (tariff decimal × 100000)
|
||||
ratePerKmMinor = nationalitySeatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm * 1.02);
|
||||
fareSource = 'SEAT_CLASS_BASE_FARE';
|
||||
}
|
||||
|
||||
// Premium and insurance fees applied per passenger
|
||||
@@ -110,8 +153,7 @@ export class FareEngineService {
|
||||
}
|
||||
|
||||
const afterDiscountMinor = subtotalMinor - discountMinor;
|
||||
const taxMinor = Math.round(afterDiscountMinor * TAX_RATE);
|
||||
const totalEtbMinor = afterDiscountMinor + taxMinor;
|
||||
const totalEtbMinor = afterDiscountMinor;
|
||||
|
||||
const billingCurrency = resolveCurrencyFromNationality(dto.nationality);
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
@@ -119,8 +161,9 @@ export class FareEngineService {
|
||||
|
||||
const calculation = [
|
||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} = ${baseFarePerPassengerMinor} ETB minor`,
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType} → ${nationalitySeatClass.name}`,
|
||||
`Rate per km: ${ratePerKmMinor} ETB minor (${nationalitySeatClass.name})`,
|
||||
`Base fare/pax: ${totalDistanceKm} km × ${ratePerKmMinor} × 1.02 = ${baseFarePerPassengerMinor} ETB minor`,
|
||||
`Premium/pax: ${premiumPerPassenger} ETB minor`,
|
||||
`Insurance/pax: ${insurancePerPassenger} ETB minor`,
|
||||
`Total fare/pax: ${farePerPassengerMinor} ETB minor`,
|
||||
@@ -132,10 +175,8 @@ export class FareEngineService {
|
||||
``,
|
||||
`Subtotal: ${subtotalMinor} ETB minor`,
|
||||
`Discount: ${promoLabel} → -${discountMinor} ETB minor`,
|
||||
`Tax (5%): +${taxMinor} ETB minor`,
|
||||
`Total (ETB): ${totalEtbMinor} ETB minor`,
|
||||
``,
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||
`Fare source: ${fareSource}`,
|
||||
@@ -146,7 +187,8 @@ export class FareEngineService {
|
||||
routeCode: route.code,
|
||||
originName: originStation?.name ?? dto.originStationId,
|
||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||
seatClassName: seatClass.name,
|
||||
seatClassId: nationalitySeatClass.id,
|
||||
seatClassName: nationalitySeatClass.name,
|
||||
totalDistanceKm,
|
||||
ratePerKmMinor,
|
||||
baseFarePerPassengerMinor,
|
||||
@@ -159,7 +201,6 @@ export class FareEngineService {
|
||||
paidChildrenCount,
|
||||
subtotalMinor,
|
||||
discountMinor,
|
||||
taxMinor,
|
||||
totalMinor: totalEtbMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency,
|
||||
@@ -284,16 +325,13 @@ export class FareEngineService {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
return fareRules.map(rule => {
|
||||
const seatClassId = rule.seatClassId;
|
||||
const taxMinor = Math.round(rule.baseFareMinor * TAX_RATE);
|
||||
const totalMinor = rule.baseFareMinor + taxMinor;
|
||||
return {
|
||||
seatClassId,
|
||||
seatClassName: 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
taxMinor,
|
||||
totalMinor,
|
||||
totalMinor: rule.baseFareMinor,
|
||||
billingCurrency,
|
||||
totalInBillingCurrency: Math.round(totalMinor * exchangeRate),
|
||||
totalInBillingCurrency: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
exchangeRate,
|
||||
source: 'FARE_RULE',
|
||||
};
|
||||
|
||||
@@ -79,11 +79,10 @@ export class UpdateClassDto {
|
||||
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
|
||||
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
@ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
}
|
||||
|
||||
export class GenerateSeatMapDto {
|
||||
|
||||
@@ -258,6 +258,8 @@ export class FleetService {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
baseFareMinor: dto.baseFareMinor,
|
||||
premiumMinor: dto.premiumMinor,
|
||||
insuranceFeeMinor: dto.insuranceFeeMinor,
|
||||
};
|
||||
|
||||
if (dto.isActive !== undefined) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Routes')
|
||||
@@ -93,4 +93,35 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
||||
@ApiResponse({ status: 200, description: 'Schedules with train and terminal station details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getSchedules(@Param('id') id: string) { return this.service.getSchedulesForRoute(id); }
|
||||
|
||||
// ── Route Coach Template ───────────────────────────────────────────────────
|
||||
|
||||
@Get(':id/coaches')
|
||||
@ApiOperation({ summary: 'Get the default coach lineup for this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered coach template with coach and coach type details' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
|
||||
|
||||
@Put(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Set the default coach lineup for this route',
|
||||
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Updated coach template' })
|
||||
@ApiResponse({ status: 400, description: 'Duplicate positions or inactive coach' })
|
||||
@ApiResponse({ status: 404, description: 'Route or coach not found' })
|
||||
setCoachTemplate(@Param('id') id: string, @Body() dto: SetRouteCoachTemplateDto) {
|
||||
return this.service.setRouteCoachTemplate(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
|
||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Template cleared' })
|
||||
@ApiResponse({ status: 404, description: 'Route not found' })
|
||||
clearCoachTemplate(@Param('id') id: string) { return this.service.removeRouteCoachTemplate(id); }
|
||||
}
|
||||
|
||||
@@ -44,3 +44,14 @@ export class UpdateRouteDto {
|
||||
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
|
||||
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
|
||||
}
|
||||
|
||||
export class RouteCoachTemplateItemDto {
|
||||
@ApiProperty({ example: 'coach-uuid', description: 'Coach UUID' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1, description: 'Position in the train consist (1 = first coach)' }) @IsInt() @Min(1) positionNumber: number;
|
||||
}
|
||||
|
||||
export class SetRouteCoachTemplateDto {
|
||||
@ApiProperty({ type: [RouteCoachTemplateItemDto], description: 'Ordered list of coaches for this route. Replaces the existing template.' })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => RouteCoachTemplateItemDto)
|
||||
coaches: RouteCoachTemplateItemDto[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
@Injectable()
|
||||
@@ -202,6 +202,44 @@ export class RoutesService {
|
||||
});
|
||||
}
|
||||
|
||||
async getRouteCoachTemplate(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
return this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId },
|
||||
include: { coach: { include: { coachType: true } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async setRouteCoachTemplate(routeId: string, dto: SetRouteCoachTemplateDto) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const coachIds = dto.coaches.map(c => c.coachId);
|
||||
const coaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||
if (coaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||
const inactive = coaches.find(c => c.status !== 'ACTIVE');
|
||||
if (inactive) throw new BadRequestException(`Coach ${inactive.number} is not active`);
|
||||
|
||||
const positions = dto.coaches.map(c => c.positionNumber);
|
||||
if (new Set(positions).size !== positions.length) throw new BadRequestException('Duplicate positionNumber values');
|
||||
|
||||
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
|
||||
await this.prisma.routeCoachTemplate.createMany({
|
||||
data: dto.coaches.map(c => ({ routeId, coachId: c.coachId, positionNumber: c.positionNumber })),
|
||||
});
|
||||
|
||||
return this.getRouteCoachTemplate(routeId);
|
||||
}
|
||||
|
||||
async removeRouteCoachTemplate(routeId: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id: routeId } });
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
await this.prisma.routeCoachTemplate.deleteMany({ where: { routeId } });
|
||||
return { deleted: true, routeId };
|
||||
}
|
||||
|
||||
// ── Used by SchedulesService ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
@@ -132,6 +132,23 @@ export class SchedulesController {
|
||||
@Body() dto: UpdateStopTimeDto,
|
||||
) { return this.service.updateStop(id, sequence, dto); }
|
||||
|
||||
@Put(':scheduleId/fares/:seatClassId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Override fare for a specific seat class on a schedule',
|
||||
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
|
||||
})
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule upserted' })
|
||||
upsertScheduleFare(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Param('seatClassId') seatClassId: string,
|
||||
@Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
|
||||
) {
|
||||
return this.service.upsertScheduleFare(scheduleId, seatClassId, dto);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares/stored')
|
||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
|
||||
@@ -119,7 +119,7 @@ export class BulkCreateSchedulesDto {
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule' })
|
||||
@ApiPropertyOptional({ type: [String], description: 'Optional coach UUIDs to assign to every generated schedule. Overrides the route coach template if provided.' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ export class SchedulesService {
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// createSchedule already auto-applies the route coach template;
|
||||
// only override if explicit coachIds are provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
@@ -177,6 +179,18 @@ export class SchedulesService {
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
// Auto-apply route coach template if one is defined
|
||||
const coachTemplates = await this.prisma.routeCoachTemplate.findMany({
|
||||
where: { routeId: dto.routeId },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
if (coachTemplates.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })),
|
||||
);
|
||||
}
|
||||
|
||||
return this.getSchedule(schedule.id);
|
||||
}
|
||||
|
||||
@@ -402,6 +416,34 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertScheduleFare(
|
||||
scheduleId: string,
|
||||
seatClassId: string,
|
||||
dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
|
||||
) {
|
||||
const [schedule, seatClass] = await Promise.all([
|
||||
this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }),
|
||||
this.prisma.seatClass.findUnique({ where: { id: seatClassId } }),
|
||||
]);
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
const now = new Date();
|
||||
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
|
||||
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.fareRule.updateMany({
|
||||
where: { tripId: scheduleId, seatClassId, validUntil: null },
|
||||
data: { validUntil: now },
|
||||
});
|
||||
return tx.fareRule.create({
|
||||
data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil },
|
||||
include: { seatClass: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createFareRule(dto: CreateFareRuleDto) {
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.fareRule.create({
|
||||
@@ -555,10 +597,10 @@ export class SchedulesService {
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
const data = coaches.map((c, idx) => ({
|
||||
const data = coaches.map((c) => ({
|
||||
scheduleId,
|
||||
coachId: c.coachId,
|
||||
positionNumber: idx + 1,
|
||||
positionNumber: c.positionNumber,
|
||||
isOperational: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -418,6 +418,7 @@ export class SearchService {
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||
|
||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||
@@ -426,56 +427,25 @@ export class SearchService {
|
||||
}
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
||||
if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`);
|
||||
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
||||
const now = new Date();
|
||||
const nationality = dto.nationality;
|
||||
|
||||
const candidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId: seatClass?.id,
|
||||
validFrom: { lte: now },
|
||||
OR: [
|
||||
{ validUntil: null },
|
||||
{ validUntil: { gte: now } },
|
||||
],
|
||||
},
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
seatClassId: seatClass.id,
|
||||
nationality: dto.nationality,
|
||||
scheduleId: dto.scheduleId,
|
||||
adultCount: dto.adultCount,
|
||||
childCount: dto.childCount ?? 0,
|
||||
promoCode: dto.promoCode,
|
||||
});
|
||||
|
||||
const bestMatch = this.selectBestFareRule(
|
||||
candidates,
|
||||
dto.scheduleId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
nationality,
|
||||
);
|
||||
|
||||
const baseFareMinor = bestMatch?.baseFareMinor
|
||||
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
|
||||
|
||||
const adultCount = dto.adultCount;
|
||||
const childCount = dto.childCount ?? 0;
|
||||
const adultFareMinor = baseFareMinor * adultCount;
|
||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
||||
|
||||
let discountMinor = 0;
|
||||
if (dto.promoCode) {
|
||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
||||
if (promo?.active && promo.validUntil > now) {
|
||||
discountMinor = promo.percentOff
|
||||
? Math.round(totalBaseFareMinor * promo.percentOff / 100)
|
||||
: (promo.amountOffMinor ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
||||
const taxesMinor = 0;
|
||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
||||
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
|
||||
|
||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
|
||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
@@ -487,13 +457,23 @@ export class SearchService {
|
||||
segmentRoute,
|
||||
seatClassName: dto.seatClassName,
|
||||
nationality: dto.nationality,
|
||||
adultCount, childCount,
|
||||
baseFareMinor, adultFareMinor, childFareMinor,
|
||||
freeChildrenCount: Math.min(childCount, 1),
|
||||
paidChildrenCount, totalBaseFareMinor,
|
||||
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
|
||||
taxesFeesMinor: taxesMinor, totalMinor,
|
||||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
||||
adultCount: fare.adultCount,
|
||||
childCount: fare.childCount,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
|
||||
childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
|
||||
freeChildrenCount: fare.freeChildrenCount,
|
||||
paidChildrenCount: fare.paidChildrenCount,
|
||||
premiumMinor: fare.premiumPerPassenger,
|
||||
insuranceFeeMinor: fare.insurancePerPassenger,
|
||||
totalBaseFareMinor: fare.subtotalMinor,
|
||||
discountMinor: fare.discountMinor,
|
||||
taxesFeesMinor: 0,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -505,15 +485,19 @@ export class SearchService {
|
||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||
|
||||
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
|
||||
const seatClassMap = new Map<string, any>();
|
||||
// Collect seat class IDs from the schedule include for the ID set,
|
||||
// but fetch fresh records from DB so updated baseFareMinor is always current
|
||||
const seatClassIdSet = new Set<string>();
|
||||
for (const a of schedule.coachAssignments) {
|
||||
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
||||
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
|
||||
if (sc.isActive) seatClassIdSet.add(sc.id);
|
||||
}
|
||||
}
|
||||
const seatClasses = Array.from(seatClassMap.values())
|
||||
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
|
||||
const freshSeatClasses = await this.prisma.seatClass.findMany({
|
||||
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
|
||||
});
|
||||
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
|
||||
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||
|
||||
if (seatClasses.length === 0) return [];
|
||||
|
||||
@@ -531,9 +515,9 @@ export class SearchService {
|
||||
});
|
||||
return {
|
||||
seatClassName: fare.seatClassName,
|
||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||
baseFareMinor: fare.totalMinor,
|
||||
displayCurrency: fare.billingCurrency as Currency,
|
||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
||||
displayAmountMinor: fare.totalInBillingCurrency,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -568,12 +552,16 @@ export class SearchService {
|
||||
|
||||
if (fareRules.length > 0) {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||
return fareRules.map(rule => ({
|
||||
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
||||
baseFareMinor: rule.baseFareMinor,
|
||||
displayCurrency,
|
||||
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
||||
}));
|
||||
const TAX_RATE = 0.05;
|
||||
return fareRules.map(rule => {
|
||||
const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
|
||||
return {
|
||||
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
||||
baseFareMinor: totalMinor,
|
||||
displayCurrency,
|
||||
displayAmountMinor: Math.round(totalMinor * exchangeRate),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,54 +632,4 @@ export class SearchService {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
|
||||
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||
const fare = await this.fareEngine.calculate({
|
||||
routeId: schedule.routeId,
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
});
|
||||
return fare.baseFarePerPassengerMinor;
|
||||
}
|
||||
|
||||
private selectBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId: string,
|
||||
segmentRoute: string,
|
||||
fullRoute: string,
|
||||
nationality?: string,
|
||||
): any | null {
|
||||
const priorities = [
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: null, nationality },
|
||||
{ tripId: scheduleId, route: null, nationality: null },
|
||||
{ tripId: null, route: segmentRoute, nationality },
|
||||
{ tripId: null, route: segmentRoute, nationality: null },
|
||||
{ tripId: null, route: fullRoute, nationality },
|
||||
{ tripId: null, route: fullRoute, nationality: null },
|
||||
{ tripId: null, route: null, nationality },
|
||||
{ tripId: null, route: null, nationality: null },
|
||||
];
|
||||
|
||||
for (const priority of priorities) {
|
||||
const match = candidates.find(
|
||||
(c) =>
|
||||
c.tripId === priority.tripId &&
|
||||
c.route === priority.route &&
|
||||
c.nationality === priority.nationality,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,31 @@ export class SeatClassesService {
|
||||
return sc;
|
||||
}
|
||||
|
||||
async updateSeatClass(id: string, dto: any) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
const { basePrice, ...rest } = dto;
|
||||
const data = {
|
||||
...rest,
|
||||
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||
};
|
||||
return this.prisma.seatClass.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async createSeatClass(dto: any) {
|
||||
try {
|
||||
return await this.prisma.seatClass.create({ data: dto });
|
||||
const { basePrice, ...rest } = dto;
|
||||
const data = {
|
||||
...rest,
|
||||
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||
};
|
||||
return await this.prisma.seatClass.create({ data });
|
||||
} catch (e: any) {
|
||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSeatClass(id: string, dto: any) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
return this.prisma.seatClass.update({ where: { id }, data: dto });
|
||||
}
|
||||
|
||||
async deleteSeatClass(id: string) {
|
||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -2,12 +2,20 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
|
||||
/** Maximum time (hours) a passenger has to pay after booking. */
|
||||
const MAX_PAYMENT_HOURS = 2;
|
||||
/** Minutes before departure: cutoff for new bookings and payment deadline. */
|
||||
const CUTOFF_MINUTES = 30;
|
||||
|
||||
// Retention windows
|
||||
const OTP_RETENTION_HOURS = 1;
|
||||
const FAYDA_SESSION_RETENTION_HOURS = 1;
|
||||
const AUDIT_LOG_RETENTION_DAYS = 365;
|
||||
const WEBHOOK_EVENT_RETENTION_DAYS = 90;
|
||||
const GATE_LOG_RETENTION_DAYS = 180;
|
||||
|
||||
/**
|
||||
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
|
||||
*/
|
||||
@@ -32,6 +40,7 @@ export class TasksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -243,4 +252,47 @@ export class TasksService {
|
||||
this.logger.log(`Auto-cancelled ${cancelledCount} expired pending booking(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 01:00 EAT: fetch mid-market rates from central bank API.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' })
|
||||
async syncExchangeRates() {
|
||||
try {
|
||||
await this.currencyService.syncExchangeRates();
|
||||
} catch (err) {
|
||||
this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('0 2 * * *')
|
||||
async purgeExpiredData() {
|
||||
const now = new Date();
|
||||
|
||||
const otpCutoff = new Date(now.getTime() - OTP_RETENTION_HOURS * 60 * 60 * 1000);
|
||||
const faydaCutoff = new Date(now.getTime() - FAYDA_SESSION_RETENTION_HOURS * 60 * 60 * 1000);
|
||||
const auditCutoff = new Date(now.getTime() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
const webhookCutoff = new Date(now.getTime() - WEBHOOK_EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
const gateCutoff = new Date(now.getTime() - GATE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [otps, faydaSessions, auditLogs, webhookEvents, gateLogs] = await Promise.all([
|
||||
this.prisma.otpCode.deleteMany({
|
||||
where: { OR: [{ expiresAt: { lte: otpCutoff } }, { verified: true, createdAt: { lte: otpCutoff } }] },
|
||||
}),
|
||||
this.prisma.faydaVerificationSession.deleteMany({
|
||||
where: { OR: [{ expiresAt: { lte: faydaCutoff } }, { status: { in: ['COMPLETED', 'FAILED'] }, createdAt: { lte: faydaCutoff } }] },
|
||||
}),
|
||||
this.prisma.auditLog.deleteMany({ where: { createdAt: { lte: auditCutoff } } }),
|
||||
this.prisma.paymentWebhookEvent.deleteMany({ where: { receivedAt: { lte: webhookCutoff } } }),
|
||||
this.prisma.gateValidationLog.deleteMany({ where: { validatedAt: { lte: gateCutoff } } }),
|
||||
]);
|
||||
|
||||
this.logger.log(
|
||||
`Data retention purge: ${otps.count} OTPs, ${faydaSessions.count} Fayda sessions, ` +
|
||||
`${auditLogs.count} audit logs, ${webhookEvents.count} webhook events, ${gateLogs.count} gate logs deleted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,8 @@ export class TicketsService {
|
||||
} : null,
|
||||
status: t.status,
|
||||
validatedAt: t.validatedAt,
|
||||
boardedAt: t.validatedAt,
|
||||
qrCode: t.qrPayload ?? null,
|
||||
createdAt: t.issuedAt,
|
||||
};
|
||||
}),
|
||||
@@ -247,9 +249,12 @@ export class TicketsService {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
// Build passenger QR data with all legs included
|
||||
const qrData = JSON.stringify({
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
// Re-encode QR with ticketNumber included
|
||||
const qrDataWithTicket = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
ticketNumber: barcodePayload,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
@@ -259,8 +264,7 @@ export class TicketsService {
|
||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||
})),
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket);
|
||||
|
||||
const ticket = await this.prisma.ticket.create({
|
||||
data: {
|
||||
@@ -270,7 +274,7 @@ export class TicketsService {
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload,
|
||||
qrPayload: qrPayloadFinal,
|
||||
barcodePayload,
|
||||
} as any,
|
||||
});
|
||||
@@ -371,15 +375,13 @@ export class TicketsService {
|
||||
let bookingRef = qrCodeOrRef;
|
||||
try {
|
||||
const qrData = JSON.parse(qrCodeOrRef);
|
||||
if (qrData.ref) {
|
||||
bookingRef = qrData.ref;
|
||||
}
|
||||
if (qrData.ref) bookingRef = qrData.ref;
|
||||
} catch {
|
||||
// Not JSON, treat as booking reference
|
||||
// Not JSON, treat as booking reference or ticket number
|
||||
}
|
||||
|
||||
// Get booking and ticket info
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
let booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
@@ -389,6 +391,23 @@ export class TicketsService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
// Input may be a ticket number (barcodePayload) — look it up
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } });
|
||||
if (ticket) {
|
||||
booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: ticket.bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
if (booking) bookingRef = (booking as any).bookingRef;
|
||||
}
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Ticket not found');
|
||||
}
|
||||
@@ -443,6 +462,7 @@ export class TicketsService {
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
boarding: {
|
||||
ticketId: ticket.id,
|
||||
ticketNumber: ticket.barcodePayload,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
unoptimized: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [isInitializing, setIsInitializing] = useState(false);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const scanIntervalRef = useRef<number | null>(null);
|
||||
|
||||
@@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
}
|
||||
|
||||
// First, stop any existing stream
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
setStream(null);
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Request camera access with simpler fallback
|
||||
@@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
}
|
||||
|
||||
// Set state to show video
|
||||
setStream(mediaStream);
|
||||
streamRef.current = mediaStream;
|
||||
setIsScanning(true);
|
||||
setIsInitializing(false);
|
||||
|
||||
@@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
onError(errorMsg);
|
||||
|
||||
// Clean up on error
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
setStream(null);
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsScanning(false);
|
||||
setIsInitializing(false);
|
||||
@@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
clearInterval(scanIntervalRef.current);
|
||||
scanIntervalRef.current = null;
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
setStream(null);
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setIsScanning(false);
|
||||
setCameraError(null);
|
||||
}, [stream]);
|
||||
}, []);
|
||||
|
||||
// QR code scanning with jsqr
|
||||
const scanFrame = useCallback(() => {
|
||||
@@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
|
||||
useEffect(() => {
|
||||
if (isScanning) {
|
||||
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
|
||||
}
|
||||
return () => {
|
||||
scanIntervalRef.current = window.setInterval(scanFrame, 100);
|
||||
} else {
|
||||
if (scanIntervalRef.current) {
|
||||
clearInterval(scanIntervalRef.current);
|
||||
scanIntervalRef.current = null;
|
||||
}
|
||||
stopCamera();
|
||||
};
|
||||
}, [isScanning, scanFrame, stopCamera]);
|
||||
}
|
||||
}, [isScanning, scanFrame]);
|
||||
|
||||
// Cleanup on unmount only
|
||||
useEffect(() => {
|
||||
return () => stopCamera();
|
||||
}, [stopCamera]);
|
||||
|
||||
// Load jsqr from CDN
|
||||
useEffect(() => {
|
||||
@@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
setStream(null);
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsInitializing(false);
|
||||
setCameraError('Camera initialization cancelled by user');
|
||||
@@ -348,7 +352,7 @@ export default function BoardingPage() {
|
||||
if (result.success) {
|
||||
setSuccess('Passenger boarded successfully!');
|
||||
setLastScanned(result.boarding);
|
||||
setQrInput('');
|
||||
setQrInput(result.boarding?.ticketNumber || result.boarding?.bookingRef || '');
|
||||
// Auto-focus for next scan
|
||||
setTimeout(() => inputRef.current?.focus(), 1000);
|
||||
} else {
|
||||
@@ -442,7 +446,13 @@ export default function BoardingPage() {
|
||||
{/* Camera Scanner */}
|
||||
<QRScanner
|
||||
onScan={(data) => {
|
||||
setQrInput(data);
|
||||
let displayValue = data;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.ticketNumber) displayValue = parsed.ticketNumber;
|
||||
else if (parsed.ref) displayValue = parsed.ref;
|
||||
} catch { /* not JSON, use raw */ }
|
||||
setQrInput(displayValue);
|
||||
handleScan(data);
|
||||
}}
|
||||
onError={setError}
|
||||
@@ -537,7 +547,7 @@ export default function BoardingPage() {
|
||||
)}
|
||||
|
||||
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
|
||||
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
|
||||
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketNumber}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
|
||||
@@ -565,7 +575,7 @@ export default function BoardingPage() {
|
||||
<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 ticket number</li>
|
||||
<li>• Tickets can only be boarded on their departure date</li>
|
||||
<li>• First scan boards outbound leg for round trips</li>
|
||||
<li>• Email & SMS sent automatically to passenger contacts</li>
|
||||
|
||||
@@ -75,9 +75,9 @@ export default function ClassesPage() {
|
||||
coachTypeId: selectedCoachTypeId,
|
||||
name: formData.get('name') as string,
|
||||
description: formData.get('description') as string,
|
||||
baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0,
|
||||
premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0,
|
||||
insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0,
|
||||
baseFareMinor: Math.round(Number((parseFloat(formData.get('baseFareMinor') as string) * 100).toFixed(10))) || 0,
|
||||
premiumMinor: Math.round(Number((parseFloat(formData.get('premiumMinor') as string) * 100).toFixed(10))) || 0,
|
||||
insuranceFeeMinor: Math.round(Number((parseFloat(formData.get('insuranceFeeMinor') as string) * 100).toFixed(10))) || 0,
|
||||
isActive: formData.get('isActive') === 'true',
|
||||
};
|
||||
|
||||
@@ -129,13 +129,6 @@ export default function ClassesPage() {
|
||||
label: 'Class Name',
|
||||
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
render: (cls: any) => (
|
||||
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'baseFareMinor',
|
||||
label: 'Base Fare',
|
||||
@@ -251,7 +244,7 @@ export default function ClassesPage() {
|
||||
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="label">Coach Type *</label>
|
||||
@@ -265,7 +258,7 @@ export default function ClassesPage() {
|
||||
<option value="">Select Coach Type</option>
|
||||
{coachTypesArray.map((ct: any) => (
|
||||
<option key={ct.id} value={ct.id}>
|
||||
{ct.code} - {ct.name}
|
||||
{ct.code} - {ct.name} - {ct.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -283,17 +276,6 @@ export default function ClassesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Description</label>
|
||||
<textarea
|
||||
name="description"
|
||||
className="input"
|
||||
rows={3}
|
||||
defaultValue={editingClass?.description || ''}
|
||||
placeholder="Describe this class..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||
|
||||
@@ -345,8 +327,8 @@ export default function ClassesPage() {
|
||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
||||
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
|
||||
<p className="mt-2 text-xs">• Premium applies per passenger (including free child)</p>
|
||||
<p className="text-xs">• Insurance applies per passenger (including free child)</p>
|
||||
<p className="mt-2 text-xs">• Premium applies per passenger</p>
|
||||
<p className="text-xs">• Insurance applies per passenger</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -731,7 +731,7 @@ export default function CoachesPage() {
|
||||
<option value="">Select Coach Type</option>
|
||||
{coachTypesArray.map((ct: any) => (
|
||||
<option key={ct.id} value={ct.id}>
|
||||
{ct.code} - {ct.name}
|
||||
{ct.code} - {ct.name} - {ct.type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -34,7 +34,7 @@ interface SeatClass {
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule');
|
||||
const [tab, setTab] = useState<'segment' | 'schedule' | 'baggage'>('segment');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
|
||||
const [selectedRoute, setSelectedRoute] = useState<string>('');
|
||||
@@ -103,7 +103,7 @@ export default function PricingPage() {
|
||||
queryFn: async () => {
|
||||
if (!selectedSchedule) return [];
|
||||
try {
|
||||
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`);
|
||||
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/stored`);
|
||||
return Array.isArray(response) ? response : (response as any)?.data || [];
|
||||
} catch (err: any) {
|
||||
const errMsg = err.response?.data?.message || err.message || 'Failed to load fares';
|
||||
@@ -175,7 +175,7 @@ export default function PricingPage() {
|
||||
});
|
||||
|
||||
const updateFareMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.patch(`/schedules/fares/${data.id}`, data),
|
||||
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/fares/${id}`, data),
|
||||
onSuccess: () => {
|
||||
refetchFares();
|
||||
setEditingFare(null);
|
||||
@@ -264,10 +264,12 @@ export default function PricingPage() {
|
||||
};
|
||||
|
||||
const handleEditFare = (fare: any) => {
|
||||
setEditingFare(fare);
|
||||
// Engine-calculated fares have no `id` — open as new rule pre-filled with engine values
|
||||
setEditingFare(fare.id ? fare : null);
|
||||
const minor = fare.totalMinor ?? fare.baseFareMinor ?? fare.baseFare ?? 0;
|
||||
setFareForm({
|
||||
seatClassId: fare.seatClassId || '',
|
||||
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
|
||||
baseFare: (minor / 100).toFixed(2),
|
||||
nationality: fare.nationality || '',
|
||||
passengerCategory: fare.passengerCategory || '',
|
||||
route: fare.route || '',
|
||||
@@ -288,7 +290,7 @@ export default function PricingPage() {
|
||||
seatClassId: fare.seatClassId || '',
|
||||
originStationId: originStop?.stationId || '',
|
||||
destinationStationId: destStop?.stationId || '',
|
||||
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
|
||||
baseFare: ((fare.baseFare || fare.baseFareMinor || 0) / 100).toFixed(2),
|
||||
nationality: fare.nationality || '',
|
||||
passengerCategory: fare.passengerCategory || '',
|
||||
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||||
@@ -305,7 +307,7 @@ export default function PricingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseFareMinor = parseInt(fareForm.baseFare, 10);
|
||||
const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100);
|
||||
|
||||
if (editingFare) {
|
||||
await updateFareMutation.mutateAsync({
|
||||
@@ -353,7 +355,7 @@ export default function PricingPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseFareMinor = parseInt(segmentForm.baseFare, 10);
|
||||
const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100);
|
||||
|
||||
if (editingFare) {
|
||||
await updateSegmentFareMutation.mutateAsync({
|
||||
@@ -422,9 +424,9 @@ export default function PricingPage() {
|
||||
key: 'baseFare',
|
||||
label: 'Fare (ETB)',
|
||||
render: (fare: any) => {
|
||||
const fareValue = fare.baseFare || fare.baseFareMinor;
|
||||
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
|
||||
return <span className="font-mono font-medium">{fareValue} ETB</span>;
|
||||
const minor = fare.baseFareMinor ?? fare.baseFare;
|
||||
if (minor == null) return <span className="text-muted-foreground">—</span>;
|
||||
return <span className="font-mono font-medium">{(minor / 100).toFixed(2)} </span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -438,7 +440,7 @@ export default function PricingPage() {
|
||||
key: 'route',
|
||||
label: 'Route',
|
||||
render: (fare: any) => (
|
||||
<span className="text-sm font-mono">{fare.route || '-'}</span>
|
||||
<span className="text-sm font-mono">{fare.routeCode || fare.route || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -467,9 +469,11 @@ export default function PricingPage() {
|
||||
const stops = currentRoute?.stops || [];
|
||||
const originStop = stops.find((s: any) => s.sequence === fare.originStopSequence);
|
||||
const destStop = stops.find((s: any) => s.sequence === fare.destinationStopSequence);
|
||||
const originCode = stationsArray.find((s: any) => s.id === originStop?.stationId)?.code ?? `Stop ${fare.originStopSequence}`;
|
||||
const destCode = stationsArray.find((s: any) => s.id === destStop?.stationId)?.code ?? `Stop ${fare.destinationStopSequence}`;
|
||||
return (
|
||||
<span className="text-sm font-medium">
|
||||
Stop {fare.originStopSequence} → {fare.destinationStopSequence}
|
||||
<span className="text-sm font-medium font-mono">
|
||||
{originCode} → {destCode}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -479,7 +483,7 @@ export default function PricingPage() {
|
||||
label: 'Seat Class',
|
||||
render: (fare: any) => {
|
||||
const className = fare.seatClass?.name || 'N/A';
|
||||
return <span className="font-medium">{className}</span>;
|
||||
return <span>{className}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -493,9 +497,9 @@ export default function PricingPage() {
|
||||
key: 'baseFare',
|
||||
label: 'Fare (ETB)',
|
||||
render: (fare: any) => {
|
||||
const fareValue = fare.baseFare || fare.baseFareMinor;
|
||||
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
|
||||
return <span className="font-mono font-medium">{fareValue} ETB</span>;
|
||||
const fareValue = fare.baseFare ?? fare.baseFareMinor;
|
||||
if (fareValue == null) return <span className="text-muted-foreground">—</span>;
|
||||
return <span className="font-mono font-medium">{(fareValue / 100).toFixed(2)} </span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -529,14 +533,12 @@ export default function PricingPage() {
|
||||
onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
disabled: tab === 'schedule', // Schedule fares are computed, not stored
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
disabled: tab === 'schedule', // Schedule fares are computed, not stored
|
||||
},
|
||||
];
|
||||
|
||||
@@ -588,89 +590,30 @@ export default function PricingPage() {
|
||||
|
||||
<div className="card">
|
||||
<div className="flex gap-4 border-b mb-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTab('schedule');
|
||||
setError(null);
|
||||
}}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Schedule Fares
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTab('segment'); setError(null); }}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Segment Fares
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTab('schedule'); setError(null); }}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Schedule Fares
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTab('baggage'); setError(null); }}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Excess Baggage Rates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{tab === 'schedule' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => {
|
||||
setSelectedSchedule(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="input w-full max-w-md"
|
||||
>
|
||||
<option value="">Choose a schedule...</option>
|
||||
{schedulesArray.map((schedule: Schedule) => (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedSchedule && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Calculated Fares</h3>
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||
These are <strong>dynamically calculated</strong> fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
|
||||
</div>
|
||||
{faresLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : faresArray.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No fares available for this schedule.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
{faresArray.length} seat class(es) available
|
||||
</div>
|
||||
<DataTable
|
||||
data={faresArray}
|
||||
columns={fareColumns}
|
||||
actions={fareActions}
|
||||
loading={false}
|
||||
emptyMessage="No fares available."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'segment' && (
|
||||
<>
|
||||
@@ -722,6 +665,61 @@ export default function PricingPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'schedule' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => {
|
||||
setSelectedSchedule(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="input w-full max-w-md"
|
||||
>
|
||||
<option value="">Choose a schedule...</option>
|
||||
{schedulesArray.map((schedule: Schedule) => (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedSchedule && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Stored Fare Rules</h3>
|
||||
{faresLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : faresArray.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No fare rules defined for this schedule. Click "Add Fare Rule" to create one.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||
These are <strong>stored fare override rules</strong> for this schedule. Click "Add Fare Rule" to create one. If no rules exist, the fare engine calculates fares automatically.
|
||||
</div>
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
{faresArray.length} fare rule(s) defined
|
||||
</div>
|
||||
<DataTable
|
||||
data={faresArray}
|
||||
columns={fareColumns}
|
||||
actions={fareActions}
|
||||
loading={false}
|
||||
emptyMessage="No fares available."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'baggage' && (
|
||||
<>
|
||||
{allowancesLoading ? (
|
||||
@@ -736,7 +734,7 @@ export default function PricingPage() {
|
||||
columns={[
|
||||
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
|
||||
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
|
||||
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
|
||||
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} </span> },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
@@ -771,10 +769,10 @@ export default function PricingPage() {
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
|
||||
<li>
|
||||
• <strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
|
||||
• <strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
|
||||
• <strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
|
||||
@@ -876,11 +874,11 @@ export default function PricingPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
value={fareForm.baseFare}
|
||||
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., 350"
|
||||
placeholder="e.g., 350.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -1006,11 +1004,11 @@ export default function PricingPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
step="0.01"
|
||||
value={segmentForm.baseFare}
|
||||
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., 150"
|
||||
placeholder="e.g., 150.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { stationsApi } from '@/lib/api';
|
||||
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
||||
|
||||
interface RouteStop {
|
||||
stationId: string;
|
||||
@@ -18,7 +18,153 @@ interface RouteStop {
|
||||
distanceFromOrigin?: number;
|
||||
}
|
||||
|
||||
type Tab = 'routes' | 'coaches';
|
||||
|
||||
function RouteCoachesTab({ routes }: { routes: any[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedRouteId, setSelectedRouteId] = useState('');
|
||||
const [coachRows, setCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: coaches } = useQuery({
|
||||
queryKey: ['coaches-all'],
|
||||
queryFn: () => fleetApi.getCoaches(),
|
||||
});
|
||||
|
||||
const { data: template, isLoading: templateLoading } = useQuery({
|
||||
queryKey: ['route-coaches', selectedRouteId],
|
||||
queryFn: () => routeCoachTemplatesApi.get(selectedRouteId),
|
||||
enabled: !!selectedRouteId,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => routeCoachTemplatesApi.set(selectedRouteId, coachRows),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] }),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => routeCoachTemplatesApi.clear(selectedRouteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] });
|
||||
setCoachRows([]);
|
||||
},
|
||||
});
|
||||
|
||||
// Sync coachRows when template loads
|
||||
useEffect(() => {
|
||||
const rows: any[] = Array.isArray(template) ? template : (template as any)?.coaches ?? [];
|
||||
if (rows.length) setCoachRows(rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })));
|
||||
}, [template]);
|
||||
|
||||
const allCoaches: any[] = (coaches as any)?.items ?? (Array.isArray(coaches) ? coaches : []);
|
||||
|
||||
const addRow = () => setCoachRows([...coachRows, { coachId: '', positionNumber: coachRows.length + 1 }]);
|
||||
const removeRow = (i: number) => {
|
||||
const updated = coachRows.filter((_, idx) => idx !== i);
|
||||
setCoachRows(updated.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
};
|
||||
const updateRow = (i: number, field: 'coachId', val: string) => {
|
||||
const updated = [...coachRows];
|
||||
updated[i] = { ...updated[i], [field]: val };
|
||||
setCoachRows(updated);
|
||||
};
|
||||
|
||||
const selectedCoachIds = new Set(coachRows.map((r) => r.coachId).filter(Boolean));
|
||||
|
||||
const handleCoachDragStart = (e: React.DragEvent, index: number) => {
|
||||
e.dataTransfer.setData('coach-index', index.toString());
|
||||
};
|
||||
const handleCoachDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '0.5';
|
||||
};
|
||||
const handleCoachDragLeave = (e: React.DragEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
};
|
||||
const handleCoachDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const sourceIndex = parseInt(e.dataTransfer.getData('coach-index'));
|
||||
if (sourceIndex === targetIndex) return;
|
||||
const reordered = [...coachRows];
|
||||
const [moved] = reordered.splice(sourceIndex, 1);
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
setCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="label">Select Route</label>
|
||||
<select className="input" value={selectedRouteId} onChange={(e) => { setSelectedRouteId(e.target.value); setCoachRows([]); }}>
|
||||
<option value="">— choose a route —</option>
|
||||
{routes.map((r: any) => <option key={r.id} value={r.id}>{r.name} ({r.code})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRouteId && (
|
||||
<>
|
||||
{templateLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading template…</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{coachRows.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder coaches</p>
|
||||
)}
|
||||
{coachRows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={(e) => handleCoachDragStart(e, i)}
|
||||
onDragOver={handleCoachDragOver}
|
||||
onDragLeave={handleCoachDragLeave}
|
||||
onDrop={(e) => handleCoachDrop(e, i)}
|
||||
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="w-8 text-center text-sm font-medium text-muted-foreground flex-shrink-0">
|
||||
{row.positionNumber}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select className="input input-sm" value={row.coachId} onChange={(e) => updateRow(i, 'coachId', e.target.value)}>
|
||||
<option value="">Select Coach</option>
|
||||
{allCoaches
|
||||
.filter((c: any) => !selectedCoachIds.has(c.id) || c.id === row.coachId)
|
||||
.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.number} — {c.coachType?.name ?? c.type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeRow(i)} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<ActionButton type="button" variant="secondary" size="sm" icon={Plus} onClick={addRow}>Add Coach</ActionButton>
|
||||
<div className="flex gap-2">
|
||||
{coachRows.length > 0 && (
|
||||
<ActionButton type="button" variant="danger" size="sm" onClick={() => clearMutation.mutate()} loading={clearMutation.isPending}>
|
||||
Clear
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton type="button" size="sm" icon={Save} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||
Save Template
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('routes');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
const [stops, setStops] = useState<RouteStop[]>([]);
|
||||
@@ -84,21 +230,18 @@ export default function RoutesPage() {
|
||||
// Keep current stop order (already rearranged by user)
|
||||
const sortedMiddleStops = stops;
|
||||
|
||||
// Calculate distanceKm (distance from previous stop)
|
||||
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||
const stopsArray = [
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
|
||||
...sortedMiddleStops.map((stop, idx) => {
|
||||
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
|
||||
return {
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 2,
|
||||
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
|
||||
};
|
||||
}),
|
||||
...sortedMiddleStops.map((stop, idx) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 2,
|
||||
distanceKm: stop.distanceFromOrigin || 0,
|
||||
})),
|
||||
{
|
||||
stationId: destinationStationId,
|
||||
sequence: sortedMiddleStops.length + 2,
|
||||
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
|
||||
distanceKm: destinationDistance || 0,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -218,19 +361,14 @@ export default function RoutesPage() {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// 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);
|
||||
// distanceKm is cumulative from origin — read directly
|
||||
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
|
||||
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: allStops[idx + 1]._cumulative,
|
||||
distanceFromOrigin: stop.distanceKm || 0,
|
||||
}));
|
||||
setStops(middleStops);
|
||||
}
|
||||
@@ -252,42 +390,69 @@ export default function RoutesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Routes</h1>
|
||||
<p className="text-muted-foreground">Manage railway routes</p>
|
||||
<p className="text-muted-foreground">Manage railway routes and coach templates</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Route
|
||||
</ActionButton>
|
||||
{activeTab === 'routes' && (
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Route
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code, name, or description..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{(['routes', 'coaches'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab === 'coaches' ? 'Coach Templates' : 'Routes'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
emptyMessage={search ? "No routes match your search" : "No routes found"}
|
||||
/>
|
||||
{activeTab === 'routes' && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code, name, or description..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'coaches' && (
|
||||
<RouteCoachesTab routes={filteredRoutes} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X } from 'lucide-react';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -46,6 +47,7 @@ interface Coach {
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
||||
@@ -59,12 +61,45 @@ export default function SchedulesPage() {
|
||||
trainId: '',
|
||||
routeId: '',
|
||||
startDateTime: '',
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [] as string[],
|
||||
durationHours: '10',
|
||||
repeatEveryDays: '2',
|
||||
forNextDays: '15',
|
||||
});
|
||||
|
||||
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
||||
queryKey: ['route-coaches', addForm.routeId],
|
||||
queryFn: () => routeCoachTemplatesApi.get(addForm.routeId),
|
||||
enabled: !!addForm.routeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!addForm.routeId) { setAddCoachRows([]); return; }
|
||||
const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? [];
|
||||
setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []);
|
||||
}, [singleRouteTemplate, addForm.routeId]);
|
||||
|
||||
// Fetch route coach template when route changes
|
||||
const { data: routeTemplate, isLoading: templateLoading } = useQuery({
|
||||
queryKey: ['route-coaches', bulkForm.routeId],
|
||||
queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId),
|
||||
enabled: !!bulkForm.routeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkForm.routeId) { setBulkCoachRows([]); return; }
|
||||
const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? [];
|
||||
setBulkCoachRows(
|
||||
rows.length
|
||||
? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber }))
|
||||
: []
|
||||
);
|
||||
}, [routeTemplate, bulkForm.routeId]);
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
departureAt: '',
|
||||
arrivalAt: '',
|
||||
@@ -121,8 +156,8 @@ export default function SchedulesPage() {
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [],
|
||||
});
|
||||
setBulkCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -130,6 +165,20 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const createScheduleMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/schedules', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
setShowAddModal(false);
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
setAddCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to create schedule');
|
||||
},
|
||||
});
|
||||
|
||||
const updateScheduleMutation = useMutation({
|
||||
mutationFn: (data: { id: string; payload: any }) =>
|
||||
apiClient.patch(`/schedules/${data.id}`, data.payload),
|
||||
@@ -187,13 +236,30 @@ export default function SchedulesPage() {
|
||||
forNextDays: parseInt(bulkForm.forNextDays),
|
||||
};
|
||||
|
||||
if (bulkForm.coachIds.length > 0) {
|
||||
payload.coachIds = bulkForm.coachIds;
|
||||
const validCoaches = bulkCoachRows.filter((r) => r.coachId);
|
||||
if (validCoaches.length > 0) {
|
||||
payload.coachIds = validCoaches.map((r) => r.coachId);
|
||||
}
|
||||
|
||||
await bulkGenerateMutation.mutateAsync(payload);
|
||||
};
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const dep = new Date(addForm.departureAt);
|
||||
const arr = new Date(addForm.arrivalAt);
|
||||
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
||||
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
||||
await createScheduleMutation.mutateAsync({
|
||||
trainId: addForm.trainId,
|
||||
routeId: addForm.routeId,
|
||||
departureAt: dep.toISOString(),
|
||||
arrivalAt: arr.toISOString(),
|
||||
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -396,6 +462,13 @@ export default function SchedulesPage() {
|
||||
},
|
||||
] as any;
|
||||
|
||||
const cancelScheduleMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
|
||||
});
|
||||
|
||||
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||
|
||||
const scheduleActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
@@ -403,6 +476,13 @@ export default function SchedulesPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||
variant: 'danger' as const,
|
||||
icon: X,
|
||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
@@ -428,6 +508,13 @@ export default function SchedulesPage() {
|
||||
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
variant="secondary"
|
||||
onClick={() => { setError(null); setShowAddModal(true); }}
|
||||
>
|
||||
Add Schedule
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
icon={Zap}
|
||||
onClick={() => {
|
||||
@@ -530,6 +617,22 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={cancelConfirm.isOpen}
|
||||
onClose={() => setCancelConfirm({ isOpen: false, item: null })}
|
||||
onConfirm={async () => {
|
||||
if (cancelConfirm.item) {
|
||||
await cancelScheduleMutation.mutateAsync(cancelConfirm.item.id);
|
||||
setCancelConfirm({ isOpen: false, item: null });
|
||||
}
|
||||
}}
|
||||
title="Cancel Schedule"
|
||||
message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`}
|
||||
confirmText="Cancel Schedule"
|
||||
isDanger={true}
|
||||
isLoading={cancelScheduleMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
@@ -549,6 +652,107 @@ export default function SchedulesPage() {
|
||||
warning="Schedules with existing bookings cannot be deleted."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Train *</label>
|
||||
<select className="input" value={addForm.trainId} onChange={(e) => setAddForm({ ...addForm, trainId: e.target.value })} required>
|
||||
<option value="">Select Train</option>
|
||||
{trains.map((t: Train) => <option key={t.id} value={t.id}>{t.number} ({t.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Route *</label>
|
||||
<select className="input" value={addForm.routeId} onChange={(e) => setAddForm({ ...addForm, routeId: e.target.value })} required>
|
||||
<option value="">Select Route</option>
|
||||
{routes.map((r: Route) => <option key={r.id} value={r.id}>{r.code} ({r.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Departure *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Arrival *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label mb-0">Coaches</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{singleTemplateLoading && addForm.routeId && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template…</span>
|
||||
)}
|
||||
{!addForm.routeId && <span className="text-xs text-muted-foreground">Select a route to load its coach template</span>}
|
||||
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
|
||||
disabled={addCoachRows.filter(r => r.coachId).length >= coaches.length}
|
||||
onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}>
|
||||
Add Coach
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{addCoachRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">No coaches assigned.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{addCoachRows.length > 1 && <p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>}
|
||||
{addCoachRows.map((row, i) => {
|
||||
const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean));
|
||||
return (
|
||||
<div key={i} draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData('add-coach-idx', i.toString())}
|
||||
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
|
||||
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const src = parseInt(e.dataTransfer.getData('add-coach-idx'));
|
||||
if (src === i) return;
|
||||
const reordered = [...addCoachRows];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(i, 0, moved);
|
||||
setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
}}
|
||||
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
|
||||
<select className="input input-sm flex-1" value={row.coachId}
|
||||
onChange={(e) => { const u = [...addCoachRows]; u[i] = { ...u[i], coachId: e.target.value }; setAddCoachRows(u); }}>
|
||||
<option value="">Select Coach</option>
|
||||
{coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => (
|
||||
<option key={c.id} value={c.id}>{c.number || c.coachNumber} — {c.coachType?.name} (Cap: {c.capacity})</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => setAddCoachRows(addCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
@@ -561,8 +765,8 @@ export default function SchedulesPage() {
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [],
|
||||
});
|
||||
setBulkCoachRows([]);
|
||||
}}
|
||||
title="Bulk Generate Schedules"
|
||||
size="lg"
|
||||
@@ -611,7 +815,7 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Start Date & Time *</label>
|
||||
<label className="label">Departure Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={bulkForm.startDateTime}
|
||||
@@ -627,7 +831,7 @@ export default function SchedulesPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkForm.durationHours}
|
||||
placeholder={bulkForm.durationHours}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })}
|
||||
className="input"
|
||||
/>
|
||||
@@ -638,7 +842,7 @@ export default function SchedulesPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkForm.repeatEveryDays}
|
||||
placeholder={bulkForm.repeatEveryDays}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })}
|
||||
className="input"
|
||||
/>
|
||||
@@ -649,61 +853,91 @@ export default function SchedulesPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkForm.forNextDays}
|
||||
placeholder={bulkForm.forNextDays}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label">Coaches (Optional)</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (bulkForm.coachIds.length === coaches.length) {
|
||||
setBulkForm({ ...bulkForm, coachIds: [] });
|
||||
} else {
|
||||
setBulkForm({ ...bulkForm, coachIds: coaches.map((c: Coach) => c.id) });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{bulkForm.coachIds.length === coaches.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<label className="label mb-0">Coaches</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{templateLoading && bulkForm.routeId && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template…</span>
|
||||
)}
|
||||
{!bulkForm.routeId && (
|
||||
<span className="text-xs text-muted-foreground">Select a route to load its coach template</span>
|
||||
)}
|
||||
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
|
||||
disabled={bulkCoachRows.filter(r => r.coachId).length >= coaches.length}
|
||||
onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}>
|
||||
Add Coach
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg p-3 max-h-64 overflow-y-auto space-y-2">
|
||||
{coaches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No coaches available</p>
|
||||
) : (
|
||||
coaches.map((coach: Coach) => (
|
||||
<label key={coach.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkForm.coachIds.includes(coach.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBulkForm({
|
||||
...bulkForm,
|
||||
coachIds: [...bulkForm.coachIds, coach.id],
|
||||
});
|
||||
} else {
|
||||
setBulkForm({
|
||||
...bulkForm,
|
||||
coachIds: bulkForm.coachIds.filter((id) => id !== coach.id),
|
||||
});
|
||||
}
|
||||
|
||||
{bulkCoachRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">No coaches assigned — schedules will be created without coach assignments.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{bulkCoachRows.length > 1 && (
|
||||
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>
|
||||
)}
|
||||
{bulkCoachRows.map((row, i) => {
|
||||
const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean));
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData('bulk-coach-idx', i.toString())}
|
||||
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
|
||||
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const src = parseInt(e.dataTransfer.getData('bulk-coach-idx'));
|
||||
if (src === i) return;
|
||||
const reordered = [...bulkCoachRows];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(i, 0, moved);
|
||||
setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
|
||||
<select
|
||||
className="input input-sm flex-1"
|
||||
value={row.coachId}
|
||||
onChange={(e) => {
|
||||
const updated = [...bulkCoachRows];
|
||||
updated[i] = { ...updated[i], coachId: e.target.value };
|
||||
setBulkCoachRows(updated);
|
||||
}}
|
||||
>
|
||||
<option value="">Select Coach</option>
|
||||
{coaches
|
||||
.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId)
|
||||
.map((c: Coach) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.number || c.coachNumber} — {c.coachType?.name} (Cap: {c.capacity})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkCoachRows(bulkCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))}
|
||||
className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
@@ -713,10 +947,10 @@ export default function SchedulesPage() {
|
||||
schedules, starting from the specified date, repeating every{' '}
|
||||
<strong>{bulkForm.repeatEveryDays}</strong> days for the next{' '}
|
||||
<strong>{bulkForm.forNextDays}</strong> days.
|
||||
{bulkForm.coachIds.length > 0 && (
|
||||
{bulkCoachRows.filter(r => r.coachId).length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
Each schedule will have <strong>{bulkForm.coachIds.length}</strong> coach(es) assigned.
|
||||
Each schedule will have <strong>{bulkCoachRows.filter(r => r.coachId).length}</strong> coach(es) assigned.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
@@ -736,8 +970,8 @@ export default function SchedulesPage() {
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [],
|
||||
});
|
||||
setBulkCoachRows([]);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -756,7 +990,7 @@ export default function SchedulesPage() {
|
||||
setEditingSchedule(null);
|
||||
setError(null);
|
||||
}}
|
||||
title={`Edit Schedule - ${editingSchedule?.train?.name} (${editingSchedule?.train?.number})`}
|
||||
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
||||
size="lg"
|
||||
>
|
||||
{editingSchedule && (
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function TariffRatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
428
apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
Normal file
428
apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
|
||||
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
|
||||
const COACH_TYPE_LABELS: Record<string, string> = {
|
||||
HSC: 'Regular Seat (Hard Seat)',
|
||||
HBC: 'Economy Bed (Hard Berth)',
|
||||
SBC: 'VIP Bed (Soft Berth)',
|
||||
};
|
||||
|
||||
// Tariff reference rates per the official policy document
|
||||
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
|
||||
LOCAL: {
|
||||
'HSC-null': 0.03,
|
||||
'HBC-UPPER': 0.04,
|
||||
'HBC-MIDDLE': 0.055,
|
||||
'HBC-LOWER': 0.06,
|
||||
'SBC-UPPER': 0.075,
|
||||
'SBC-LOWER': 0.08,
|
||||
},
|
||||
INTERNATIONAL: {
|
||||
'HSC-null': 0.06,
|
||||
'HBC-UPPER': 0.08,
|
||||
'HBC-MIDDLE': 0.11,
|
||||
'HBC-LOWER': 0.12,
|
||||
'SBC-UPPER': 0.15,
|
||||
'SBC-LOWER': 0.16,
|
||||
},
|
||||
};
|
||||
|
||||
function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) {
|
||||
const key = `${coachCode}-${bedPosition ?? 'null'}`;
|
||||
return TARIFF_REFERENCE[nationalityType]?.[key];
|
||||
}
|
||||
|
||||
export default function TariffRatesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingClass, setEditingClass] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
|
||||
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
|
||||
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: classesData, isLoading } = useQuery({
|
||||
queryKey: ['seat-classes'],
|
||||
queryFn: () => apiClient.get<any>('/seat-classes'),
|
||||
});
|
||||
|
||||
const { data: coachTypesData } = useQuery({
|
||||
queryKey: ['coach-types'],
|
||||
queryFn: () => apiClient.get<any>('/fleet/coach-types'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/seat-classes', data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
|
||||
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
|
||||
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/seat-classes/${id}`),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); setDeleteConfirm({ isOpen: false, item: null }); },
|
||||
onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Delete failed' })),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingClass(null);
|
||||
setFormError(null);
|
||||
setSelectedCoachTypeId('');
|
||||
setSelectedBedPosition('');
|
||||
setSelectedNationalityType('LOCAL');
|
||||
};
|
||||
|
||||
const openEdit = (cls: any) => {
|
||||
setEditingClass(cls);
|
||||
setSelectedCoachTypeId(cls.coachTypeId || '');
|
||||
setSelectedBedPosition(cls.bedPosition || '');
|
||||
setSelectedNationalityType(cls.nationalityType || 'LOCAL');
|
||||
setFormError(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const payload: any = {
|
||||
coachTypeId: selectedCoachTypeId,
|
||||
name: fd.get('name') as string,
|
||||
nationalityType: selectedNationalityType,
|
||||
bedPosition: selectedBedPosition || null,
|
||||
baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
|
||||
isActive: fd.get('isActive') === 'true',
|
||||
};
|
||||
if (editingClass) {
|
||||
await updateMutation.mutateAsync({ id: editingClass.id, data: payload });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const coachTypesArray: any[] = Array.isArray(coachTypesData)
|
||||
? coachTypesData
|
||||
: (coachTypesData as any)?.data || (coachTypesData as any)?.items || [];
|
||||
|
||||
const allClasses: any[] = Array.isArray(classesData)
|
||||
? classesData
|
||||
: (classesData as any)?.items || (classesData as any)?.data || [];
|
||||
|
||||
// Only show classes that have nationalityType set (tariff-managed rows)
|
||||
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
|
||||
|
||||
const displayed = tariffClasses.filter((c: any) => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
c.name?.toLowerCase().includes(s) ||
|
||||
c.nationalityType?.toLowerCase().includes(s) ||
|
||||
c.bedPosition?.toLowerCase().includes(s) ||
|
||||
c.coachType?.name?.toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-suggest name from selections
|
||||
const suggestName = () => {
|
||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
if (!ct) return '';
|
||||
const label = COACH_TYPE_LABELS[ct.code] || ct.name;
|
||||
const pos = selectedBedPosition ? ` ${selectedBedPosition.charAt(0) + selectedBedPosition.slice(1).toLowerCase()}` : '';
|
||||
const nat = selectedNationalityType === 'LOCAL' ? 'Local' : 'Intl';
|
||||
return `${label}${pos} (${nat})`;
|
||||
};
|
||||
|
||||
// Auto-suggest baseFareMinor from tariff reference
|
||||
const suggestRate = () => {
|
||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
if (!ct) return '';
|
||||
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
|
||||
// baseFareMinor = tariff_decimal × 100000
|
||||
return ref ? Math.round(ref * 100000).toString() : '';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'nationalityType', label: 'Passenger Type',
|
||||
render: (c: any) => (
|
||||
<Badge variant="status" status={c.nationalityType === 'LOCAL' ? 'CONFIRMED' : 'INFO'}>
|
||||
{c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coachType', label: 'Coach Type',
|
||||
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
|
||||
},
|
||||
{
|
||||
key: 'bedPosition', label: 'Berth Position',
|
||||
render: (c: any) => c.bedPosition
|
||||
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
||||
: <span className="text-muted-foreground text-xs">Standard</span>,
|
||||
},
|
||||
{
|
||||
key: 'name', label: 'Class Name',
|
||||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'baseFareMinor', label: 'Rate per km (minor)',
|
||||
render: (c: any) => {
|
||||
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||||
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
|
||||
const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
|
||||
const matches = tariffMinor === c.baseFareMinor;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-medium">{c.baseFareMinor}</span>
|
||||
{tariffMinor !== undefined && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
|
||||
{matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isActive', label: 'Status',
|
||||
render: (c: any) => (
|
||||
<Badge variant="status" status={c.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{c.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: openEdit },
|
||||
{
|
||||
label: 'Delete', icon: Trash2, variant: 'danger' as const,
|
||||
onClick: (c: any) => setDeleteConfirm({ isOpen: true, item: c }),
|
||||
},
|
||||
];
|
||||
|
||||
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
|
||||
Add Rate
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Tariff reference card */}
|
||||
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-2">Official Tariff Formula</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono">
|
||||
Fare = KM × rate × 1.02 × ExchangeRate
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-400 mt-1">
|
||||
Rate is stored as <strong>baseFareMinor = tariff_decimal × 100,000</strong> (e.g. 0.03 → 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, nationality, berth position..."
|
||||
className="input pl-10 w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
data={displayed}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)}
|
||||
title="Delete Tariff Rate"
|
||||
message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={deleteMutation.isPending}
|
||||
error={deleteConfirm.error}
|
||||
warning="Bookings in progress may be affected. Ensure a replacement rate exists."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
title={`${editingClass ? 'Edit' : 'Add'} Tariff Rate`}
|
||||
size="lg"
|
||||
>
|
||||
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||
{formError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Passenger Nationality *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedNationalityType}
|
||||
onChange={(e) => setSelectedNationalityType(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="LOCAL">Local (Ethiopian / Djiboutian)</option>
|
||||
<option value="INTERNATIONAL">International (Foreign nationals)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Coach Type *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedCoachTypeId}
|
||||
onChange={(e) => { setSelectedCoachTypeId(e.target.value); setSelectedBedPosition(''); }}
|
||||
required
|
||||
>
|
||||
<option value="">Select coach type</option>
|
||||
{coachTypesArray.map((ct: any) => (
|
||||
<option key={ct.id} value={ct.id}>
|
||||
{ct.code} — {ct.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isBedCoach && (
|
||||
<div>
|
||||
<label className="label">Berth Position *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedBedPosition}
|
||||
onChange={(e) => setSelectedBedPosition(e.target.value)}
|
||||
required={isBedCoach}
|
||||
>
|
||||
<option value="">Select berth position</option>
|
||||
{(selectedCoachType?.code === 'HBC'
|
||||
? BED_POSITIONS
|
||||
: (['UPPER', 'LOWER'] as const)
|
||||
).map((pos) => (
|
||||
<option key={pos} value={pos}>{pos}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Class Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
defaultValue={editingClass?.name || ''}
|
||||
key={editingClass?.id ?? `new-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||||
placeholder={suggestName() || 'e.g. Economy Bed Upper (Local)'}
|
||||
required
|
||||
/>
|
||||
{!editingClass && suggestName() && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested:{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline"
|
||||
onClick={(e) => {
|
||||
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=name]') as HTMLInputElement);
|
||||
if (inp) inp.value = suggestName();
|
||||
}}
|
||||
>
|
||||
{suggestName()}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare Minor (per km) *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass?.baseFareMinor ?? ''}
|
||||
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||||
placeholder={suggestRate() || 'e.g. 3000'}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
{suggestRate() && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Official tariff rate:{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline"
|
||||
onClick={(e) => {
|
||||
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=baseFareMinor]') as HTMLInputElement);
|
||||
if (inp) inp.value = suggestRate();
|
||||
}}
|
||||
>
|
||||
{suggestRate()}
|
||||
</button>
|
||||
{' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={closeModal}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingClass ? 'Update' : 'Create'} Rate
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -761,9 +761,12 @@ export default function TicketsPage() {
|
||||
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
|
||||
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-right shrink-0 flex flex-col items-end gap-1">
|
||||
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
|
||||
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${t.qrCode ? 'bg-emerald-200 text-emerald-900' : 'bg-white/20 text-white/60'}`}>
|
||||
{t.qrCode ? '✓ QR Available' : 'No QR'}
|
||||
</span>
|
||||
{t.validatedAt && <p className="text-emerald-200 text-xs">Validated {formatDateTime(t.validatedAt)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
@@ -835,13 +838,29 @@ export default function TicketsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* QR Code */}
|
||||
{t.qrCode && (
|
||||
<section>
|
||||
<SectionHeader title="QR Code" />
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-white p-4 rounded-xl border border-muted inline-block">
|
||||
<img
|
||||
src={t.qrCode.startsWith('data:') ? t.qrCode : `data:image/png;base64,${t.qrCode}`}
|
||||
alt={`QR Code for ${t.ticketNumber}`}
|
||||
className="w-48 h-48 object-contain"
|
||||
/>
|
||||
<p className="text-center text-xs text-muted-foreground mt-2 font-mono">{t.ticketNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Validation */}
|
||||
<section>
|
||||
<SectionHeader title="Validation & Timestamps" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
|
||||
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
|
||||
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
|
||||
<Field label="Created" value={formatDateTime(t.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
|
||||
<Field label="Ticket ID" value={t.id} mono truncate />
|
||||
|
||||
@@ -89,7 +89,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
title: 'Financial',
|
||||
items: [
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||
// { name: 'Configurable Fares', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
|
||||
{ name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
|
||||
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedData.map((item, index) => (
|
||||
<tr
|
||||
key={item.id || index}
|
||||
key={item.id ?? index}
|
||||
onClick={() => onRowClick?.(item)}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
@@ -186,15 +186,16 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => { buttonRefs.current[item.id] = el; }}
|
||||
ref={(el) => { buttonRefs.current[item.id ?? index] = el; }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.right + window.scrollX - 192, // 192px = w-48
|
||||
left: rect.right + window.scrollX - 192,
|
||||
});
|
||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
||||
const key = item.id ?? String(index);
|
||||
setExpandedActions(expandedActions === key ? null : key);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
@@ -229,9 +230,9 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
>
|
||||
<div className="py-1">
|
||||
{actions
|
||||
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
|
||||
?.filter(action => !action.show || action.show(sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions)!))
|
||||
.map((action, actionIndex) => {
|
||||
const item = sortedData.find(item => item.id === expandedActions);
|
||||
const item = sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -442,6 +442,14 @@ export const excessBaggageApi = {
|
||||
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
|
||||
};
|
||||
|
||||
// Route Coach Templates API
|
||||
export const routeCoachTemplatesApi = {
|
||||
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),
|
||||
set: (routeId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
|
||||
apiClient.put<any>(`/routes/${routeId}/coaches`, { coaches }),
|
||||
clear: (routeId: string) => apiClient.delete(`/routes/${routeId}/coaches`),
|
||||
};
|
||||
|
||||
// System Config API
|
||||
export const systemConfigApi = {
|
||||
getAll: () => apiClient.get<Record<string, string>>('/config'),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
images: {
|
||||
unoptimized: true,
|
||||
unoptimized: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -260,9 +260,21 @@ export default function ReviewPage() {
|
||||
if (localStoragePassengerId) passengerId = localStoragePassengerId;
|
||||
}
|
||||
|
||||
// Fallback 3: Use passengerId from user object
|
||||
// Fallback 3: Use passengerId from user object. The profile response nests
|
||||
// it under `passenger.id`, while the login response exposes it top-level as
|
||||
// `passengerId` — accept either shape so a cached profile user still resolves.
|
||||
if (!passengerId && user) {
|
||||
passengerId = (user as any).passengerId;
|
||||
passengerId = (user as any).passengerId || (user as any).passenger?.id;
|
||||
}
|
||||
|
||||
// Fallback 4: last resort — fetch the passenger profile directly.
|
||||
if (!passengerId) {
|
||||
try {
|
||||
const me: any = await apiClient.get('/passengers/me');
|
||||
passengerId = me?.id || me?.passengerId || '';
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve passengerId from /passengers/me:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!passengerId) {
|
||||
|
||||
@@ -30,6 +30,18 @@ services:
|
||||
- apps/edr-passenger-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:4000/health/ready"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
freight-portal:
|
||||
build:
|
||||
context: .
|
||||
@@ -72,6 +84,18 @@ services:
|
||||
- "${PASSENGER_PORTAL_PORT:-5174}:${PASSENGER_PORTAL_PORT:-5174}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/portal/.env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_PORTAL_PORT:-5174}/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
passenger-backoffice:
|
||||
build:
|
||||
context: .
|
||||
@@ -86,6 +110,18 @@ services:
|
||||
- "${PASSENGER_BACKOFFICE_PORT:-5184}:${PASSENGER_BACKOFFICE_PORT:-5184}"
|
||||
env_file:
|
||||
- apps/edr-passenger-web/backoffice/.env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:${PASSENGER_BACKOFFICE_PORT:-5184}/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
|
||||
payment-api:
|
||||
build:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Next.js Dockerfile for passenger web (portal + backoffice).
|
||||
# Builds with turbo, runs with Node.js (not nginx).
|
||||
# Builds with turbo, runs with Node.js standalone output.
|
||||
#
|
||||
# Build example (portal):
|
||||
# DOCKER_BUILDKIT=1 docker build \
|
||||
@@ -16,8 +16,6 @@ ARG PORT=5174
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit
|
||||
# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds.
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
@@ -46,20 +44,30 @@ RUN if [ -z "$NEXT_PUBLIC_API_URL" ]; then \
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="${APP_PACKAGE}..."
|
||||
FROM base AS deployer
|
||||
ARG APP_PACKAGE
|
||||
COPY --from=builder /app/ .
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="${APP_PACKAGE}" --prod --legacy /deploy
|
||||
|
||||
# Runner: copy the standalone output produced by Next.js.
|
||||
# With output:'standalone' in a pnpm monorepo, Next.js emits:
|
||||
# .next/standalone/ <- shared workspace node_modules
|
||||
# .next/standalone/<APP_PATH>/ <- app server.js + app-level node_modules
|
||||
# .next/static/ <- hashed client assets
|
||||
# public/ <- static public files
|
||||
# We copy the full standalone tree to /app, then WORKDIR into the app
|
||||
# subdirectory so `node server.js` resolves correctly.
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
ARG APP_PATH
|
||||
ARG PORT=5174
|
||||
ENV PORT=${PORT}
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=deployer /deploy .
|
||||
COPY --from=builder /app/${APP_PATH}/.next .next
|
||||
COPY --from=builder /app/${APP_PATH}/public public
|
||||
USER node
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
# Copy the entire standalone tree (includes root node_modules symlinks)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/.next/standalone ./
|
||||
# Overlay the hashed static assets and public files at the path Next.js expects
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/.next/static ./${APP_PATH}/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/${APP_PATH}/public ./${APP_PATH}/public
|
||||
USER nextjs
|
||||
# server.js lives at <APP_PATH>/server.js inside the standalone tree
|
||||
WORKDIR /app/${APP_PATH}
|
||||
EXPOSE ${PORT}
|
||||
CMD ["npx", "next", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -4,10 +4,24 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/wasm;
|
||||
gzip_min_length 1024;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -525,6 +525,9 @@ importers:
|
||||
express:
|
||||
specifier: ^4.18.2
|
||||
version: 4.22.2
|
||||
helmet:
|
||||
specifier: ^8.0.0
|
||||
version: 8.2.0
|
||||
jose:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
@@ -7107,6 +7110,10 @@ packages:
|
||||
headers-polyfill@5.0.1:
|
||||
resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==}
|
||||
|
||||
helmet@8.2.0:
|
||||
resolution: {integrity: sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
helper-date@1.0.1:
|
||||
resolution: {integrity: sha512-wU3VOwwTJvGr/w5rZr3cprPHO+hIhlblTJHD6aFBrKLuNbf4lAmkawd2iK3c6NbJEvY7HAmDpqjOFSI5/+Ey2w==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -12625,10 +12632,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
'@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.21
|
||||
react: 19.2.6
|
||||
@@ -15532,7 +15539,7 @@ snapshots:
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -19080,6 +19087,8 @@ snapshots:
|
||||
'@types/set-cookie-parser': 2.4.10
|
||||
set-cookie-parser: 3.1.0
|
||||
|
||||
helmet@8.2.0: {}
|
||||
|
||||
helper-date@1.0.1:
|
||||
dependencies:
|
||||
date.js: 0.3.3
|
||||
@@ -20446,7 +20455,7 @@ snapshots:
|
||||
mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
|
||||
Reference in New Issue
Block a user