mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
-- Create passenger schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||
|
||||
-- Move all enums from public to passenger schema
|
||||
DO $$
|
||||
DECLARE
|
||||
e text;
|
||||
BEGIN
|
||||
FOR e IN
|
||||
SELECT typname FROM pg_type
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
|
||||
WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Move all tables from public to passenger schema
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Add missing columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
-- Add ReturnLegStatus enum and column
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
|
||||
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE';
|
||||
|
||||
-- Add missing columns to other tables
|
||||
ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
|
||||
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
|
||||
@@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["multiSchema"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -79,7 +80,6 @@ model CoachType {
|
||||
updatedAt DateTime @updatedAt
|
||||
coaches Coach[]
|
||||
seatClasses SeatClass[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -116,11 +116,11 @@ enum BookingStatus {
|
||||
}
|
||||
|
||||
enum ReturnLegStatus {
|
||||
NOT_APPLICABLE // one-way booking
|
||||
BOTH_USED // passenger used both legs
|
||||
OUTBOUND_ONLY // return leg not used (no-show on return)
|
||||
INBOUND_ONLY // outbound leg not used, return leg used
|
||||
NEITHER_USED // neither leg boarded yet
|
||||
NOT_APPLICABLE
|
||||
BOTH_USED
|
||||
OUTBOUND_ONLY
|
||||
INBOUND_ONLY
|
||||
NEITHER_USED
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -269,7 +269,6 @@ model User {
|
||||
fraudAlerts FraudAlert[]
|
||||
|
||||
faydaVerificationSessions FaydaVerificationSession[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -283,7 +282,6 @@ model Session {
|
||||
lastActivityAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -315,7 +313,6 @@ model TravelerProfile {
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -350,7 +347,6 @@ model Train {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
schedules TrainSchedule[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -412,7 +408,6 @@ model TripLiveStatus {
|
||||
platformLabel String?
|
||||
updatedAt DateTime @updatedAt
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -500,7 +495,6 @@ model FareRule {
|
||||
validFrom DateTime
|
||||
validUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -584,7 +578,6 @@ model BookingSeat {
|
||||
displayFareMinor Int?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -600,7 +593,6 @@ model PaymentMethod {
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -661,7 +653,6 @@ model PaymentRefund {
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -681,7 +672,6 @@ model Ticket {
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
validationLogs GateValidationLog[]
|
||||
seats TicketSeat[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -709,7 +699,6 @@ model LoyaltyAccount {
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
ledger LoyaltyLedgerEntry[]
|
||||
rewards LoyaltyReward[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -722,7 +711,6 @@ model LoyaltyLedgerEntry {
|
||||
balanceAfter Int
|
||||
createdAt DateTime @default(now())
|
||||
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -734,7 +722,6 @@ model LoyaltyReward {
|
||||
available Boolean @default(true)
|
||||
description String?
|
||||
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -763,7 +750,6 @@ model WalletLedgerEntry {
|
||||
relatedBookingId String?
|
||||
createdAt DateTime @default(now())
|
||||
wallet WalletAccount @relation(fields: [walletId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -778,7 +764,6 @@ model Notification {
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -794,7 +779,6 @@ model Promotion {
|
||||
deepLink String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -808,7 +792,6 @@ model StationCrowdSignal {
|
||||
observedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
station Station @relation(fields: [stationId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -820,7 +803,6 @@ model WeatherAlert {
|
||||
message String
|
||||
validUntil DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -828,7 +810,6 @@ model MenuCategory {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
items MenuItem[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -843,7 +824,6 @@ model MenuItem {
|
||||
availableUntil DateTime?
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
category MenuCategory @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -858,7 +838,6 @@ model FoodOrder {
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
items FoodOrderItem[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -871,7 +850,6 @@ model FoodOrderItem {
|
||||
unitPriceMinor Int?
|
||||
lineTotalMinor Int
|
||||
order FoodOrder @relation(fields: [orderId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -880,7 +858,6 @@ model FaqCategory {
|
||||
title String
|
||||
iconKey String?
|
||||
articles FaqArticle[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -891,7 +868,6 @@ model FaqArticle {
|
||||
answerMarkdown String
|
||||
rank Int @default(0)
|
||||
category FaqCategory @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -902,7 +878,6 @@ model SupportConversation {
|
||||
status SupportConversationStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
messages SupportMessage[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -914,7 +889,6 @@ model SupportMessage {
|
||||
attachments Json?
|
||||
createdAt DateTime @default(now())
|
||||
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -934,7 +908,6 @@ model UserPreferences {
|
||||
darkMode Boolean @default(false)
|
||||
language String @default("en")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -947,7 +920,6 @@ model Device {
|
||||
trusted Boolean @default(false)
|
||||
lastSeenAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -961,7 +933,6 @@ model SavedRoute {
|
||||
tripCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -973,7 +944,6 @@ model Journey {
|
||||
currency String @default("ETB")
|
||||
createdAt DateTime @default(now())
|
||||
journeySegments JourneySegment[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -988,7 +958,6 @@ model JourneySegment {
|
||||
arrivalStationId String
|
||||
journey Journey @relation(fields: [journeyId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1032,7 +1001,6 @@ model Route {
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
schedules TrainSchedule[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1102,7 +1070,6 @@ model Agent {
|
||||
bookings AgentBooking[]
|
||||
shifts AgentShift[]
|
||||
commissions AgentCommission[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1117,7 +1084,6 @@ model AgentBooking {
|
||||
createdAt DateTime @default(now())
|
||||
agent Agent @relation(fields: [agentId], references: [id])
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1177,7 +1143,6 @@ model BookingCancellation {
|
||||
processedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1205,7 +1170,6 @@ model BaggageAllowance {
|
||||
excessFeePerKg Int
|
||||
currency String @default("ETB")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1249,7 +1213,6 @@ model NotificationTemplate {
|
||||
bodyTemplate String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1288,7 +1251,6 @@ model FraudRule {
|
||||
config Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ async function seedRoute() {
|
||||
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const returnRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-102' },
|
||||
update: {},
|
||||
@@ -317,7 +317,7 @@ async function seedTrips() {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
|
||||
|
||||
const schedules = [];
|
||||
|
||||
for (let d = 0; d < 5; d++) {
|
||||
@@ -403,7 +403,7 @@ async function seedTrips() {
|
||||
|
||||
const coachAssignments = [];
|
||||
const liveStatuses = [];
|
||||
|
||||
|
||||
for (const schedule of createdSchedules) {
|
||||
for (let p = 0; p < coaches.length; p++) {
|
||||
coachAssignments.push({
|
||||
@@ -418,12 +418,12 @@ async function seedTrips() {
|
||||
progressPercent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
await Promise.all([
|
||||
...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })),
|
||||
...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })),
|
||||
]);
|
||||
|
||||
|
||||
console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`);
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@ async function seedFareRules() {
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
await Promise.all(
|
||||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
||||
);
|
||||
@@ -518,7 +518,7 @@ async function seedSegmentFares() {
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
const validFrom = new Date('2026-01-01');
|
||||
|
||||
if (route && route.stops.length > 2) {
|
||||
for (const sc of seatClasses) {
|
||||
@@ -531,7 +531,7 @@ async function seedSegmentFares() {
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
|
||||
validFrom,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
|
||||
await prisma.segmentFareRule.create({
|
||||
data: {
|
||||
@@ -542,7 +542,7 @@ async function seedSegmentFares() {
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
|
||||
validFrom,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
}
|
||||
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
|
||||
}
|
||||
@@ -586,16 +586,16 @@ async function seedMenuAndFood() {
|
||||
const coffeeId = uuidv4();
|
||||
const juiceId = uuidv4();
|
||||
const sandwichId = uuidv4();
|
||||
|
||||
|
||||
await prisma.menuItem.create({
|
||||
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}).catch(() => { }); // ignore if exists
|
||||
await prisma.menuItem.create({
|
||||
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}).catch(() => { }); // ignore if exists
|
||||
await prisma.menuItem.create({
|
||||
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}).catch(() => { }); // ignore if exists
|
||||
}
|
||||
console.log(` ✅ Menu categories and items created`);
|
||||
}
|
||||
@@ -682,6 +682,9 @@ async function main() {
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['system users', seedSystemUsers],
|
||||
['fare rules', seedFareRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
['currency', seedCurrency]
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -202,7 +202,7 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
if (status) where.status = status;
|
||||
if (returnLegStatus) where.returnLegStatus = returnLegStatus;
|
||||
if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.booking.findMany({
|
||||
@@ -233,6 +233,8 @@ export class BookingsService {
|
||||
contactPhone: booking.contactPhone,
|
||||
bookingType: booking.bookingType,
|
||||
returnLegStatus: (booking as any).returnLegStatus ?? null,
|
||||
adultCount: booking.adultCount,
|
||||
childCount: booking.childCount,
|
||||
createdAt: booking.createdAt,
|
||||
passenger: booking.passenger?.user,
|
||||
schedule: {
|
||||
|
||||
@@ -47,6 +47,9 @@ export class FareCalculateDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' })
|
||||
@IsOptional() @IsString() scheduleId?: string;
|
||||
}
|
||||
|
||||
export class FareBreakdownDto {
|
||||
|
||||
@@ -32,20 +32,59 @@ export class FareEngineService {
|
||||
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
||||
);
|
||||
|
||||
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||
if (missingDistance.length > 0)
|
||||
throw new BadRequestException(
|
||||
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||
);
|
||||
|
||||
const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
|
||||
const now = new Date();
|
||||
const [originStation, destStation] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
||||
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
||||
]);
|
||||
const segmentRoute = originStation && destStation
|
||||
? `${originStation.code}-${destStation.code}` : null;
|
||||
const fullRoute = `${route.code}`;
|
||||
|
||||
const fareRuleCandidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId: dto.seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
|
||||
const fareRule = this.pickBestFareRule(
|
||||
fareRuleCandidates,
|
||||
dto.scheduleId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
let baseFarePerPassengerMinor: number;
|
||||
let ratePerKmMinor: number;
|
||||
let totalDistanceKm: number;
|
||||
let fareSource: string;
|
||||
|
||||
if (fareRule) {
|
||||
// Flat fare from FareRule — distance is informational only
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
||||
} else {
|
||||
// Distance × rate fallback
|
||||
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||
if (missingDistance.length > 0)
|
||||
throw new BadRequestException(
|
||||
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||
);
|
||||
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
ratePerKmMinor = seatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
fareSource = 'DISTANCE_RATE';
|
||||
}
|
||||
|
||||
// Premium and insurance fees applied per passenger
|
||||
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
|
||||
@@ -84,11 +123,6 @@ export class FareEngineService {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
|
||||
|
||||
const [originStation, destStation] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
||||
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
||||
]);
|
||||
|
||||
const calculation = [
|
||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||
@@ -110,9 +144,11 @@ export class FareEngineService {
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||
`Fare source: ${fareSource}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
fareSource,
|
||||
routeCode: route.code,
|
||||
originName: originStation?.name ?? dto.originStationId,
|
||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||
@@ -161,6 +197,37 @@ export class FareEngineService {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
private pickBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId?: string,
|
||||
segmentRoute?: string | null,
|
||||
fullRoute?: string,
|
||||
nationality?: string,
|
||||
): any | null {
|
||||
const nat = nationality ?? null;
|
||||
const priorities = [
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: nat },
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: nat },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: null, nationality: nat },
|
||||
{ tripId: scheduleId, route: null, nationality: null },
|
||||
{ tripId: null, route: segmentRoute, nationality: nat },
|
||||
{ tripId: null, route: segmentRoute, nationality: null },
|
||||
{ tripId: null, route: fullRoute, nationality: nat },
|
||||
{ tripId: null, route: fullRoute, nationality: null },
|
||||
{ tripId: null, route: null, nationality: nat },
|
||||
{ tripId: null, route: null, nationality: null },
|
||||
];
|
||||
for (const p of priorities) {
|
||||
const match = candidates.find(
|
||||
c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
@@ -175,6 +242,7 @@ export class FareEngineService {
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
scheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,6 +267,7 @@ export class FareEngineService {
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId: sc.id,
|
||||
nationality,
|
||||
scheduleId,
|
||||
}).catch(() => null),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -21,10 +21,11 @@ export class PassengersService {
|
||||
const { search, verified, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
const where: any = { user: { role: 'PASSENGER' } };
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
...where.user,
|
||||
OR: [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
@@ -68,7 +69,7 @@ export class PassengersService {
|
||||
userId: passenger.userId,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
phone: user.phone?.startsWith('+guest-') ? null : user.phone,
|
||||
nationalId: user.nationalId,
|
||||
nationality: user.nationality,
|
||||
dateOfBirth: user.dateOfBirth ?? null,
|
||||
|
||||
@@ -12,34 +12,22 @@ export class SchedulesController {
|
||||
|
||||
@Post('bulk-generate')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Bulk generate repetitive schedules',
|
||||
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
|
||||
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
|
||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||
return this.service.bulkGenerateSchedules(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a train schedule from a route template',
|
||||
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
||||
@ApiResponse({ status: 404, description: 'Train or route not found' })
|
||||
@ApiOperation({ summary: 'Create a train schedule from a route template' })
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
||||
@ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
|
||||
@ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
|
||||
@ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
|
||||
@ApiQuery({ name: 'date', required: false })
|
||||
@ApiQuery({ name: 'routeId', required: false })
|
||||
@ApiQuery({ name: 'trainId', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus })
|
||||
listSchedules(
|
||||
@Query('date') date?: string,
|
||||
@Query('routeId') routeId?: string,
|
||||
@@ -57,57 +45,63 @@ export class SchedulesController {
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
|
||||
@Patch('fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
||||
updateFareRule(@Param('id') id: string, @Body() dto: Partial<CreateFareRuleDto>) {
|
||||
return this.service.updateFareRule(id, dto);
|
||||
}
|
||||
|
||||
@Delete('fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule deleted' })
|
||||
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
||||
|
||||
@Post('segment-fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
|
||||
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
|
||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
@Get('routes/:routeId/segment-fares')
|
||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
|
||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||
|
||||
@Patch('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
|
||||
@Delete('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
|
||||
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||
|
||||
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@ApiOperation({ summary: 'Get schedule detail' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
|
||||
@ApiOperation({ summary: 'Update a schedule (partial)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
return this.service.updateSchedulePartial(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
|
||||
@ApiOperation({ summary: 'Update schedule status' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Status updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
||||
return this.service.updateScheduleStatus(id, dto);
|
||||
}
|
||||
@@ -116,26 +110,18 @@ export class SchedulesController {
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
deleteSchedule(@Param('id') id: string) {
|
||||
return this.service.deleteSchedule(id);
|
||||
}
|
||||
deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); }
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Patch(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
|
||||
@ApiOperation({ summary: 'Update a stop time' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||
@ApiResponse({ status: 200, description: 'Stop updated' })
|
||||
@ApiResponse({ status: 404, description: 'Stop not found on schedule' })
|
||||
updateStop(
|
||||
@Param('id') id: string,
|
||||
@Param('sequence', ParseIntPipe) sequence: number,
|
||||
@@ -145,19 +131,26 @@ export class SchedulesController {
|
||||
@Get(':scheduleId/fares/stored')
|
||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
|
||||
getStoredFares(@Param('scheduleId') scheduleId: string) {
|
||||
return this.service.getFareRules(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||
@Get(':scheduleId/fares/all')
|
||||
@ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
|
||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
|
||||
@ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
|
||||
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
getAllFares(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
) {
|
||||
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'seatClassId', required: true })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
getFare(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query('seatClassId') seatClassId: string,
|
||||
@@ -166,42 +159,15 @@ export class SchedulesController {
|
||||
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares/all')
|
||||
@ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
|
||||
@ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
|
||||
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getAllFares(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
) {
|
||||
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
||||
}
|
||||
|
||||
@Post(':id/fares/sync')
|
||||
@ApiOperation({
|
||||
summary: 'Sync fares from fare engine',
|
||||
description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
|
||||
})
|
||||
@ApiOperation({ summary: 'Sync fares from fare engine' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
|
||||
@ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
syncFares(@Param('id') id: string) {
|
||||
return this.service.syncFaresFromEngine(id);
|
||||
}
|
||||
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Assign coaches to a schedule',
|
||||
description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.'
|
||||
})
|
||||
@ApiOperation({ summary: 'Assign coaches to a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Coaches assigned successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
||||
assignCoaches(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
|
||||
@@ -212,21 +178,14 @@ export class SchedulesController {
|
||||
@Get(':id/coaches')
|
||||
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' })
|
||||
getAssignedCoaches(@Param('id') id: string) {
|
||||
return this.service.getAssignedCoaches(id);
|
||||
}
|
||||
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
|
||||
|
||||
@Delete(':id/coaches/:coachId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach assignment removed' })
|
||||
removeCoachAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('coachId') coachId: string,
|
||||
) {
|
||||
removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) {
|
||||
return this.service.removeCoachAssignment(id, coachId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export class SchedulesService {
|
||||
const errors: string[] = [];
|
||||
const scheduleIds: string[] = [];
|
||||
|
||||
// Validate route and get stops for plannedTimes generation
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
@@ -45,7 +44,6 @@ export class SchedulesService {
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// Assign coaches if provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
@@ -58,15 +56,10 @@ export class SchedulesService {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Move to next repetition
|
||||
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
schedulesCreated: scheduleCount,
|
||||
errors,
|
||||
scheduleIds,
|
||||
};
|
||||
return { schedulesCreated: scheduleCount, errors, scheduleIds };
|
||||
}
|
||||
|
||||
async listSchedules(dto: ListSchedulesDto) {
|
||||
@@ -104,7 +97,6 @@ export class SchedulesService {
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
// Validate route exists and has stops
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
@@ -113,21 +105,13 @@ export class SchedulesService {
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
|
||||
// Check for duplicate schedule with same train, route, and date
|
||||
const depDate = new Date(dep);
|
||||
depDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(depDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||
where: {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
departureAt: {
|
||||
gte: depDate,
|
||||
lt: nextDay,
|
||||
},
|
||||
},
|
||||
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
@@ -136,7 +120,6 @@ export class SchedulesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-generate plannedTimes if not provided or empty
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
@@ -144,7 +127,6 @@ export class SchedulesService {
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
@@ -154,7 +136,6 @@ export class SchedulesService {
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -163,14 +144,12 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate all route stop sequences are covered by plannedTimes
|
||||
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||
if (missingSeqs.length > 0) {
|
||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||
}
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
const lastStop = route.stops[route.stops.length - 1];
|
||||
|
||||
@@ -188,9 +167,7 @@ export class SchedulesService {
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
return this.getSchedule(schedule.id);
|
||||
@@ -230,10 +207,7 @@ export class SchedulesService {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
@@ -304,7 +278,6 @@ export class SchedulesService {
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
@@ -314,7 +287,6 @@ export class SchedulesService {
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -323,9 +295,7 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
|
||||
|
||||
return this.getSchedule(id);
|
||||
@@ -376,9 +346,35 @@ export class SchedulesService {
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
|
||||
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.fareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(scheduleId !== undefined && { tripId: scheduleId }),
|
||||
...(nationality !== undefined && { nationality }),
|
||||
...(validFrom && { validFrom: new Date(validFrom) }),
|
||||
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFareRule(id: string) {
|
||||
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||
await this.prisma.fareRule.delete({ where: { id } });
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
createSegmentFareRule(dto: any) {
|
||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.create({
|
||||
@@ -419,7 +415,6 @@ export class SchedulesService {
|
||||
async getFareRules(scheduleId?: string) {
|
||||
const where: any = {};
|
||||
if (scheduleId) where.tripId = scheduleId;
|
||||
|
||||
return this.prisma.fareRule.findMany({
|
||||
where,
|
||||
include: { seatClass: true },
|
||||
@@ -439,11 +434,10 @@ export class SchedulesService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
||||
|
||||
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
|
||||
error instanceof Error ? error.message : 'Failed to calculate fares for schedule',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -483,20 +477,13 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
async assignCoaches(
|
||||
scheduleId: string,
|
||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||
) {
|
||||
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({
|
||||
where: { id: { in: coachIds } },
|
||||
});
|
||||
if (existingCoaches.length !== coachIds.length) {
|
||||
throw new NotFoundException('One or more coaches not found');
|
||||
}
|
||||
const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||
if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
@@ -508,20 +495,13 @@ export class SchedulesService {
|
||||
}));
|
||||
|
||||
await this.prisma.coachAssignment.createMany({ data });
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
|
||||
async getAssignedCoaches(scheduleId: string) {
|
||||
return this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
@@ -535,30 +515,22 @@ export class SchedulesService {
|
||||
if (dto.departureAt || dto.arrivalAt) {
|
||||
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
|
||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||
|
||||
updateData.departureAt = dep;
|
||||
updateData.arrivalAt = arr;
|
||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
updateData.status = dto.status;
|
||||
}
|
||||
if (dto.status) updateData.status = dto.status;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
if (dto.coaches !== undefined) {
|
||||
if (dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
} else {
|
||||
// Remove all coach assignments when empty array is sent
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
}
|
||||
}
|
||||
@@ -567,12 +539,9 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||
where: { scheduleId, coachId },
|
||||
});
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
|
||||
if (!assignment) throw new NotFoundException('Coach assignment not found');
|
||||
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,7 @@ export class SearchService {
|
||||
destinationStationId,
|
||||
seatClassId: sc.id,
|
||||
nationality,
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
return {
|
||||
seatClassName: fare.seatClassName,
|
||||
|
||||
@@ -547,16 +547,14 @@ export class SeatsService {
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async expireHolds() {
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
for (const hold of expired) {
|
||||
const now = new Date();
|
||||
const expired = await this.prisma.seatHold.findMany({ where: { expiresAt: { lt: now } } });
|
||||
if (expired.length === 0) return;
|
||||
|
||||
const expiredIds = expired.map(h => h.id);
|
||||
for (const hold of expired) {
|
||||
await this.releaseSeats(hold.seatIds);
|
||||
try {
|
||||
await this.prisma.seatHold.delete({ where: { id: hold.id } });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && !err.message.includes('P2025')) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.prisma.seatHold.deleteMany({ where: { id: { in: expiredIds } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,18 +31,27 @@ export class TicketsController {
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all tickets with optional filters' })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'ACTIVE | USED | CANCELLED' })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
return this.service.listTickets({
|
||||
search,
|
||||
status,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
@@ -60,17 +69,8 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Get(':bookingRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Get ticket with QR code and passenger details',
|
||||
description: `Returns ticket information including:
|
||||
- QR code for gate scanning
|
||||
- Barcode for offline validation
|
||||
- Passenger details (name, age category, nationality)
|
||||
- Journey details (origin, destination, seat, coach)
|
||||
- Fare breakdown with currency
|
||||
- PDF download link`
|
||||
summary: 'Get ticket with QR code and passenger details (public)',
|
||||
})
|
||||
getByRef(@Param('bookingRef') ref: string) {
|
||||
return this.service.getByRef(ref);
|
||||
|
||||
@@ -14,7 +14,7 @@ interface OfflineValidation {
|
||||
export class TicketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -24,7 +24,19 @@ export class TicketsService {
|
||||
];
|
||||
}
|
||||
if (filters.status) {
|
||||
where.booking = { status: filters.status };
|
||||
where.booking = { ...where.booking, status: filters.status };
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
}
|
||||
if (filters.destinationStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
||||
}
|
||||
if (filters.arrivalDate) {
|
||||
const start = new Date(filters.arrivalDate);
|
||||
const end = new Date(filters.arrivalDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } };
|
||||
}
|
||||
const tickets = await this.prisma.ticket.findMany({
|
||||
where,
|
||||
@@ -32,7 +44,7 @@ export class TicketsService {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
},
|
||||
@@ -235,7 +247,16 @@ export class TicketsService {
|
||||
};
|
||||
}
|
||||
|
||||
async validate(bookingRef: string, validatorId: string, gateId?: string, leg?: string) {
|
||||
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
|
||||
// Accept either a ticket UUID or a bookingRef
|
||||
let bookingRef = ticketIdOrRef;
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ticketIdOrRef);
|
||||
if (isUuid) {
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { id: ticketIdOrRef }, select: { bookingRef: true } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
bookingRef = ticket.bookingRef;
|
||||
}
|
||||
const resolvedValidatorId = validatorId || 'BACKOFFICE';
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
@@ -247,11 +268,10 @@ export class TicketsService {
|
||||
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
|
||||
if (type === 'ONE_WAY') {
|
||||
if (ticket.validatedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } });
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } });
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
}
|
||||
|
||||
@@ -264,28 +284,32 @@ export class TicketsService {
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
||||
if (alreadyValidated) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// ── ROUND_TRIP — leg=OUTBOUND or leg=RETURN ────────────────────────
|
||||
if (type === 'ROUND_TRIP') {
|
||||
const resolvedLeg = (leg ?? 'OUTBOUND').toUpperCase();
|
||||
let resolvedLeg = (leg ?? '').toUpperCase();
|
||||
// Auto-detect next unused leg when called from backoffice without a leg param
|
||||
if (!resolvedLeg) {
|
||||
resolvedLeg = !(booking as any).outboundBoardedAt ? 'OUTBOUND' : 'RETURN';
|
||||
}
|
||||
const bookingData: Record<string, any> = {};
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
} else if (resolvedLeg === 'RETURN') {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Return leg already used');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
@@ -298,7 +322,7 @@ export class TicketsService {
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
@@ -311,7 +335,7 @@ export class TicketsService {
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
if (logs.some(l => l.leg === resolvedLeg)) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
}
|
||||
const bookingData: Record<string, any> = {};
|
||||
@@ -327,18 +351,17 @@ export class TicketsService {
|
||||
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// Fallback for unknown booking types — single scan
|
||||
if (ticket.validatedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'REJECTED', reason: 'ALREADY_VALIDATED' } });
|
||||
throw new BadRequestException('Ticket already validated');
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId, gateId, status: 'APPROVED' } });
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
}
|
||||
|
||||
@@ -354,7 +377,7 @@ export class TicketsService {
|
||||
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
||||
include: {
|
||||
ticket: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { include: { user: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,19 @@ export default function BookingsPage() {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
bookingRef: true,
|
||||
passenger: true,
|
||||
status: true,
|
||||
bookingType: false,
|
||||
passengerCount: false,
|
||||
totalMinor: true,
|
||||
paymentStatus: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -80,22 +93,23 @@ export default function BookingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportBookings = async () => {
|
||||
const selectedColumns = prompt(
|
||||
'Select columns to export (comma-separated):\n\n' +
|
||||
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
|
||||
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
|
||||
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
|
||||
);
|
||||
|
||||
if (!selectedColumns) return;
|
||||
|
||||
const cols = selectedColumns.split(',').map(c => c.trim());
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
const exportItems = (data?.items || []).filter((b: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...data?.items?.map((booking: any) => {
|
||||
...exportItems.map((booking: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch(col) {
|
||||
switch (col) {
|
||||
case 'bookingRef': return booking.bookingRef;
|
||||
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
|
||||
case 'status': return booking.status;
|
||||
@@ -108,28 +122,29 @@ export default function BookingsPage() {
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}) || []
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'bookingRef',
|
||||
{
|
||||
key: 'bookingRef',
|
||||
label: 'Reference',
|
||||
sortable: true,
|
||||
render: (booking: any) => (
|
||||
<span className="font-mono font-semibold">{booking.bookingRef}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'passenger',
|
||||
{
|
||||
key: 'passenger',
|
||||
label: 'Passenger',
|
||||
render: (booking: any) => (
|
||||
<div>
|
||||
@@ -138,32 +153,39 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bookingType',
|
||||
{
|
||||
key: 'bookingType',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (booking: any) => booking.bookingType || 'ONE_WAY',
|
||||
},
|
||||
{
|
||||
{
|
||||
key: 'passengerCount',
|
||||
label: 'Passengers',
|
||||
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
|
||||
render: (booking: any) => {
|
||||
const adults = booking.adultCount || 0;
|
||||
const children = booking.childCount || 0;
|
||||
if (adults === 0 && children === 0) return '—';
|
||||
const parts = [`Adult: ${adults}`];
|
||||
if (children > 0) parts.push(`Child: ${children}`);
|
||||
return parts.join(' / ');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalMinor',
|
||||
{
|
||||
key: 'totalMinor',
|
||||
label: 'Amount',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
label: 'Payment',
|
||||
render: (booking: any) => (
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
|
||||
@@ -171,8 +193,8 @@ export default function BookingsPage() {
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (booking: any) => formatDateTime(booking.createdAt),
|
||||
@@ -208,7 +230,7 @@ export default function BookingsPage() {
|
||||
<h1 className="text-2xl font-bold">Bookings</h1>
|
||||
<p className="text-muted-foreground">Manage all passenger bookings</p>
|
||||
</div>
|
||||
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -253,7 +275,7 @@ export default function BookingsPage() {
|
||||
loading={isLoading}
|
||||
emptyMessage="No bookings found"
|
||||
/>
|
||||
|
||||
|
||||
{data?.meta && (
|
||||
<Pagination
|
||||
currentPage={data.meta.page}
|
||||
@@ -264,15 +286,9 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Booking Details Modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => setSelectedBooking(null)}
|
||||
title="Booking Details"
|
||||
size="xl"
|
||||
>
|
||||
<Modal isOpen={!!selectedBooking} onClose={() => setSelectedBooking(null)} title="Booking Details" size="xl">
|
||||
{selectedBooking && (
|
||||
<div className="space-y-6">
|
||||
{/* Booking Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Booking Reference</label>
|
||||
@@ -281,9 +297,7 @@ export default function BookingsPage() {
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Status</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedBooking.status}>
|
||||
{selectedBooking.status}
|
||||
</Badge>
|
||||
<Badge variant="status" status={selectedBooking.status}>{selectedBooking.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -298,7 +312,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Passenger Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Passenger Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -323,7 +336,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Booking Details */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Journey Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -348,7 +360,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Payment Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Payment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -377,7 +388,6 @@ export default function BookingsPage() {
|
||||
|
||||
<hr className="border-muted" />
|
||||
|
||||
{/* Additional Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Additional Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -393,12 +403,7 @@ export default function BookingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedBooking(null)}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedBooking(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -407,10 +412,7 @@ export default function BookingsPage() {
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setBookingToDelete(null);
|
||||
}}
|
||||
onClose={() => { setDeleteConfirmOpen(false); setBookingToDelete(null); }}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Booking"
|
||||
message={`Are you sure you want to permanently delete booking ${bookingToDelete?.bookingRef}? This action cannot be undone and will release all associated seats.`}
|
||||
@@ -419,6 +421,53 @@ export default function BookingsPage() {
|
||||
isLoading={deleteMutation.isPending}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Created)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Created)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'bookingRef', label: 'Booking Reference' },
|
||||
{ key: 'passenger', label: 'Passenger' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'bookingType', label: 'Booking Type' },
|
||||
{ key: 'passengerCount', label: 'Passenger Count' },
|
||||
{ key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' },
|
||||
{ key: 'createdAt', label: 'Created At' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
|
||||
import { Edit, Loader2, RefreshCw } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
interface Currency {
|
||||
interface CurrencyRate {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -18,203 +17,104 @@ interface Currency {
|
||||
exchangeRate: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const CURRENCY_META: Record<string, { name: string; symbol: string }> = {
|
||||
ETB: { name: 'Ethiopian Birr', symbol: 'Br' },
|
||||
DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' },
|
||||
USD: { name: 'US Dollar', symbol: '$' },
|
||||
};
|
||||
|
||||
export default function CurrenciesPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
|
||||
isOpen: false,
|
||||
id: null,
|
||||
});
|
||||
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
|
||||
const [rateInput, setRateInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [currencyForm, setCurrencyForm] = useState({
|
||||
code: '',
|
||||
name: '',
|
||||
symbol: '',
|
||||
baseCurrencyCode: 'ETB',
|
||||
exchangeRate: '',
|
||||
});
|
||||
|
||||
const { data: currencies = [], isLoading } = useQuery({
|
||||
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
|
||||
queryKey: ['currencies'],
|
||||
queryFn: () => apiClient.get('/currencies'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/currencies', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||
resetForm();
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to create currency');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
|
||||
mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) =>
|
||||
apiClient.patch(`/currencies/${id}`, { exchangeRate }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||
setEditingCurrency(null);
|
||||
resetForm();
|
||||
setEditingRate(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to update currency');
|
||||
setError(err.response?.data?.message || 'Failed to update exchange rate');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||
setDeleteConfirm({ isOpen: false, id: null });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to delete currency');
|
||||
},
|
||||
});
|
||||
|
||||
const syncRatesMutation = useMutation({
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to sync exchange rates');
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
|
||||
onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrencyForm({
|
||||
code: '',
|
||||
name: '',
|
||||
symbol: '',
|
||||
baseCurrencyCode: 'ETB',
|
||||
exchangeRate: '',
|
||||
});
|
||||
setEditingCurrency(null);
|
||||
setShowModal(false);
|
||||
const handleEdit = (currency: CurrencyRate) => {
|
||||
setEditingRate(currency);
|
||||
setRateInput(currency.exchangeRate.toString());
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleEditCurrency = (currency: Currency) => {
|
||||
setEditingCurrency(currency);
|
||||
setCurrencyForm({
|
||||
code: currency.code,
|
||||
name: currency.name,
|
||||
symbol: currency.symbol,
|
||||
baseCurrencyCode: currency.baseCurrencyCode,
|
||||
exchangeRate: currency.exchangeRate.toString(),
|
||||
});
|
||||
setError(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSaveCurrency = async () => {
|
||||
setError(null);
|
||||
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
|
||||
setError('All fields are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const rate = parseFloat(currencyForm.exchangeRate);
|
||||
const handleSave = async () => {
|
||||
const rate = parseFloat(rateInput);
|
||||
if (isNaN(rate) || rate <= 0) {
|
||||
setError('Exchange rate must be a positive number');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
code: currencyForm.code.toUpperCase(),
|
||||
name: currencyForm.name,
|
||||
symbol: currencyForm.symbol,
|
||||
baseCurrencyCode: currencyForm.baseCurrencyCode,
|
||||
exchangeRate: rate,
|
||||
};
|
||||
|
||||
if (editingCurrency) {
|
||||
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteConfirm.id) {
|
||||
await deleteMutation.mutateAsync(deleteConfirm.id);
|
||||
}
|
||||
};
|
||||
|
||||
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
|
||||
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'code',
|
||||
label: 'Code',
|
||||
render: (currency: Currency) => (
|
||||
<span className="font-mono font-semibold text-primary">{currency.code}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
render: (currency: Currency) => (
|
||||
<span className="font-medium">{currency.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'symbol',
|
||||
label: 'Symbol',
|
||||
render: (currency: Currency) => (
|
||||
<span className="text-lg">{currency.symbol}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'baseCurrencyCode',
|
||||
label: 'Base Currency',
|
||||
render: (currency: Currency) => (
|
||||
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exchangeRate',
|
||||
label: 'Exchange Rate',
|
||||
render: (currency: Currency) => (
|
||||
<div className="space-y-1">
|
||||
<div className="font-mono font-semibold">
|
||||
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
|
||||
label: 'Currency',
|
||||
render: (c: CurrencyRate) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl font-bold text-muted-foreground w-10 text-center">
|
||||
{CURRENCY_META[c.code]?.symbol ?? c.symbol}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-semibold">{c.code}</div>
|
||||
<div className="text-xs text-muted-foreground">{CURRENCY_META[c.code]?.name ?? c.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isActive',
|
||||
label: 'Status',
|
||||
render: (currency: Currency) => (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
currency.isActive
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
|
||||
}`}>
|
||||
{currency.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
key: 'baseCurrencyCode',
|
||||
label: 'Base',
|
||||
render: (c: CurrencyRate) => (
|
||||
<span className="font-mono text-sm text-muted-foreground">{c.baseCurrencyCode}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
key: 'exchangeRate',
|
||||
label: 'Exchange Rate',
|
||||
render: (c: CurrencyRate) => (
|
||||
<div>
|
||||
<div className="font-mono font-semibold">
|
||||
1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Last Updated',
|
||||
render: (currency: Currency) => (
|
||||
render: (c: CurrencyRate) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(currency.updatedAt).toLocaleDateString()}
|
||||
{new Date(c.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -222,140 +122,93 @@ export default function CurrenciesPage() {
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: handleEditCurrency,
|
||||
label: 'Edit Rate',
|
||||
onClick: handleEdit,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
|
||||
variant: 'danger' as const,
|
||||
icon: Trash2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<ActionButton
|
||||
icon={RefreshCw}
|
||||
variant="secondary"
|
||||
onClick={() => syncRatesMutation.mutate()}
|
||||
loading={syncRatesMutation.isPending}
|
||||
>
|
||||
Sync Rates
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setEditingCurrency(null);
|
||||
setCurrencyForm({
|
||||
code: '',
|
||||
name: '',
|
||||
symbol: '',
|
||||
baseCurrencyCode: 'ETB',
|
||||
exchangeRate: '',
|
||||
});
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Currency
|
||||
</ActionButton>
|
||||
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage ETB exchange rates for display currencies (DJF, USD)
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={RefreshCw}
|
||||
variant="secondary"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
loading={syncMutation.isPending}
|
||||
>
|
||||
Sync Rates
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{error && !editingRate && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
|
||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
|
||||
{currenciesArray.length}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
{(['ETB', 'DJF', 'USD'] as const).map((code) => {
|
||||
const entry = currenciesArray.find((c: CurrencyRate) => c.code === code);
|
||||
return (
|
||||
<div
|
||||
key={code}
|
||||
className="p-4 rounded-lg border bg-muted/30 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground font-medium">{CURRENCY_META[code].name}</div>
|
||||
<div className="text-2xl font-bold mt-1">{code}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{entry ? (
|
||||
<>
|
||||
<div className="font-mono font-semibold text-lg">{entry.exchangeRate}</div>
|
||||
<div className="text-xs text-muted-foreground">per ETB</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Not configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
|
||||
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
|
||||
{currenciesArray.filter((c: Currency) => c.isActive).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
|
||||
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
|
||||
</div>
|
||||
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
|
||||
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
|
||||
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
|
||||
{currenciesArray.length > 0
|
||||
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : currenciesArray.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No currencies configured. Click "Add Currency" to create one.</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={currenciesArray}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={false}
|
||||
emptyMessage="No currencies found."
|
||||
/>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={currenciesArray}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={false}
|
||||
emptyMessage="No exchange rates configured."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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-3">Currency Management</h3>
|
||||
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
|
||||
<li>
|
||||
• <strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Display Currencies:</strong> Configure which currencies customers can view prices in
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Sync Rates:</strong> Automatically update exchange rates from external sources
|
||||
</li>
|
||||
</ul>
|
||||
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-sm text-blue-800 dark:text-blue-300 space-y-1">
|
||||
<p className="font-semibold text-blue-900 dark:text-blue-200 mb-2">How it works</p>
|
||||
<p>• ETB is the transaction currency — all fares are stored in ETB minor units (1 ETB = 100 minor)</p>
|
||||
<p>• DJF and USD rates are used to display prices to passengers in their preferred currency</p>
|
||||
<p>• Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Currency"
|
||||
message="Are you sure you want to delete this currency? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
isDanger={true}
|
||||
warning="This will remove the currency from the system."
|
||||
/>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={resetForm}
|
||||
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
|
||||
size="lg"
|
||||
isOpen={!!editingRate}
|
||||
onClose={() => { setEditingRate(null); setError(null); }}
|
||||
title={`Update Rate — ${editingRate?.code}`}
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
@@ -364,108 +217,38 @@ export default function CurrenciesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Currency Code *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currencyForm.code}
|
||||
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., USD"
|
||||
maxLength={3}
|
||||
disabled={!!editingCurrency}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Currency Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currencyForm.name}
|
||||
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., United States Dollar"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Symbol *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currencyForm.symbol}
|
||||
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., $"
|
||||
maxLength={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Currency *</label>
|
||||
<select
|
||||
value={currencyForm.baseCurrencyCode}
|
||||
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
|
||||
className="input w-full"
|
||||
disabled
|
||||
>
|
||||
<option value="ETB">ETB (Ethiopian Birr)</option>
|
||||
<option value="USD">USD (US Dollar)</option>
|
||||
<option value="DJF">DJF (Djiboutian Franc)</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/40 rounded-lg text-sm">
|
||||
<span className="text-muted-foreground">Currency: </span>
|
||||
<span className="font-semibold">{editingRate?.code} — {CURRENCY_META[editingRate?.code ?? '']?.name}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Exchange Rate *</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
value={currencyForm.exchangeRate}
|
||||
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., 0.018"
|
||||
required
|
||||
/>
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
|
||||
</div>
|
||||
</div>
|
||||
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
≈ 1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
|
||||
<label className="label">
|
||||
1 {editingRate?.baseCurrencyCode} = ? {editingRate?.code}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.0001"
|
||||
step="0.0001"
|
||||
value={rateInput}
|
||||
onChange={(e) => setRateInput(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="e.g., 3.25"
|
||||
autoFocus
|
||||
/>
|
||||
{rateInput && parseFloat(rateInput) > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
≈ 1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
|
||||
<p className="font-semibold mb-1">Exchange Rate Example:</p>
|
||||
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
|
||||
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={resetForm}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={handleSaveCurrency}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{editingCurrency ? 'Update Currency' : 'Add Currency'}
|
||||
<ActionButton onClick={handleSave} loading={updateMutation.isPending}>
|
||||
Save Rate
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,12 @@ export default function PassengersPage() {
|
||||
});
|
||||
const [selectedPassenger, setSelectedPassenger] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; passenger: any | null }>({ isOpen: false, passenger: null });
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
fullName: true, email: true, phone: true, gender: true, nationality: true, verified: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -52,22 +58,23 @@ export default function PassengersPage() {
|
||||
console.error('Passengers API Error:', error);
|
||||
}
|
||||
|
||||
const handleExportPassengers = async () => {
|
||||
const selectedColumns = prompt(
|
||||
'Select columns to export (comma-separated):\n\n' +
|
||||
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
|
||||
'Default: fullName, email, phone, gender, nationality, verified',
|
||||
'fullName, email, phone, gender, nationality, verified'
|
||||
);
|
||||
|
||||
if (!selectedColumns) return;
|
||||
|
||||
const cols = selectedColumns.split(',').map(c => c.trim());
|
||||
const confirmExportPassengers = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
const exportItems = (data?.items || []).filter((p: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...data?.items?.map((passenger: any) => {
|
||||
...exportItems.map((passenger: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch(col) {
|
||||
switch (col) {
|
||||
case 'fullName': return passenger.fullName;
|
||||
case 'email': return passenger.email || '';
|
||||
case 'phone': return passenger.phone || '';
|
||||
@@ -79,15 +86,16 @@ export default function PassengersPage() {
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}) || []
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -160,7 +168,7 @@ export default function PassengersPage() {
|
||||
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
|
||||
<ActionButton variant="export" icon={Download} onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -381,6 +389,51 @@ export default function PassengersPage() {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Passengers" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Registered)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'fullName', label: 'Full Name' },
|
||||
{ key: 'email', label: 'Email' },
|
||||
{ key: 'phone', label: 'Phone' },
|
||||
{ key: 'dateOfBirth', label: 'Date of Birth' },
|
||||
{ key: 'gender', label: 'Gender' },
|
||||
{ key: 'nationality', label: 'Nationality' },
|
||||
{ key: 'verified', label: 'Verified' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExportPassengers}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,27 +6,76 @@ import { Download } 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 { paymentsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['payments', filters],
|
||||
queryFn: () => paymentsApi.getAll(filters),
|
||||
queryFn: () => paymentsApi.getAll({
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
method: filters.method || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
|
||||
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
|
||||
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
|
||||
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
|
||||
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
];
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (cols.length === 0) { alert('Please select at least one column'); return; }
|
||||
|
||||
const actions: any[] = [];
|
||||
const items = ((data as any)?.items || (Array.isArray(data) ? data : [])) as any[];
|
||||
const exportItems = items.filter((p: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = p.createdAt ? new Date(p.createdAt).toISOString().split('T')[0] : null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...exportItems.map((payment: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch (col) {
|
||||
case 'reference': return payment.reference || payment.id?.substring(0, 8) || '';
|
||||
case 'booking': return payment.booking?.bookingRef || 'N/A';
|
||||
case 'amount': return formatCurrency(payment.amountMinor, payment.currency);
|
||||
case 'method': return payment.method || '';
|
||||
case 'status': return payment.status || '';
|
||||
case 'createdAt': return payment.createdAt || '';
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `payments-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
|
||||
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
|
||||
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
|
||||
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
|
||||
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -35,36 +84,91 @@ export default function PaymentsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
||||
<option value="">All Status</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Method</label>
|
||||
<select className="input" value={filters.method} onChange={(e) => setFilters({ ...filters, method: e.target.value })}>
|
||||
<option value="">All Methods</option>
|
||||
<option value="TELEBIRR">Telebirr</option>
|
||||
<option value="CBE_BIRR">CBE Birr</option>
|
||||
<option value="EBIRR">eBirr</option>
|
||||
<option value="CARD">Card</option>
|
||||
<option value="WALLET">Wallet</option>
|
||||
<option value="CASH">Cash</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
actions={[]}
|
||||
loading={isLoading}
|
||||
emptyMessage="No payments found"
|
||||
/>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: 'reference', label: 'Reference' },
|
||||
{ key: 'booking', label: 'Booking Reference' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'method', label: 'Payment Method' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'createdAt', label: 'Created At' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportColumns[col.key] || false}
|
||||
onChange={(e) => setExportColumns({ ...exportColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -731,14 +731,19 @@ export default function PricingPage() {
|
||||
|
||||
<div>
|
||||
<label className="label">Route Code (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
<select
|
||||
value={fareForm.route}
|
||||
onChange={(e) => setFareForm({ ...fareForm, route: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., ADD-DJI"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">e.g., ADD-DJI for full route</p>
|
||||
>
|
||||
<option value="">All routes</option>
|
||||
{routesArray.map((route: Route) => (
|
||||
<option key={route.id} value={route.code}>
|
||||
{route.code} — {route.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">Scope this fare to a specific route</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function SeatsPage() {
|
||||
|
||||
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
||||
|
||||
if (isBedCoach && hasBedPositionData) {
|
||||
if (isBedCoach) {
|
||||
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
||||
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
||||
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
||||
|
||||
@@ -9,11 +9,11 @@ import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
|
||||
import { ticketsApi, apiClient, stationsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '' });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
|
||||
@@ -22,6 +22,8 @@ export default function TicketsPage() {
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
|
||||
ticketNumber: true,
|
||||
booking: true,
|
||||
@@ -35,7 +37,15 @@ export default function TicketsPage() {
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tickets', filters],
|
||||
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
|
||||
queryFn: () => ticketsApi.getAll({
|
||||
search: filters.search || undefined,
|
||||
status: filters.status || undefined,
|
||||
originStationId: filters.originStationId || undefined,
|
||||
destinationStationId: filters.destinationStationId || undefined,
|
||||
arrivalDate: filters.arrivalDate || undefined,
|
||||
skip: 0,
|
||||
take: 50,
|
||||
}),
|
||||
});
|
||||
|
||||
const { data: stationsData } = useQuery({
|
||||
@@ -94,25 +104,31 @@ export default function TicketsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTickets = async () => {
|
||||
setExportModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmExport = () => {
|
||||
const cols = Object.entries(selectedColumns)
|
||||
.filter(([, selected]) => selected)
|
||||
.map(([col]) => col);
|
||||
|
||||
|
||||
if (cols.length === 0) {
|
||||
alert('Please select at least one column');
|
||||
return;
|
||||
}
|
||||
|
||||
const exportItems = (data?.items || []).filter((ticket: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = ticket.schedule?.arrivalAt
|
||||
? new Date(ticket.schedule.arrivalAt).toISOString().split('T')[0]
|
||||
: null;
|
||||
if (exportDateFrom && (!d || d < exportDateFrom)) return false;
|
||||
if (exportDateTo && (!d || d > exportDateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const csv = [
|
||||
cols.join(','),
|
||||
...data?.items?.map((ticket: any) => {
|
||||
...exportItems.map((ticket: any) => {
|
||||
const values = cols.map(col => {
|
||||
switch(col) {
|
||||
switch (col) {
|
||||
case 'ticketNumber': return ticket.ticketNumber || '';
|
||||
case 'booking': return ticket.booking?.bookingRef || '';
|
||||
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
|
||||
@@ -126,9 +142,9 @@ export default function TicketsPage() {
|
||||
}
|
||||
});
|
||||
return values.map(v => `"${v}"`).join(',');
|
||||
}) || []
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -175,12 +191,12 @@ export default function TicketsPage() {
|
||||
},
|
||||
{
|
||||
key: 'seat',
|
||||
label: 'Seat',
|
||||
label: 'Seat/Bed',
|
||||
sortable: true,
|
||||
render: (ticket: any) => (
|
||||
<div>
|
||||
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div>
|
||||
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'} - {ticket.seat?.seatNumber || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -264,7 +280,7 @@ export default function TicketsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
|
||||
<p className="text-muted-foreground">Manage tickets and validations</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
@@ -317,12 +333,12 @@ export default function TicketsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Trip Date</label>
|
||||
<label className="label">Arrival Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={filters.tripDate}
|
||||
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
|
||||
value={filters.arrivalDate}
|
||||
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -353,10 +369,7 @@ export default function TicketsPage() {
|
||||
{/* Board Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={boardConfirmOpen}
|
||||
onClose={() => {
|
||||
setBoardConfirmOpen(false);
|
||||
setTicketToBoard(null);
|
||||
}}
|
||||
onClose={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}
|
||||
onConfirm={handleConfirmBoard}
|
||||
title="Board Ticket"
|
||||
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
|
||||
@@ -368,10 +381,7 @@ export default function TicketsPage() {
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
setTicketToDelete(null);
|
||||
}}
|
||||
onClose={() => { setDeleteConfirmOpen(false); setTicketToDelete(null); }}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Ticket"
|
||||
message={`Are you sure you want to permanently delete ticket ${ticketToDelete?.ticketNumber}? This action cannot be undone.`}
|
||||
@@ -384,10 +394,7 @@ export default function TicketsPage() {
|
||||
{/* Ticket Details Modal */}
|
||||
<Modal
|
||||
isOpen={detailsModalOpen}
|
||||
onClose={() => {
|
||||
setDetailsModalOpen(false);
|
||||
setSelectedTicket(null);
|
||||
}}
|
||||
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
|
||||
title="Ticket Details"
|
||||
size="lg"
|
||||
>
|
||||
@@ -488,13 +495,7 @@ export default function TicketsPage() {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDetailsModalOpen(false);
|
||||
setSelectedTicket(null);
|
||||
}}
|
||||
>
|
||||
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
@@ -502,49 +503,55 @@ export default function TicketsPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Export Columns Modal */}
|
||||
{/* Export Modal */}
|
||||
<Modal
|
||||
isOpen={exportModalOpen}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
title="Export Tickets - Select Columns"
|
||||
title="Export Tickets"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
|
||||
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||
{ key: 'booking', label: 'Booking Reference & Passenger' },
|
||||
{ key: 'trip', label: 'Trip (Origin → Destination)' },
|
||||
{ key: 'coach', label: 'Coach Number' },
|
||||
{ key: 'seat', label: 'Seat Number' },
|
||||
{ key: 'seatClass', label: 'Seat Class' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'boarded', label: 'Boarded Status' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColumns[col.key] || false}
|
||||
onChange={(e) =>
|
||||
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Date From (Arrival)</label>
|
||||
<input type="date" className="input" value={exportDateFrom} onChange={(e) => setExportDateFrom(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Date To (Arrival)</label>
|
||||
<input type="date" className="input" value={exportDateTo} onChange={(e) => setExportDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Select Columns</p>
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto">
|
||||
{[
|
||||
{ key: 'ticketNumber', label: 'Ticket Number' },
|
||||
{ key: 'booking', label: 'Booking Reference & Passenger' },
|
||||
{ key: 'trip', label: 'Trip (Origin → Destination)' },
|
||||
{ key: 'coach', label: 'Coach Number' },
|
||||
{ key: 'seat', label: 'Seat Number' },
|
||||
{ key: 'seatClass', label: 'Seat Class' },
|
||||
{ key: 'amount', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'boarded', label: 'Boarded Status' },
|
||||
].map((col) => (
|
||||
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedColumns[col.key] || false}
|
||||
onChange={(e) => setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={confirmExport}>Export CSV</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user