mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #264 from Tria-plc/alpha
Update seatmap configuration
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentBooking" DROP CONSTRAINT "AgentBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentCommission" DROP CONSTRAINT "AgentCommission_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AgentShift" DROP CONSTRAINT "AgentShift_agentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BaggageBooking" DROP CONSTRAINT "BaggageBooking_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Booking" DROP CONSTRAINT "Booking_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingCancellation" DROP CONSTRAINT "BookingCancellation_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingModification" DROP CONSTRAINT "BookingModification_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "BookingSeat" DROP CONSTRAINT "BookingSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Coach" DROP CONSTRAINT "Coach_coachTypeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_coachId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CoachAssignment" DROP CONSTRAINT "CoachAssignment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FaqArticle" DROP CONSTRAINT "FaqArticle_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FareRule" DROP CONSTRAINT "FareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrder" DROP CONSTRAINT "FoodOrder_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "FoodOrderItem" DROP CONSTRAINT "FoodOrderItem_orderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "GateValidationLog" DROP CONSTRAINT "GateValidationLog_ticketId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_journeyId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JourneySegment" DROP CONSTRAINT "JourneySegment_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" DROP CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LoyaltyReward" DROP CONSTRAINT "LoyaltyReward_accountId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_categoryId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MenuItem" DROP CONSTRAINT "MenuItem_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PaymentRefund" DROP CONSTRAINT "PaymentRefund_paymentIntentId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RouteFareRule" DROP CONSTRAINT "RouteFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SavedRoute" DROP CONSTRAINT "SavedRoute_passengerId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SeatBlock" DROP CONSTRAINT "SeatBlock_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SegmentFareRule" DROP CONSTRAINT "SegmentFareRule_seatClassId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" DROP CONSTRAINT "StationCrowdSignal_stationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "SupportMessage" DROP CONSTRAINT "SupportMessage_conversationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Ticket" DROP CONSTRAINT "Ticket_bookingId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TicketSeat" DROP CONSTRAINT "TicketSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_destinationStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_originStationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_routeId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TrainSchedule" DROP CONSTRAINT "TrainSchedule_trainId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripLiveStatus" DROP CONSTRAINT "TripLiveStatus_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TripStopTime" DROP CONSTRAINT "TripStopTime_scheduleId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" DROP CONSTRAINT "WalletLedgerEntry_walletId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Journey_bookingId_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FaydaVerificationSession" ALTER COLUMN "purpose" SET DEFAULT 'VERIFY';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SystemConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_trainId_fkey" FOREIGN KEY ("trainId") REFERENCES "Train"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_originStationId_fkey" FOREIGN KEY ("originStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrainSchedule" ADD CONSTRAINT "TrainSchedule_destinationStationId_fkey" FOREIGN KEY ("destinationStationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripStopTime" ADD CONSTRAINT "TripStopTime_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TripLiveStatus" ADD CONSTRAINT "TripLiveStatus_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coach" ADD CONSTRAINT "Coach_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") REFERENCES "CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CoachAssignment" ADD CONSTRAINT "CoachAssignment_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FareRule" ADD CONSTRAINT "FareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_returnScheduleId_fkey" FOREIGN KEY ("returnScheduleId") REFERENCES "TrainSchedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingSeat" ADD CONSTRAINT "BookingSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PaymentRefund" ADD CONSTRAINT "PaymentRefund_paymentIntentId_fkey" FOREIGN KEY ("paymentIntentId") REFERENCES "PaymentIntent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketSeat" ADD CONSTRAINT "TicketSeat_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyLedgerEntry" ADD CONSTRAINT "LoyaltyLedgerEntry_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LoyaltyReward" ADD CONSTRAINT "LoyaltyReward_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "LoyaltyAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WalletLedgerEntry" ADD CONSTRAINT "WalletLedgerEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "WalletAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StationCrowdSignal" ADD CONSTRAINT "StationCrowdSignal_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "MenuCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrder" ADD CONSTRAINT "FoodOrder_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FoodOrderItem" ADD CONSTRAINT "FoodOrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "FoodOrder"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FaqArticle" ADD CONSTRAINT "FaqArticle_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "FaqCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SupportMessage" ADD CONSTRAINT "SupportMessage_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "SupportConversation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedRoute" ADD CONSTRAINT "SavedRoute_passengerId_fkey" FOREIGN KEY ("passengerId") REFERENCES "Passenger"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Journey" ADD CONSTRAINT "Journey_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_journeyId_fkey" FOREIGN KEY ("journeyId") REFERENCES "Journey"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JourneySegment" ADD CONSTRAINT "JourneySegment_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "TrainSchedule"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteFareRule" ADD CONSTRAINT "RouteFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SegmentFareRule" ADD CONSTRAINT "SegmentFareRule_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentBooking" ADD CONSTRAINT "AgentBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentShift" ADD CONSTRAINT "AgentShift_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentCommission" ADD CONSTRAINT "AgentCommission_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingModification" ADD CONSTRAINT "BookingModification_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BookingCancellation" ADD CONSTRAINT "BookingCancellation_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GateValidationLog" ADD CONSTRAINT "GateValidationLog_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BaggageBooking" ADD CONSTRAINT "BaggageBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeatBlock" ADD CONSTRAINT "SeatBlock_seatId_fkey" FOREIGN KEY ("seatId") REFERENCES "Seat"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -283,15 +283,17 @@ model Passenger {
|
||||
userId String? @unique
|
||||
iamUserId String? @unique
|
||||
defaultTravelerProfileId String?
|
||||
preferredLanguage String?
|
||||
preferredLanguage String?
|
||||
blockedUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
bookings Booking[]
|
||||
loyalty LoyaltyAccount?
|
||||
wallet WalletAccount?
|
||||
notifications Notification[]
|
||||
travelerProfiles TravelerProfile[]
|
||||
savedRoutes SavedRoute[]
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
bookings Booking[]
|
||||
loyalty LoyaltyAccount?
|
||||
wallet WalletAccount?
|
||||
notifications Notification[]
|
||||
travelerProfiles TravelerProfile[]
|
||||
savedRoutes SavedRoute[]
|
||||
packageBookings PackageBooking[]
|
||||
@@index([userId])
|
||||
@@index([iamUserId])
|
||||
@@schema("passenger")
|
||||
@@ -370,6 +372,8 @@ model TrainSchedule {
|
||||
liveStatus TripLiveStatus?
|
||||
menuItems MenuItem[]
|
||||
journeySegments JourneySegment[]
|
||||
outboundPackages TravelPackage[] @relation("PackageOutbound")
|
||||
returnPackages TravelPackage[] @relation("PackageReturn")
|
||||
|
||||
@@index([departureAt, originStationId])
|
||||
@@schema("passenger")
|
||||
@@ -1337,3 +1341,135 @@ model FaydaVerificationSession {
|
||||
@@index([expiresAt])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model SystemConfig {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
value String
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
enum PackageStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
SOLD_OUT
|
||||
EXPIRED
|
||||
CANCELLED
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model TravelPackage {
|
||||
id String @id @default(uuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
status PackageStatus @default(DRAFT)
|
||||
outboundScheduleId String
|
||||
returnScheduleId String
|
||||
originStationId String
|
||||
destinationStationId String
|
||||
boardingTime DateTime
|
||||
departureTime DateTime
|
||||
arrivalTime DateTime
|
||||
totalCapacity Int
|
||||
bookedCount Int @default(0)
|
||||
includedServices Json
|
||||
coachConfiguration String?
|
||||
busTransferIncluded Boolean @default(false)
|
||||
busTransferRoute String?
|
||||
validFrom DateTime
|
||||
validUntil DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
outboundSchedule TrainSchedule @relation("PackageOutbound", fields: [outboundScheduleId], references: [id])
|
||||
returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id])
|
||||
priceTiers PackagePriceTier[]
|
||||
bookings PackageBooking[]
|
||||
|
||||
@@index([status, validFrom])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model PackagePriceTier {
|
||||
id String @id @default(uuid())
|
||||
packageId String
|
||||
seatType String
|
||||
label String
|
||||
priceMinor Int
|
||||
currency String @default("ETB")
|
||||
availableSeats Int @default(0)
|
||||
bookedSeats Int @default(0)
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
bookings PackageBooking[]
|
||||
|
||||
@@unique([packageId, seatType])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model PackageBooking {
|
||||
id String @id @default(uuid())
|
||||
bookingRef String @unique
|
||||
packageId String
|
||||
priceTierId String
|
||||
passengerId String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
status BookingStatus @default(PENDING_PAYMENT)
|
||||
passengerCount Int @default(1)
|
||||
totalMinor Int
|
||||
currency String @default("ETB")
|
||||
displayCurrency Currency?
|
||||
displayTotalMinor Int?
|
||||
promoCode String?
|
||||
source String @default("WEB")
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
package TravelPackage @relation(fields: [packageId], references: [id])
|
||||
priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id])
|
||||
passenger Passenger? @relation(fields: [passengerId], references: [id])
|
||||
passengers PackageBookingPassenger[]
|
||||
paymentIntent PackagePaymentIntent?
|
||||
|
||||
@@index([packageId, status])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model PackageBookingPassenger {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
passengerName String
|
||||
dateOfBirth DateTime?
|
||||
idDocumentType IdDocumentType?
|
||||
idDocumentNumber String?
|
||||
passportNumber String?
|
||||
passportCountry String?
|
||||
seatLabel String?
|
||||
|
||||
booking PackageBooking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model PackagePaymentIntent {
|
||||
id String @id @default(uuid())
|
||||
packageBookingId String @unique
|
||||
amountMinor Int
|
||||
currency String @default("ETB")
|
||||
method PaymentMethodType
|
||||
status PaymentIntentStatus @default(REQUIRES_ACTION)
|
||||
providerRef String?
|
||||
paidAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
packageBooking PackageBooking @relation(fields: [packageBookingId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -670,6 +670,70 @@ async function seedFraudRules() {
|
||||
console.log(` ✅ ${rules.length} fraud detection rules created`);
|
||||
}
|
||||
|
||||
async function seedKulubbiPackage() {
|
||||
console.log('\n🚆 Seeding Kulubbi Gabriel 2025 package...');
|
||||
|
||||
const addisStation = await prisma.station.findFirst({ where: { code: 'SBT' } });
|
||||
const direDawaStation = await prisma.station.findFirst({ where: { code: 'DRE' } });
|
||||
if (!addisStation || !direDawaStation) {
|
||||
console.log(' ⚠️ Stations not found, skipping Kulubbi package seed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the first two schedules as outbound/return (or create dedicated ones)
|
||||
const schedules = await prisma.trainSchedule.findMany({ take: 2, orderBy: { departureAt: 'asc' } });
|
||||
if (schedules.length < 2) {
|
||||
console.log(' ⚠️ Not enough schedules found, skipping Kulubbi package seed');
|
||||
return;
|
||||
}
|
||||
const [outboundSchedule, returnSchedule] = schedules;
|
||||
|
||||
await prisma.travelPackage.upsert({
|
||||
where: { code: 'KULUBBI-2025' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'KULUBBI-2025',
|
||||
name: 'Kulubbi Gabriel Pilgrimage Package',
|
||||
description: 'Annual pilgrimage round-trip package to Kulubi Gabriel Church. Includes train travel, bus transfer, meals, and entertainment.',
|
||||
outboundScheduleId: outboundSchedule.id,
|
||||
returnScheduleId: returnSchedule.id,
|
||||
originStationId: addisStation.id,
|
||||
destinationStationId: direDawaStation.id,
|
||||
boardingTime: new Date('2025-07-24T07:00:00+03:00'),
|
||||
departureTime: new Date('2025-07-24T09:00:00+03:00'),
|
||||
arrivalTime: new Date('2025-07-25T06:00:00+03:00'),
|
||||
totalCapacity: 912,
|
||||
coachConfiguration: '1 Locomotive + 2SBC + 2HBC + 6HSC',
|
||||
busTransferIncluded: true,
|
||||
busTransferRoute: 'Dire Dawa ↔ Kulubi Gabriel',
|
||||
validFrom: new Date('2025-07-01'),
|
||||
validUntil: new Date('2025-07-24T09:00:00+03:00'),
|
||||
status: 'ACTIVE',
|
||||
includedServices: [
|
||||
'Round-trip train travel (Addis Ababa ↔ Dire Dawa)',
|
||||
'Lunch served on board',
|
||||
'Refreshments and bottled water',
|
||||
'Round-trip bus transfer (Dire Dawa ↔ Kulubi Gabriel)',
|
||||
'Onboard first aid and medical support',
|
||||
'Entertainment (audio/video)',
|
||||
'Service briefing and pilgrimage guidance',
|
||||
'Pick-up and drop-off coordination',
|
||||
],
|
||||
priceTiers: {
|
||||
create: [
|
||||
{ seatType: 'HSC', label: 'Regular Seat (HSC)', priceMinor: 1023200, availableSeats: 550 },
|
||||
{ seatType: 'ECU', label: 'Economic Bed Upper (ECU)', priceMinor: 1295200, availableSeats: 80 },
|
||||
{ seatType: 'ECM', label: 'Economic Bed Middle (ECM)', priceMinor: 1364000, availableSeats: 80 },
|
||||
{ seatType: 'ECL', label: 'Economic Bed Lower (ECL)', priceMinor: 1430200, availableSeats: 80 },
|
||||
{ seatType: 'VIU', label: 'VIP Bed Upper (VIU)', priceMinor: 1243500, availableSeats: 61 },
|
||||
{ seatType: 'VIL', label: 'VIP Bed Lower (VIL)', priceMinor: 1643500, availableSeats: 61 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(' ✅ Kulubbi Gabriel 2025 package created');
|
||||
}
|
||||
|
||||
// Run a seed step in isolation: if it throws (FK conflict, duplicate row,
|
||||
// missing record, etc.) log the error and keep going so the rest of the seed —
|
||||
// and the API startup that follows it — are never blocked by one bad step.
|
||||
@@ -691,7 +755,8 @@ async function main() {
|
||||
['fare rules', seedFareRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
['currency', seedCurrency],
|
||||
['notification templates', seedNotificationTemplates]
|
||||
['notification templates', seedNotificationTemplates],
|
||||
['kulubbi package', seedKulubbiPackage],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -57,6 +57,8 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
|
||||
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||
import { AuditModuleFeature } from './modules/audit/audit.module';
|
||||
import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||
import { SystemConfigModule } from './modules/system-config/system-config.module';
|
||||
import { PackagesModule } from './modules/packages/packages.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -116,6 +118,8 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||
VerifaydaModule,
|
||||
AuditModuleFeature,
|
||||
CurrenciesModule,
|
||||
SystemConfigModule,
|
||||
PackagesModule,
|
||||
],
|
||||
providers: [
|
||||
EdrPassengerOrgSeeder,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
|
||||
import { FleetService } from './fleet.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
|
||||
@ApiTags('Fleet')
|
||||
@@ -317,6 +317,39 @@ export class FleetController {
|
||||
return this.service.removeAssignment(id);
|
||||
}
|
||||
|
||||
@Post('seatmap/generate')
|
||||
@ApiOperation({
|
||||
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
|
||||
description: `Generates a structured seat map for bed coaches without persisting anything.
|
||||
|
||||
**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper)
|
||||
|
||||
**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper)
|
||||
|
||||
Use this to preview the full flat seat list before creating coaches.`,
|
||||
})
|
||||
@ApiBody({ type: GenerateSeatMapDto })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Generated seat map preview',
|
||||
schema: {
|
||||
example: {
|
||||
coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12,
|
||||
seats: [
|
||||
{ seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' },
|
||||
{ seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
generateSeatMap(@Body() dto: GenerateSeatMapDto) {
|
||||
return this.service.generateSeatMapPreview(dto);
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Occupancy statistics' })
|
||||
|
||||
@@ -12,9 +12,20 @@ export class CreateTrainDto {
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
|
||||
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
|
||||
@ApiPropertyOptional({
|
||||
enum: ['ECONOMY_BED', 'VIP_BED'],
|
||||
description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.',
|
||||
example: 'VIP_BED',
|
||||
})
|
||||
@IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED';
|
||||
@ApiPropertyOptional({
|
||||
example: 4,
|
||||
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
|
||||
})
|
||||
@IsOptional() @IsInt() bedsPerRoom?: number;
|
||||
}
|
||||
|
||||
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
|
||||
@@ -67,3 +78,14 @@ export class UpdateClassDto {
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class GenerateSeatMapDto {
|
||||
@ApiProperty({ example: 2, description: 'Number of coaches' })
|
||||
@IsInt() coachCount: number;
|
||||
|
||||
@ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' })
|
||||
@IsInt() roomsPerCoach: number;
|
||||
|
||||
@ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U × Left/Right), VIP_BED = 4 beds/room (L/U × Left/Right)' })
|
||||
@IsString() roomType: 'ECONOMY_BED' | 'VIP_BED';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { SeatKind } from '@prisma/client';
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
@@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] {
|
||||
type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null;
|
||||
|
||||
// Default beds per room for each category when not explicitly configured
|
||||
const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
|
||||
VIP_BED: 4,
|
||||
ECONOMY_BED: 6,
|
||||
};
|
||||
|
||||
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
||||
function detectBedCategory(coachTypeName: string): BedCategory {
|
||||
const name = coachTypeName.toLowerCase();
|
||||
const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette');
|
||||
if (!isBed) return null;
|
||||
if (name.includes('vip')) return 'VIP_BED';
|
||||
return 'ECONOMY_BED';
|
||||
}
|
||||
|
||||
// Resolves bed type names per side from beds-per-side count:
|
||||
// 2/side → ['LOWER','UPPER'] (VIP style)
|
||||
// 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style)
|
||||
function resolveBedTypes(bedsPerSide: number): string[] {
|
||||
if (bedsPerSide === 1) return ['LOWER'];
|
||||
if (bedsPerSide === 2) return ['LOWER', 'UPPER'];
|
||||
if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER'];
|
||||
return Array.from({ length: bedsPerSide }, (_, i) => {
|
||||
if (i === 0) return 'LOWER';
|
||||
if (i === bedsPerSide - 1) return 'UPPER';
|
||||
return 'MIDDLE';
|
||||
});
|
||||
}
|
||||
|
||||
// Generates the flat seat/bed list for a bed coach.
|
||||
// Row = room number; col = position-relative label (L1, L2 … R1, R2 …).
|
||||
function buildBedSeats(
|
||||
coachId: string,
|
||||
capacity: number,
|
||||
bedsPerRoom: number,
|
||||
): SeatRow[] {
|
||||
const bedsPerSide = bedsPerRoom / 2;
|
||||
const bedTypeNames = resolveBedTypes(bedsPerSide);
|
||||
const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [
|
||||
...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })),
|
||||
...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })),
|
||||
];
|
||||
|
||||
const roomCount = Math.ceil(capacity / bedsPerRoom);
|
||||
const seats: SeatRow[] = [];
|
||||
let seatNumber = 1;
|
||||
|
||||
for (let room = 1; room <= roomCount; room++) {
|
||||
const posCount: Record<string, number> = {};
|
||||
for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) {
|
||||
const { position, bedType } = layout[slot];
|
||||
posCount[position] = (posCount[position] ?? 0) + 1;
|
||||
const col = `${position[0]}${posCount[position]}`;
|
||||
seats.push({
|
||||
coachId,
|
||||
row: room,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
bedPosition: bedType.toLowerCase(),
|
||||
isWindow: false,
|
||||
isAisle: false,
|
||||
});
|
||||
seatNumber++;
|
||||
}
|
||||
}
|
||||
return seats;
|
||||
}
|
||||
|
||||
function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] {
|
||||
const cols = seatCols(arrangement);
|
||||
const groups = parseArrangement(arrangement);
|
||||
const seats: SeatRow[] = [];
|
||||
let row = 1;
|
||||
let seatNumber = 1;
|
||||
let seatIndex = 0;
|
||||
const isBedCoach = seatClass?.toLowerCase().includes('bed');
|
||||
const totalCols = cols.length;
|
||||
|
||||
while (seatIndex < capacity) {
|
||||
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
|
||||
const col = cols[ci];
|
||||
let bedPosition = null;
|
||||
|
||||
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
|
||||
if (isBedCoach) {
|
||||
if (totalCols === 3) {
|
||||
// Economy bed (3-row cycle): upper, middle, lower
|
||||
if (row % 3 === 1) bedPosition = 'upper';
|
||||
else if (row % 3 === 2) bedPosition = 'middle';
|
||||
else bedPosition = 'lower';
|
||||
} else if (totalCols === 2) {
|
||||
// VIP bed (2-row cycle): upper, lower
|
||||
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
|
||||
}
|
||||
}
|
||||
|
||||
seats.push({
|
||||
coachId,
|
||||
row,
|
||||
col,
|
||||
seatNumber: `${seatNumber}`,
|
||||
kind: SeatKind.STANDARD,
|
||||
bedPosition,
|
||||
bedPosition: null,
|
||||
isWindow: isWindowCol(ci, groups),
|
||||
isAisle: isAisleCol(ci, groups),
|
||||
});
|
||||
seatNumber++;
|
||||
seatIndex++;
|
||||
@@ -84,6 +140,8 @@ type SeatRow = {
|
||||
seatNumber: string;
|
||||
kind: SeatKind;
|
||||
bedPosition?: string | null;
|
||||
isWindow?: boolean;
|
||||
isAisle?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -332,8 +390,20 @@ export class FleetService {
|
||||
});
|
||||
|
||||
if (dto.capacity > 0) {
|
||||
const seatClass = coach.coachType?.name || '';
|
||||
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass);
|
||||
// dto.bedCategory takes priority; fall back to name-based detection
|
||||
const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || '');
|
||||
let seats: SeatRow[];
|
||||
|
||||
if (bedCategory) {
|
||||
const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory];
|
||||
if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) {
|
||||
throw new BadRequestException('bedsPerRoom must be an even number ≥ 2');
|
||||
}
|
||||
seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom);
|
||||
} else {
|
||||
seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity);
|
||||
}
|
||||
|
||||
await this.prisma.seat.createMany({ data: seats });
|
||||
}
|
||||
|
||||
@@ -425,6 +495,50 @@ export class FleetService {
|
||||
return this.prisma.coachAssignment.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async generateSeatMapPreview(dto: GenerateSeatMapDto) {
|
||||
const { coachCount, roomsPerCoach, roomType } = dto;
|
||||
const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType];
|
||||
const bedTypeNames = resolveBedTypes(bedsPerRoom / 2);
|
||||
const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [
|
||||
...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })),
|
||||
...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })),
|
||||
];
|
||||
const seats: object[] = [];
|
||||
let globalSeq = 1;
|
||||
|
||||
for (let c = 1; c <= coachCount; c++) {
|
||||
const coachLabel = `C${c}`;
|
||||
for (let r = 1; r <= roomsPerCoach; r++) {
|
||||
const roomLabel = `R${r}`;
|
||||
const posCount: Record<string, number> = {};
|
||||
for (let s = 0; s < bedsPerRoom; s++) {
|
||||
const { position, bedType } = layout[s];
|
||||
posCount[position] = (posCount[position] ?? 0) + 1;
|
||||
seats.push({
|
||||
seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`,
|
||||
coach_id: coachLabel,
|
||||
room_id: `${coachLabel}-${roomLabel}`,
|
||||
category: roomType,
|
||||
position,
|
||||
col: `${position[0]}${posCount[position]}`,
|
||||
bed_type: bedType,
|
||||
sequence_number: globalSeq,
|
||||
status: 'AVAILABLE',
|
||||
});
|
||||
globalSeq++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
coachCount,
|
||||
roomsPerCoach,
|
||||
roomType,
|
||||
bedsPerRoom,
|
||||
totalBeds: seats.length,
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
async getAnalytics() {
|
||||
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
|
||||
this.prisma.train.count(),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto } from './packages.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
|
||||
@ApiTags('Packages')
|
||||
@Controller('packages')
|
||||
export class PackagesController {
|
||||
constructor(private readonly service: PackagesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List active packages' })
|
||||
listActive() {
|
||||
return this.service.listActive();
|
||||
}
|
||||
|
||||
@Get('all')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List all packages (admin)' })
|
||||
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.service.listAll(page ? +page : 1, pageSize ? +pageSize : 20);
|
||||
}
|
||||
|
||||
@Get('my-bookings')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get my package bookings' })
|
||||
myBookings(@Request() req: any) {
|
||||
return this.service.getMyBookings(req.user.passengerId);
|
||||
}
|
||||
|
||||
@Get('booking/:ref')
|
||||
@ApiOperation({ summary: 'Get package booking by reference' })
|
||||
getBookingByRef(@Param('ref') ref: string) {
|
||||
return this.service.getBookingByRef(ref);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get package details' })
|
||||
getById(@Param('id') id: string) {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create package (admin)' })
|
||||
create(@Body() dto: CreatePackageDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Activate package (admin)' })
|
||||
activate(@Param('id') id: string) {
|
||||
return this.service.activate(id);
|
||||
}
|
||||
|
||||
@Post('book')
|
||||
@UseGuards(OptionalJwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Book a package (public or authenticated)' })
|
||||
book(@Body() dto: BookPackageDto, @Request() req: any) {
|
||||
return this.service.book(dto, req.user?.passengerId);
|
||||
}
|
||||
}
|
||||
94
apps/edr-passenger-api/src/modules/packages/packages.dto.ts
Normal file
94
apps/edr-passenger-api/src/modules/packages/packages.dto.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { IsString, IsOptional, IsInt, IsBoolean, IsArray, IsDateString, Min, ValidateNested, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class CreatePriceTierDto {
|
||||
@ApiProperty({ example: 'HSC' })
|
||||
@IsString() seatType: string;
|
||||
|
||||
@ApiProperty({ example: 'Regular Seat (HSC)' })
|
||||
@IsString() label: string;
|
||||
|
||||
@ApiProperty({ example: 1023200 })
|
||||
@IsInt() @Min(0) priceMinor: number;
|
||||
|
||||
@ApiProperty({ example: 100 })
|
||||
@IsInt() @Min(0) availableSeats: number;
|
||||
}
|
||||
|
||||
export class CreatePackageDto {
|
||||
@ApiProperty({ example: 'KULUBBI-2025' })
|
||||
@IsString() code: string;
|
||||
|
||||
@ApiProperty({ example: 'Kulubbi Gabriel Pilgrimage Package' })
|
||||
@IsString() name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional() @IsString() description?: string;
|
||||
|
||||
@ApiProperty() @IsUUID() outboundScheduleId: string;
|
||||
@ApiProperty() @IsUUID() returnScheduleId: string;
|
||||
@ApiProperty() @IsUUID() originStationId: string;
|
||||
@ApiProperty() @IsUUID() destinationStationId: string;
|
||||
|
||||
@ApiProperty({ example: '2025-07-24T07:00:00Z' })
|
||||
@IsDateString() boardingTime: string;
|
||||
|
||||
@ApiProperty({ example: '2025-07-24T09:00:00Z' })
|
||||
@IsDateString() departureTime: string;
|
||||
|
||||
@ApiProperty({ example: '2025-07-25T06:00:00Z' })
|
||||
@IsDateString() arrivalTime: string;
|
||||
|
||||
@ApiProperty({ example: 912 })
|
||||
@IsInt() @Min(1) totalCapacity: number;
|
||||
|
||||
@ApiPropertyOptional({ example: '1 Locomotive + 2SBC + 2HBC + 6HSC' })
|
||||
@IsOptional() @IsString() coachConfiguration?: string;
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
@IsArray() @IsString({ each: true }) includedServices: string[];
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() busTransferIncluded?: boolean;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() busTransferRoute?: string;
|
||||
|
||||
@ApiProperty({ example: '2025-07-01T00:00:00Z' })
|
||||
@IsDateString() validFrom: string;
|
||||
|
||||
@ApiProperty({ example: '2025-07-24T09:00:00Z' })
|
||||
@IsDateString() validUntil: string;
|
||||
|
||||
@ApiProperty({ type: [CreatePriceTierDto] })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => CreatePriceTierDto)
|
||||
priceTiers: CreatePriceTierDto[];
|
||||
}
|
||||
|
||||
export class BookPackagePassengerDto {
|
||||
@ApiProperty() @IsString() passengerName: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsDateString() dateOfBirth?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentType?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string;
|
||||
}
|
||||
|
||||
export class BookPackageDto {
|
||||
@ApiProperty() @IsUUID() packageId: string;
|
||||
@ApiProperty() @IsUUID() priceTierId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional() @IsString() displayCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional() @IsString() contactEmail?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional() @IsString() contactPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@ApiProperty({ type: [BookPackagePassengerDto] })
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => BookPackagePassengerDto)
|
||||
passengers: BookPackagePassengerDto[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
import { PackagesController } from './packages.controller';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, CurrencyModule],
|
||||
controllers: [PackagesController],
|
||||
providers: [PackagesService],
|
||||
exports: [PackagesService],
|
||||
})
|
||||
export class PackagesModule {}
|
||||
191
apps/edr-passenger-api/src/modules/packages/packages.service.ts
Normal file
191
apps/edr-passenger-api/src/modules/packages/packages.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto } from './packages.dto';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
function generateRef(): string {
|
||||
return 'PKG-' + Array.from({ length: 6 }, () =>
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[Math.floor(Math.random() * 26)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PackagesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
) {}
|
||||
|
||||
listActive() {
|
||||
const now = new Date();
|
||||
return this.prisma.travelPackage.findMany({
|
||||
where: { status: 'ACTIVE', validFrom: { lte: now }, validUntil: { gte: now } },
|
||||
include: {
|
||||
priceTiers: true,
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
},
|
||||
orderBy: { validFrom: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
priceTiers: true,
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
},
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
return pkg;
|
||||
}
|
||||
|
||||
create(dto: CreatePackageDto) {
|
||||
return this.prisma.travelPackage.create({
|
||||
data: {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
outboundScheduleId: dto.outboundScheduleId,
|
||||
returnScheduleId: dto.returnScheduleId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
boardingTime: new Date(dto.boardingTime),
|
||||
departureTime: new Date(dto.departureTime),
|
||||
arrivalTime: new Date(dto.arrivalTime),
|
||||
totalCapacity: dto.totalCapacity,
|
||||
coachConfiguration: dto.coachConfiguration,
|
||||
includedServices: dto.includedServices,
|
||||
busTransferIncluded: dto.busTransferIncluded ?? false,
|
||||
busTransferRoute: dto.busTransferRoute,
|
||||
validFrom: new Date(dto.validFrom),
|
||||
validUntil: new Date(dto.validUntil),
|
||||
status: 'DRAFT',
|
||||
priceTiers: { create: dto.priceTiers },
|
||||
},
|
||||
include: { priceTiers: true },
|
||||
});
|
||||
}
|
||||
|
||||
async activate(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
return this.prisma.travelPackage.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
|
||||
async book(dto: BookPackageDto, passengerId?: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({
|
||||
where: { id: dto.packageId },
|
||||
include: { priceTiers: true },
|
||||
});
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
|
||||
if (new Date() > pkg.validUntil) throw new BadRequestException('Package has expired');
|
||||
|
||||
const tier = pkg.priceTiers.find((t) => t.id === dto.priceTierId);
|
||||
if (!tier) throw new NotFoundException('Price tier not found');
|
||||
|
||||
const passengerCount = dto.passengers.length;
|
||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||
if (passengerCount > remaining) {
|
||||
throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`);
|
||||
}
|
||||
|
||||
const totalMinor = tier.priceMinor * passengerCount;
|
||||
const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB;
|
||||
const displayTotalMinor =
|
||||
displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const [booking] = await this.prisma.$transaction([
|
||||
this.prisma.packageBooking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
packageId: dto.packageId,
|
||||
priceTierId: dto.priceTierId,
|
||||
passengerId: passengerId ?? null,
|
||||
contactEmail: dto.contactEmail,
|
||||
contactPhone: dto.contactPhone,
|
||||
promoCode: dto.promoCode,
|
||||
passengerCount,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
status: 'PENDING_PAYMENT',
|
||||
passengers: {
|
||||
create: dto.passengers.map((p) => ({
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth ? new Date(p.dateOfBirth) : undefined,
|
||||
idDocumentType: p.idDocumentType as any,
|
||||
idDocumentNumber: p.idDocumentNumber,
|
||||
passportNumber: p.passportNumber,
|
||||
passportCountry: p.passportCountry,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
passengers: true,
|
||||
priceTier: true,
|
||||
package: {
|
||||
include: {
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.packagePriceTier.update({
|
||||
where: { id: dto.priceTierId },
|
||||
data: { bookedSeats: { increment: passengerCount } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
getMyBookings(passengerId: string) {
|
||||
return this.prisma.packageBooking.findMany({
|
||||
where: { passengerId },
|
||||
include: { package: true, priceTier: true, passengers: true, paymentIntent: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingByRef(bookingRef: string) {
|
||||
const booking = await this.prisma.packageBooking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
package: {
|
||||
include: {
|
||||
outboundSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
},
|
||||
},
|
||||
priceTier: true,
|
||||
passengers: true,
|
||||
paymentIntent: true,
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Package booking not found');
|
||||
return booking;
|
||||
}
|
||||
|
||||
async listAll(page = 1, pageSize = 20) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.travelPackage.findMany({
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: { priceTiers: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.travelPackage.count(),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
@@ -835,7 +835,7 @@ export class PaymentsService {
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const journeySegments: any[] = [];
|
||||
|
||||
@@ -1,38 +1,64 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Patch,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto } from "./seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { IamGuard } from "../../common/iam-adapter";
|
||||
|
||||
@ApiTags('Seats')
|
||||
@Controller('seats')
|
||||
@ApiTags("Seats")
|
||||
@Controller("seats")
|
||||
export class SeatsController {
|
||||
constructor(private service: SeatsService) {}
|
||||
|
||||
// ── Seat Map ──────────────────────────────────────────────────────────────
|
||||
@Get('seatmap/:scheduleId')
|
||||
@IsPublic()
|
||||
@Get("seatmap/:scheduleId")
|
||||
@ApiOperation({
|
||||
summary: 'Get seat map with real-time availability by class',
|
||||
description: `Returns seat map for a schedule with availability by seat class:
|
||||
- Economy Regular
|
||||
- Economy Bed
|
||||
- VIP Bed
|
||||
|
||||
Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
})
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' })
|
||||
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); }
|
||||
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
|
||||
@ApiQuery({
|
||||
name: "coachTypeId",
|
||||
required: false,
|
||||
description:
|
||||
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
"List of coaches of the given type with their seats and availability",
|
||||
})
|
||||
getSeatMap(
|
||||
@Param("scheduleId") scheduleId: string,
|
||||
@Query("coachTypeId") coachTypeId?: string,
|
||||
) {
|
||||
return this.service.getSeatMap(scheduleId, coachTypeId);
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
@Get('holds')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@Get("holds")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: 'List active seat holds with full leg context',
|
||||
summary: "List active seat holds with full leg context",
|
||||
description: `Returns all non-expired holds enriched with:
|
||||
- **schedule**: train number, departure/arrival, full route origin→destination
|
||||
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
|
||||
@@ -41,25 +67,45 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
|
||||
|
||||
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
|
||||
})
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' })
|
||||
@ApiQuery({
|
||||
name: "scheduleId",
|
||||
required: false,
|
||||
description: "Filter by TrainSchedule UUID",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "passengerId",
|
||||
required: false,
|
||||
description: "Filter by Passenger UUID",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Active holds with schedule, leg, and seat details",
|
||||
})
|
||||
getHolds(
|
||||
@Query('scheduleId') scheduleId?: string,
|
||||
@Query('passengerId') passengerId?: string,
|
||||
) { return this.service.getHolds(scheduleId, passengerId); }
|
||||
@Query("scheduleId") scheduleId?: string,
|
||||
@Query("passengerId") passengerId?: string,
|
||||
) {
|
||||
return this.service.getHolds(scheduleId, passengerId);
|
||||
}
|
||||
|
||||
@Get('holds/:holdId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Get a single hold with full leg context' })
|
||||
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' })
|
||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); }
|
||||
@Get("holds/:holdId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Get a single hold with full leg context" })
|
||||
@ApiParam({ name: "holdId", description: "SeatHold UUID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Hold with schedule, leg, and seat details",
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Hold not found" })
|
||||
getHold(@Param("holdId") holdId: string) {
|
||||
return this.service.getHold(holdId);
|
||||
}
|
||||
|
||||
@Post('hold')
|
||||
@ApiOperation({
|
||||
summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)',
|
||||
@Post("hold")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
|
||||
description: `Temporarily reserves seats for a passenger to complete booking.
|
||||
|
||||
**Features:**
|
||||
@@ -67,74 +113,107 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
- Auto-release after expiry
|
||||
- Prevents double booking
|
||||
- Required before creating booking
|
||||
- **Public endpoint** - No authentication required (supports guest booking)`
|
||||
- **Public endpoint** - No authentication required (supports guest booking)`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Seats held successfully with holdId' })
|
||||
@ApiResponse({ status: 409, description: 'One or more seats unavailable' })
|
||||
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); }
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Seats held successfully with holdId",
|
||||
})
|
||||
@ApiResponse({ status: 409, description: "One or more seats unavailable" })
|
||||
holdSeats(@Body() dto: HoldSeatsDto) {
|
||||
return this.service.holdSeats(dto);
|
||||
}
|
||||
|
||||
@Delete('hold/:holdId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Release a seat hold' })
|
||||
@ApiParam({ name: 'holdId', description: 'Hold UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Hold released' })
|
||||
@ApiResponse({ status: 404, description: 'Hold not found' })
|
||||
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); }
|
||||
@Delete("hold/:holdId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Release a seat hold" })
|
||||
@ApiParam({ name: "holdId", description: "Hold UUID" })
|
||||
@ApiResponse({ status: 200, description: "Hold released" })
|
||||
@ApiResponse({ status: 404, description: "Hold not found" })
|
||||
releaseHold(@Param("holdId") holdId: string) {
|
||||
return this.service.releaseHold(holdId);
|
||||
}
|
||||
|
||||
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||
@Post(':seatId/block')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat blocked' })
|
||||
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) {
|
||||
@Post(":seatId/block")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat blocked" })
|
||||
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
|
||||
return this.service.blockSeat(seatId, body.reason);
|
||||
}
|
||||
|
||||
@Delete(':seatId/block')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Unblock a seat' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat unblocked' })
|
||||
unblockSeat(@Param('seatId') seatId: string) {
|
||||
@Delete(":seatId/block")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({ summary: "Unblock a seat" })
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({ status: 200, description: "Seat unblocked" })
|
||||
unblockSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.unblockSeat(seatId);
|
||||
}
|
||||
|
||||
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||
@Patch(':seatId/remove')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' })
|
||||
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||
removeSeat(@Param('seatId') seatId: string) {
|
||||
@Patch(":seatId/remove")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Remove a seat by marking with negative seatNumber",
|
||||
})
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Seat removed (seatNumber negated), shows as empty space",
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Seat not found" })
|
||||
removeSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.removeSeat(seatId);
|
||||
}
|
||||
|
||||
@Patch(':seatId/undo-remove')
|
||||
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' })
|
||||
@ApiParam({ name: 'seatId', description: 'Seat UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' })
|
||||
@ApiResponse({ status: 404, description: 'Seat not found' })
|
||||
@ApiResponse({ status: 400, description: 'Seat is not removed' })
|
||||
undoRemoveSeat(@Param('seatId') seatId: string) {
|
||||
@Patch(":seatId/undo-remove")
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Undo seat removal by restoring original seatNumber",
|
||||
})
|
||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Seat restored (negative seatNumber removed)",
|
||||
})
|
||||
@ApiResponse({ status: 404, description: "Seat not found" })
|
||||
@ApiResponse({ status: 400, description: "Seat is not removed" })
|
||||
undoRemoveSeat(@Param("seatId") seatId: string) {
|
||||
return this.service.undoRemoveSeat(seatId);
|
||||
}
|
||||
|
||||
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' })
|
||||
async exportCSV(@Param('scheduleId') scheduleId: string) {
|
||||
@Get("export/csv/:scheduleId")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Export seats as CSV" })
|
||||
async exportCSV(@Param("scheduleId") scheduleId: string) {
|
||||
const csv = await this.service.exportSeatsCSV(scheduleId);
|
||||
return { csv, filename: `seats-${scheduleId}.csv` };
|
||||
}
|
||||
|
||||
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' })
|
||||
@Post("import/preview")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Preview CSV import" })
|
||||
previewCSV(@Body() body: { csv: string }) {
|
||||
return this.service.previewSeatsCSV(body.csv);
|
||||
}
|
||||
|
||||
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' })
|
||||
importCSV(@Body() body: { scheduleId: string; csv: string; commit: boolean }) {
|
||||
@Post("import/commit")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({ summary: "Commit CSV import" })
|
||||
importCSV(
|
||||
@Body() body: { scheduleId: string; csv: string; commit: boolean },
|
||||
) {
|
||||
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios';
|
||||
import { SeatsController } from './seats.controller';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule, HttpModule],
|
||||
imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -3,17 +3,28 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class SeatsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { originStationId: true, destinationStationId: true },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const assignments = await this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId, ...(coachId ? { coachId } : {}) },
|
||||
where: {
|
||||
scheduleId,
|
||||
...(coachTypeId ? { coach: { coachTypeId } } : {}),
|
||||
},
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
@@ -26,48 +37,110 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
const allSeats = a.coach.seats;
|
||||
const allSeats = a.coach.seats;
|
||||
const coachTypeName = a.coach.coachType?.name ?? '';
|
||||
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
|
||||
const isBedCoach = this.isBedCoach(coachTypeName);
|
||||
// Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6)
|
||||
const bedsPerRoom = isBedCoach
|
||||
? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length
|
||||
: 0;
|
||||
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
|
||||
|
||||
return {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
mode: a.coach.status,
|
||||
name: `Coach ${a.coach.number}`,
|
||||
seatClasses: seatClassNames,
|
||||
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
|
||||
const mappedSeats = allSeats.map((s: any) => ({
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
// Bed-specific fields
|
||||
...(isBedCoach ? {
|
||||
room_id: `${a.coach.id}-R${s.row}`,
|
||||
category: bedCategory,
|
||||
position: this.colToPosition(s.col),
|
||||
bed_type: this.bedPositionToType(s.bedPosition),
|
||||
bedPosition: s.bedPosition,
|
||||
} : {
|
||||
bedPosition: s.bedPosition,
|
||||
}),
|
||||
}));
|
||||
|
||||
const base = {
|
||||
id: a.coach.id,
|
||||
assignmentId: a.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
mode: a.coach.status,
|
||||
name: `Coach ${a.coach.number}`,
|
||||
coachTypeName,
|
||||
isBedCoach,
|
||||
bedCategory,
|
||||
seatClasses: seatClassNames,
|
||||
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
|
||||
positionNumber: a.positionNumber,
|
||||
seatArrangement: a.coach.arrangement,
|
||||
totalSeats: a.coach.capacity,
|
||||
seats: allSeats.map((s) => ({
|
||||
id: s.id,
|
||||
seatNumber: s.seatNumber,
|
||||
number: s.seatNumber,
|
||||
label: s.seatNumber,
|
||||
status: effectiveStatuses.get(s.id) ?? s.status,
|
||||
kind: s.kind,
|
||||
row: s.row,
|
||||
col: s.col,
|
||||
isWindow: s.isWindow,
|
||||
isAisle: s.isAisle,
|
||||
bedPosition: s.bedPosition,
|
||||
coach: {
|
||||
id: a.coach.id,
|
||||
coachNumber: a.coach.number,
|
||||
label: a.coach.number,
|
||||
},
|
||||
})),
|
||||
totalSeats: a.coach.capacity,
|
||||
};
|
||||
|
||||
if (isBedCoach) {
|
||||
// Group seats into rooms; row = room number
|
||||
const roomMap = new Map<number, any[]>();
|
||||
for (const seat of mappedSeats) {
|
||||
if (!roomMap.has(seat.row)) roomMap.set(seat.row, []);
|
||||
roomMap.get(seat.row)!.push(seat);
|
||||
}
|
||||
const rooms = Array.from(roomMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([roomNumber, beds]) => ({
|
||||
room_id: `${a.coach.id}-R${roomNumber}`,
|
||||
roomNumber,
|
||||
category: bedCategory,
|
||||
totalBeds: beds.length,
|
||||
beds,
|
||||
}));
|
||||
return { ...base, rooms, seats: mappedSeats };
|
||||
}
|
||||
|
||||
return { ...base, seats: mappedSeats };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private isBedCoach(coachTypeName: string): boolean {
|
||||
const n = coachTypeName.toLowerCase();
|
||||
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
|
||||
}
|
||||
|
||||
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
|
||||
const n = coachTypeName.toLowerCase();
|
||||
// Explicit VIP name check first
|
||||
if (n.includes('vip')) return 'VIP_BED';
|
||||
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
|
||||
if (bedsPerRoom === 4) return 'VIP_BED';
|
||||
return 'ECONOMY_BED';
|
||||
}
|
||||
|
||||
// col format: L1, L2, L3, R1, R2, R3
|
||||
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
|
||||
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
|
||||
}
|
||||
|
||||
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
|
||||
if (!bedPosition) return null;
|
||||
const map: Record<string, 'LOWER' | 'MIDDLE' | 'UPPER'> = {
|
||||
lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER',
|
||||
};
|
||||
return map[bedPosition.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
@@ -169,7 +242,8 @@ export class SeatsService {
|
||||
if (new Set(seatIds).size !== seatIds.length)
|
||||
throw new BadRequestException('Duplicate seatId in passengers list');
|
||||
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
||||
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
|
||||
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
|
||||
|
||||
const hold = await this.prisma.$transaction(async (tx) => {
|
||||
const seats = await tx.seat.findMany({
|
||||
@@ -426,7 +500,7 @@ export class SeatsService {
|
||||
|
||||
// Delete the Journey (and its JourneySegments) scoped to this booking.
|
||||
async releaseSeats(bookingId: string) {
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } });
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
||||
}
|
||||
|
||||
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@ApiTags('System Config')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
@Controller('system-config')
|
||||
export class SystemConfigController {
|
||||
constructor(private service: SystemConfigService) {}
|
||||
|
||||
@Get()
|
||||
getAll() {
|
||||
return this.service.getAll();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
update(@Body() body: Record<string, string>) {
|
||||
return this.service.updateMany(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
import { SystemConfigController } from './system-config.controller';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, HttpModule],
|
||||
controllers: [SystemConfigController],
|
||||
providers: [SystemConfigService],
|
||||
exports: [SystemConfigService],
|
||||
})
|
||||
export class SystemConfigModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
export const CONFIG_KEYS = {
|
||||
SEAT_HOLD_DURATION_MINUTES: 'seat_hold_duration_minutes',
|
||||
} as const;
|
||||
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
[CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES]: '5',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getAll(): Promise<Record<string, string>> {
|
||||
const rows = await this.prisma.systemConfig.findMany();
|
||||
const result: Record<string, string> = { ...DEFAULTS };
|
||||
for (const row of rows) result[row.key] = row.value;
|
||||
return result;
|
||||
}
|
||||
|
||||
async getValue(key: string): Promise<string> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { key } });
|
||||
return row?.value ?? DEFAULTS[key] ?? '';
|
||||
}
|
||||
|
||||
async getNumber(key: string): Promise<number> {
|
||||
return parseInt(await this.getValue(key), 10) || parseInt(DEFAULTS[key] ?? '0', 10);
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
}
|
||||
|
||||
async updateMany(entries: Record<string, string>): Promise<Record<string, string>> {
|
||||
await Promise.all(Object.entries(entries).map(([k, v]) => this.set(k, v)));
|
||||
return this.getAll();
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,30 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Edit, DollarSign, Clock } from 'lucide-react';
|
||||
import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { agentsApi } from '@/lib/api';
|
||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function AgentsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', active: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['agents', filters],
|
||||
@@ -30,7 +45,7 @@ export default function AgentsPage() {
|
||||
render: (agent: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
|
||||
<div className="text-sm text-gray-500">{agent.user?.email}</div>
|
||||
<div className="text-sm text-muted-foreground">{agent.user?.email}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -51,19 +66,21 @@ export default function AgentsPage() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (agent: any) => setSelected(agent),
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'View Shifts',
|
||||
onClick: (agent: any) => {
|
||||
window.location.href = `/agents/${agent.id}/shifts`;
|
||||
},
|
||||
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
|
||||
variant: 'secondary' as const,
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
label: 'View Commissions',
|
||||
onClick: (agent: any) => {
|
||||
window.location.href = `/agents/${agent.id}/commissions`;
|
||||
},
|
||||
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
|
||||
variant: 'secondary' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
@@ -119,6 +136,97 @@ export default function AgentsPage() {
|
||||
loading={isLoading}
|
||||
emptyMessage="No agents found"
|
||||
/>
|
||||
|
||||
{/* Agent Details Modal */}
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
|
||||
{selected && (() => {
|
||||
const a = selected;
|
||||
const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
return (
|
||||
<div>
|
||||
<div className="from-emerald-600 to-emerald-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-white text-xl font-bold">{initials}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xl font-bold truncate">{a.user?.fullName || 'N/A'}</p>
|
||||
<p className="text-emerald-200 text-sm font-mono">{a.agentCode}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{a.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Agent Code', value: a.agentCode || '—' },
|
||||
{ label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` },
|
||||
{ label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-emerald-200 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Agent Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Agent Code" value={a.agentCode} mono />
|
||||
<Field label="Commission Rate" value={`${a.commissionRate ?? 0}%`} />
|
||||
<Field label="Counter Location" value={a.counterLocation || a.location || 'N/A'} />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{a.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="User Account" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Full Name" value={a.user?.fullName} />
|
||||
<Field label="Email" value={a.user?.email} truncate />
|
||||
<Field label="Phone" value={a.user?.phone} />
|
||||
<Field label="Role" value={a.user?.role || 'AGENT'} />
|
||||
<Field label="User ID" value={a.userId || a.user?.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Performance" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Total Bookings" value={(a.totalBookings ?? 0).toLocaleString()} />
|
||||
<Field label="Total Revenue" value={a.totalRevenue ? formatCurrency(a.totalRevenue, 'ETB') : 'N/A'} />
|
||||
<Field label="Total Commission" value={a.totalCommission ? formatCurrency(a.totalCommission, 'ETB') : 'N/A'} />
|
||||
<Field label="Pending Commission" value={a.pendingCommission ? formatCurrency(a.pendingCommission, 'ETB') : 'N/A'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Timestamps & IDs" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Agent Since" value={formatDateTime(a.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
|
||||
<Field label="Agent ID" value={a.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,111 +252,132 @@ export default function AuditLogsPage() {
|
||||
{/* Details Modal */}
|
||||
<Modal
|
||||
isOpen={showDetailsModal}
|
||||
onClose={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedLog(null);
|
||||
}}
|
||||
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
|
||||
size="lg"
|
||||
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
|
||||
title="Audit Log Details"
|
||||
size="xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
|
||||
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Action</label>
|
||||
<p className="text-sm mt-1">
|
||||
<Badge className={getActionBadgeColor(selectedLog?.action)}>
|
||||
{selectedLog?.action}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
|
||||
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
|
||||
<p className="text-sm mt-1 font-mono text-muted-foreground">
|
||||
{selectedLog?.entityId || 'System'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedLog && (() => {
|
||||
const l = selectedLog;
|
||||
const actionColor: Record<string, string> = {
|
||||
CREATE: 'from-emerald-600 to-emerald-700',
|
||||
UPDATE: 'from-blue-600 to-blue-700',
|
||||
DELETE: 'from-red-600 to-red-700',
|
||||
LOGIN: 'from-violet-600 to-violet-700',
|
||||
LOGOUT: 'from-gray-600 to-gray-700',
|
||||
};
|
||||
const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
|
||||
|
||||
{/* User Info */}
|
||||
{selectedLog?.user && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-2">User Information</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Name</label>
|
||||
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
|
||||
<p className="text-white text-2xl font-bold">{l.action}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
|
||||
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">Email</label>
|
||||
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">User</p>
|
||||
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
|
||||
</div>
|
||||
<div className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">IP Address</p>
|
||||
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Network Info */}
|
||||
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedLog?.ipAddress && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
|
||||
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Event Details" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Action" value={l.action} />
|
||||
<Field label="Entity Type" value={l.entityType} mono />
|
||||
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
|
||||
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{l.user && (
|
||||
<section>
|
||||
<SectionHeader title="User Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Full Name" value={l.user.fullName} />
|
||||
<Field label="Email" value={l.user.email} truncate />
|
||||
<Field label="User ID" value={l.userId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{selectedLog?.userAgent && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
|
||||
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
|
||||
{selectedLog?.userAgent}
|
||||
</p>
|
||||
|
||||
{(l.ipAddress || l.userAgent) && (
|
||||
<section>
|
||||
<SectionHeader title="Network Information" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="IP Address" value={l.ipAddress} mono />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
|
||||
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(l.oldData || l.newData) && (
|
||||
<section>
|
||||
<SectionHeader title="Data Changes" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{l.oldData && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide">← Before</p>
|
||||
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||
{formatJsonData(l.oldData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{l.newData && (
|
||||
<div>
|
||||
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide">→ After</p>
|
||||
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
|
||||
{formatJsonData(l.newData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="System" />
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Field label="Log ID" value={l.id} mono truncate />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes */}
|
||||
{(selectedLog?.oldData || selectedLog?.newData) && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{selectedLog?.oldData && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-red-600">Old Data</label>
|
||||
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
|
||||
{formatJsonData(selectedLog?.oldData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog?.newData && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-green-600">New Data</label>
|
||||
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
|
||||
{formatJsonData(selectedLog?.newData)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Log ID */}
|
||||
<div className="border-t pt-4">
|
||||
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
|
||||
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,37 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle, Ban, Eye } 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 { fraudApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
const SEVERITY_GRAD: Record<string, string> = {
|
||||
CRITICAL: 'from-red-700 to-red-800',
|
||||
HIGH: 'from-red-600 to-red-700',
|
||||
MEDIUM: 'from-amber-500 to-amber-600',
|
||||
LOW: 'from-blue-500 to-blue-600',
|
||||
};
|
||||
|
||||
export default function FraudDetectionPage() {
|
||||
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -40,10 +62,7 @@ export default function FraudDetectionPage() {
|
||||
|
||||
const handleBlockUser = async (alert: any) => {
|
||||
if (confirm(`Block user ${alert.user?.email}?`)) {
|
||||
await blockUserMutation.mutateAsync({
|
||||
userId: alert.userId,
|
||||
reason: `Fraud alert: ${alert.ruleType}`,
|
||||
});
|
||||
await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,7 +71,7 @@ export default function FraudDetectionPage() {
|
||||
key: 'severity',
|
||||
label: 'Severity',
|
||||
render: (alert: any) => (
|
||||
<Badge variant="status" status={alert.severity === 'HIGH' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
||||
<Badge variant="status" status={alert.severity === 'HIGH' || alert.severity === 'CRITICAL' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
),
|
||||
@@ -62,7 +81,7 @@ export default function FraudDetectionPage() {
|
||||
label: 'Rule Type',
|
||||
render: (alert: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-[rgb(20,113,76)]" />
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<span>{alert.ruleType}</span>
|
||||
</div>
|
||||
),
|
||||
@@ -80,9 +99,7 @@ export default function FraudDetectionPage() {
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
render: (alert: any) => (
|
||||
<span className="text-sm">{alert.description || alert.details}</span>
|
||||
),
|
||||
render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
@@ -102,6 +119,12 @@ export default function FraudDetectionPage() {
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'View Details',
|
||||
onClick: (alert: any) => setSelected(alert),
|
||||
variant: 'secondary' as const,
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
label: 'Acknowledge',
|
||||
onClick: handleAcknowledge,
|
||||
@@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search alerts..."
|
||||
className="input"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
/>
|
||||
<input type="text" placeholder="Search alerts..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Severity</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.severity}
|
||||
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
|
||||
>
|
||||
<select className="input" value={filters.severity} onChange={(e) => setFilters({ ...filters, severity: e.target.value })}>
|
||||
<option value="">All Severities</option>
|
||||
<option value="LOW">Low</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
@@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<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="acknowledged">Acknowledged</option>
|
||||
@@ -174,6 +183,121 @@ export default function FraudDetectionPage() {
|
||||
loading={isLoading}
|
||||
emptyMessage="No fraud alerts found"
|
||||
/>
|
||||
|
||||
{/* Fraud Alert Details Modal */}
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Fraud Alert Details" size="xl">
|
||||
{selected && (() => {
|
||||
const al = selected;
|
||||
const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700';
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Fraud Alert</p>
|
||||
<p className="text-white text-xl font-bold">{al.ruleType}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 space-y-1">
|
||||
<Badge variant="status" status={al.severity === 'HIGH' || al.severity === 'CRITICAL' ? 'CANCELLED' : al.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
|
||||
{al.severity}
|
||||
</Badge>
|
||||
<div>
|
||||
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
||||
{al.acknowledged ? 'Acknowledged' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-white/70 text-xs">{formatDateTime(al.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Severity', value: al.severity || '—' },
|
||||
{ label: 'Rule Type', value: al.ruleType || '—' },
|
||||
{ label: 'User', value: al.user?.fullName || al.user?.email || '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Alert Details" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Rule Type" value={al.ruleType} />
|
||||
<Field label="Severity" value={al.severity} />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
|
||||
{al.acknowledged ? 'Acknowledged' : 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Field label="Detected At" value={formatDateTime(al.createdAt)} />
|
||||
<div className="col-span-2 md:col-span-4 bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">Description</p>
|
||||
<p className="text-sm font-medium">{al.description || al.details || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Flagged User" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Full Name" value={al.user?.fullName} />
|
||||
<Field label="Email" value={al.user?.email} truncate />
|
||||
<Field label="Phone" value={al.user?.phone} />
|
||||
<Field label="User ID" value={al.userId || al.user?.id} mono truncate />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Blocked</p>
|
||||
<Badge variant="status" status={al.user?.isBlocked ? 'CANCELLED' : 'CONFIRMED'}>
|
||||
{al.user?.isBlocked ? 'Blocked' : 'Not Blocked'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{al.bookingId && (
|
||||
<section>
|
||||
<SectionHeader title="Related Booking" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Booking ID" value={al.bookingId} mono truncate />
|
||||
<Field label="Booking Ref" value={al.booking?.bookingRef} mono />
|
||||
<Field label="Amount" value={al.booking?.totalMinor ? `ETB ${(al.booking.totalMinor / 100).toFixed(2)}` : 'N/A'} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{al.acknowledged && (
|
||||
<section>
|
||||
<SectionHeader title="Resolution" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Acknowledged At" value={al.acknowledgedAt ? formatDateTime(al.acknowledgedAt) : '—'} />
|
||||
<Field label="Acknowledged By" value={al.acknowledgedBy?.fullName || al.acknowledgedBy?.email || '—'} />
|
||||
<Field label="Notes" value={al.resolutionNotes || '—'} truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="System" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Alert ID" value={al.id} mono truncate />
|
||||
<Field label="Created" value={formatDateTime(al.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(al.updatedAt)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,141 +4,295 @@ import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useTheme } from '@/lib/theme-store';
|
||||
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react';
|
||||
import {
|
||||
Eye, EyeOff, Sun, Moon, ArrowRight, Loader2,
|
||||
TicketCheck, Users, TrendingUp, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
const EDR_GREEN = 'rgb(20, 113, 76)';
|
||||
|
||||
const features = [
|
||||
{ icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' },
|
||||
{ icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' },
|
||||
{ icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' },
|
||||
{ icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' },
|
||||
];
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
const [emailFocused, setEmailFocused] = useState(false);
|
||||
const [passwordFocused, setPasswordFocused] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { login } = useAuthStore();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
useEffect(() => { setIsMounted(true); }, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.';
|
||||
setError(message);
|
||||
setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
if (!isMounted) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
|
||||
{/* Full Screen Banner Background */}
|
||||
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
|
||||
<div className="flex min-h-screen bg-white dark:bg-gray-950">
|
||||
|
||||
{/* Content Overlay */}
|
||||
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Login Card with Shadow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700/50 overflow-hidden backdrop-blur-sm">
|
||||
{/* Card Header with Logo, App Name and Theme Toggle */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
|
||||
<Train className="h-9 w-9 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-700" />
|
||||
)}
|
||||
</button>
|
||||
{/* ── LEFT PANEL — form ── */}
|
||||
<div className="flex-1 lg:flex-none lg:w-[42%] xl:w-[38%] flex flex-col min-h-screen bg-gray-50 dark:bg-gray-950 relative">
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-8 py-4 lg:px-10">
|
||||
{/* Logo — always visible on the form panel */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-[rgb(20,113,76)] flex items-center justify-center shadow-md shadow-[rgb(20,113,76)]/30">
|
||||
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8 2 5 5 5 8v8l2 2h10l2-2V8c0-3-3-6-7-6z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 17v2M16 17v2M5 12h14" />
|
||||
<circle cx="9" cy="9" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="9" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-gray-900 dark:text-white tracking-wide leading-none">ETHIO-DJIBOUTI</div>
|
||||
<div className="text-[12px] text-gray-400 dark:text-gray-500 tracking-widest uppercase leading-none mt-0.5">Railway</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark
|
||||
? <Sun className="w-4 h-4 text-amber-400" />
|
||||
: <Moon className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form area */}
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-10 lg:px-10 xl:px-14">
|
||||
<div className="w-full max-w-xs">
|
||||
|
||||
{/* Heading */}
|
||||
<div className="mb-8 animate-fade-up" style={{ animationDelay: '0ms' }}>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
Sign in to continue
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter your credentials to access the back-office.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
|
||||
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800">
|
||||
{error}
|
||||
{/* Error */}
|
||||
{error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
|
||||
<div className="mb-5 flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 px-4 py-3">
|
||||
<div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
|
||||
<span className="text-white text-[10px] font-bold">!</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div></div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
|
||||
|
||||
{/* Email field */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Email address
|
||||
</label>
|
||||
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||
emailFocused
|
||||
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
||||
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||
}`}>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
||||
placeholder="name@email.com"
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
onFocus={() => setEmailFocused(true)}
|
||||
onBlur={() => setEmailFocused(false)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
||||
placeholder="name@edr.com"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Password field */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<div className={`relative rounded-xl transition-all duration-200 ${
|
||||
passwordFocused
|
||||
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
|
||||
: 'ring-1 ring-gray-200 dark:ring-gray-800'
|
||||
}`}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
onFocus={() => setPasswordFocused(true)}
|
||||
onBlur={() => setPasswordFocused(false)}
|
||||
className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
|
||||
placeholder="••••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email || !password}
|
||||
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ background: loading || !email || !password
|
||||
? 'rgb(20,113,76)'
|
||||
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign in
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800/60 animate-fade-up" style={{ animationDelay: '160ms' }}>
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30">
|
||||
<ShieldCheck className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||
Access is restricted to authorised EDR staff only. All sessions are logged and audited.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="px-8 py-4 lg:px-10 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-600">
|
||||
Back-office · v1.0
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-600">
|
||||
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT PANEL — photo ── */}
|
||||
<div className="hidden lg:flex flex-1 relative flex-col overflow-hidden">
|
||||
{/* Layer 1 — base photo, desaturated */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: "url('/banner.jpg')",
|
||||
filter: isDark
|
||||
? 'saturate(0.1) brightness(1)'
|
||||
: 'saturate(0.15) brightness(1)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Layer 2 — brand green color wash */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: 'linear-gradient(145deg, rgb(5,46,30) 0%, rgb(20,113,76) 55%, rgb(4,120,67) 100%)',
|
||||
mixBlendMode: 'multiply',
|
||||
opacity: isDark ? 0.8 : 0.4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col h-full p-10 xl:p-14">
|
||||
{/* Badge */}
|
||||
<div className="flex justify-start">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full px-3 py-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="text-white/80 text-xs font-medium tracking-wide">Back-office Portal v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero text */}
|
||||
<div className="mt-auto mb-auto">
|
||||
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight mb-4">
|
||||
Passenger<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">
|
||||
Management
|
||||
</span>
|
||||
<br />System
|
||||
</h1>
|
||||
<p className="text-white/60 text-base leading-relaxed max-w-sm">
|
||||
Unified platform for booking operations, passenger services, revenue analytics, and real-time train management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature grid */}
|
||||
<div className="mt-auto grid grid-cols-2 gap-3">
|
||||
{features.map(({ icon: Icon, label, desc }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-start gap-3 bg-white/5 hover:bg-white/10 backdrop-blur-sm border border-white/10 rounded-xl p-3.5 transition-colors duration-200"
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-[rgb(20,113,76)]/40 flex items-center justify-center">
|
||||
<Icon className="w-4 h-4 text-emerald-300" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-xs font-semibold">{label}</div>
|
||||
<div className="text-white/40 text-xs mt-0.5">{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom rule */}
|
||||
<div className="mt-8 pt-6 border-t border-white/10">
|
||||
<span className="text-white/30 text-xs block">© 2026 Ethio-Djibouti Railway S.C. — Secure · Encrypted · Monitored</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,15 +2,44 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Eye, Star } 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 { loyaltyApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
const TIER_COLORS: Record<string, string> = {
|
||||
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
|
||||
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
|
||||
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
|
||||
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
|
||||
};
|
||||
|
||||
const TIER_GRAD: Record<string, string> = {
|
||||
BRONZE: 'from-orange-500 to-orange-600',
|
||||
SILVER: 'from-gray-500 to-gray-600',
|
||||
GOLD: 'from-yellow-500 to-yellow-600',
|
||||
PLATINUM: 'from-indigo-600 to-indigo-700',
|
||||
};
|
||||
|
||||
export default function LoyaltyPage() {
|
||||
const [filters, setFilters] = useState({ search: '', tier: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['loyalty', filters],
|
||||
@@ -18,11 +47,24 @@ export default function LoyaltyPage() {
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
|
||||
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> },
|
||||
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 },
|
||||
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 },
|
||||
];
|
||||
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
|
||||
</div>
|
||||
)},
|
||||
{ key: 'tier', label: 'Tier', render: (account: any) => (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${TIER_COLORS[account.tier] || TIER_COLORS.BRONZE}`}>
|
||||
<Star className="w-3 h-3" />{account.tier}
|
||||
</span>
|
||||
)},
|
||||
{ key: 'pointsBalance', label: 'Points', render: (account: any) => (account.pointsBalance ?? 0).toLocaleString() },
|
||||
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => (account.lifetimePoints ?? 0).toLocaleString() },
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
|
||||
|
||||
<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">Tier</label>
|
||||
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="BRONZE">Bronze</option>
|
||||
<option value="SILVER">Silver</option>
|
||||
<option value="GOLD">Gold</option>
|
||||
<option value="PLATINUM">Platinum</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">Tier</label>
|
||||
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
|
||||
<option value="">All Tiers</option>
|
||||
<option value="BRONZE">Bronze</option>
|
||||
<option value="SILVER">Silver</option>
|
||||
<option value="GOLD">Gold</option>
|
||||
<option value="PLATINUM">Platinum</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={Array.isArray(data) ? data : (data?.items || [])}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No loyalty program found"
|
||||
emptyMessage="No loyalty accounts found"
|
||||
/>
|
||||
|
||||
{/* Loyalty Details Modal */}
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Loyalty Account Details" size="xl">
|
||||
{selected && (() => {
|
||||
const a = selected;
|
||||
const tier = a.tier || 'BRONZE';
|
||||
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
|
||||
const grad = TIER_GRAD[tier] || 'from-gray-600 to-gray-700';
|
||||
const passengerName = a.passenger?.fullName || a.user?.fullName || 'N/A';
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
<Star className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
|
||||
<p className="text-white/70 text-sm">{a.passenger?.email || a.user?.email || ''}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-bold px-3 py-1 rounded-full border ${tierColor}`}>
|
||||
<Star className="w-3 h-3" />{tier}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Points Balance', value: (a.pointsBalance ?? 0).toLocaleString() },
|
||||
{ label: 'Lifetime Points', value: (a.lifetimePoints ?? 0).toLocaleString() },
|
||||
{ label: 'Points Redeemed', value: (a.pointsRedeemed ?? 0).toLocaleString() },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Account Overview" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Current Tier" value={tier} />
|
||||
<Field label="Points Balance" value={(a.pointsBalance ?? 0).toLocaleString()} />
|
||||
<Field label="Lifetime Points" value={(a.lifetimePoints ?? 0).toLocaleString()} />
|
||||
<Field label="Points Redeemed" value={(a.pointsRedeemed ?? 0).toLocaleString()} />
|
||||
<Field label="Points Expiring" value={a.pointsExpiring ? a.pointsExpiring.toLocaleString() : 'N/A'} />
|
||||
<Field label="Expiry Date" value={a.expiryDate ? formatDateTime(a.expiryDate) : 'N/A'} />
|
||||
<Field label="Tier Since" value={a.tierAchievedAt ? formatDateTime(a.tierAchievedAt) : 'N/A'} />
|
||||
<Field label="Next Tier" value={a.nextTier || 'N/A'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Passenger" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Full Name" value={a.passenger?.fullName || a.user?.fullName} />
|
||||
<Field label="Email" value={a.passenger?.email || a.user?.email} truncate />
|
||||
<Field label="Phone" value={a.passenger?.phone || a.user?.phone} />
|
||||
<Field label="Passenger ID" value={a.passengerId || a.passenger?.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Timestamps & IDs" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Account Created" value={formatDateTime(a.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
|
||||
<Field label="Account ID" value={a.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Eye } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
@@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal';
|
||||
import { paymentsApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '\u2014'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
@@ -86,6 +100,10 @@ export default function PaymentsPage() {
|
||||
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
|
||||
];
|
||||
|
||||
const paymentActions = [
|
||||
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -129,11 +147,103 @@ export default function PaymentsPage() {
|
||||
<DataTable
|
||||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||||
columns={columns}
|
||||
actions={[]}
|
||||
actions={paymentActions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No payments found"
|
||||
/>
|
||||
|
||||
{/* Payment Details Modal */}
|
||||
<Modal isOpen={!!selectedPayment} onClose={() => setSelectedPayment(null)} title="Payment Details" size="xl">
|
||||
{selectedPayment && (() => {
|
||||
const p = selectedPayment;
|
||||
const statusGrad: Record<string, string> = {
|
||||
COMPLETED: 'from-emerald-600 to-emerald-700',
|
||||
FAILED: 'from-red-600 to-red-700',
|
||||
PENDING: 'from-amber-500 to-amber-600',
|
||||
REFUNDED: 'from-blue-600 to-blue-700',
|
||||
};
|
||||
const grad = statusGrad[p.status] || 'from-gray-600 to-gray-700';
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Payment Reference</p>
|
||||
<p className="text-white text-2xl font-mono font-bold">{p.reference || p.id?.substring(0, 8)}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={p.status}>{p.status}</Badge>
|
||||
<p className="text-white/70 text-xs mt-1">{formatDateTime(p.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) },
|
||||
{ label: 'Method', value: p.method || '—' },
|
||||
{ label: 'Booking', value: p.booking?.bookingRef || '—' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Transaction" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/40 rounded-lg p-3 col-span-2">
|
||||
<p className="text-xs text-muted-foreground mb-1">Amount</p>
|
||||
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p>
|
||||
</div>
|
||||
<Field label="Method" value={p.method} />
|
||||
<Field label="Status" value={p.status} />
|
||||
<Field label="Reference" value={p.reference} mono truncate />
|
||||
<Field label="Provider Ref" value={p.providerReference || p.externalReference} mono truncate />
|
||||
<Field label="Created" value={formatDateTime(p.createdAt)} />
|
||||
<Field label="Completed At" value={p.completedAt ? formatDateTime(p.completedAt) : 'N/A'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Booking" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Booking Ref" value={p.booking?.bookingRef} mono />
|
||||
<Field label="Booking Status" value={p.booking?.status} />
|
||||
<Field label="Passenger" value={p.booking?.passenger?.fullName || p.booking?.contactEmail} truncate />
|
||||
<Field label="Booking ID" value={p.bookingId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(p.failureReason || p.failureCode) && (
|
||||
<section>
|
||||
<SectionHeader title="Failure Information" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Failure Code" value={p.failureCode} mono />
|
||||
<Field label="Failure Reason" value={p.failureReason} truncate />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="IDs" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Field label="Payment ID" value={p.id} mono truncate />
|
||||
<Field label="Last Updated" value={formatDateTime(p.updatedAt)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelectedPayment(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,11 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Save, Users } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { systemConfigApi } from '@/lib/api';
|
||||
|
||||
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'payment' | 'integrations'>('general');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [configMessage, setConfigMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'configurations') return;
|
||||
setConfigLoading(true);
|
||||
systemConfigApi.getAll()
|
||||
.then((data) => {
|
||||
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setConfigLoading(false));
|
||||
}, [activeTab]);
|
||||
|
||||
const saveConfigurations = async () => {
|
||||
setConfigSaving(true);
|
||||
setConfigMessage('');
|
||||
try {
|
||||
await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes });
|
||||
setConfigMessage('Saved successfully.');
|
||||
} catch {
|
||||
setConfigMessage('Failed to save.');
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'payment', label: 'Payment' },
|
||||
{ id: 'integrations', label: 'Integrations' },
|
||||
{ id: 'configurations', label: 'Configurations' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -14,31 +51,24 @@ export default function SettingsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||
<p className="text-muted-foreground">Manage system settings and configurations</p>
|
||||
</div>
|
||||
<button className="btn btn-primary flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Changes
|
||||
</button>
|
||||
{activeTab !== 'configurations' && (
|
||||
<button className="btn btn-primary flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Changes
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`px-4 py-2 font-medium ${activeTab === 'general' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('payment')}
|
||||
className={`px-4 py-2 font-medium ${activeTab === 'payment' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Payment
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('integrations')}
|
||||
className={`px-4 py-2 font-medium ${activeTab === 'integrations' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Integrations
|
||||
</button>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 font-medium ${activeTab === tab.id ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
@@ -139,6 +169,46 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'configurations' && (
|
||||
<div className="card space-y-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
|
||||
{configLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<div className="max-w-sm space-y-2">
|
||||
<label className="label" htmlFor="hold-duration">
|
||||
Seat Hold Duration (minutes)
|
||||
</label>
|
||||
<input
|
||||
id="hold-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
className="input"
|
||||
value={seatHoldMinutes}
|
||||
onChange={(e) => setSeatHoldMinutes(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How long a seat hold remains active before it expires automatically. Default: 5 minutes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
onClick={saveConfigurations}
|
||||
disabled={configSaving || configLoading}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{configSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
{configMessage && (
|
||||
<span className="text-sm text-muted-foreground">{configMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,19 @@ export default function TicketsPage() {
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false);
|
||||
const [exportDateFrom, setExportDateFrom] = useState('');
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
@@ -534,111 +547,116 @@ export default function TicketsPage() {
|
||||
isOpen={detailsModalOpen}
|
||||
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
|
||||
title="Ticket Details"
|
||||
size="lg"
|
||||
size="xl"
|
||||
>
|
||||
{selectedTicket && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Ticket Number</p>
|
||||
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
<div className="mt-1">
|
||||
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
|
||||
{selectedTicket.status || 'ACTIVE'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Booking Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Booking Reference</p>
|
||||
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Passenger</p>
|
||||
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Amount</p>
|
||||
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Trip Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Route</p>
|
||||
<p className="font-medium">
|
||||
{selectedTicket.schedule?.originStation?.name || 'N/A'} → {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Departure</p>
|
||||
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Seat Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Coach</p>
|
||||
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Seat Number</p>
|
||||
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Class</p>
|
||||
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTicket.validatedAt && (
|
||||
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Validated At</p>
|
||||
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{selectedTicket && (() => {
|
||||
const t = selectedTicket;
|
||||
const b = t.booking;
|
||||
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
|
||||
return (
|
||||
<div>
|
||||
{/* Gradient header */}
|
||||
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Leg Status</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
|
||||
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
|
||||
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Return Boarded</p>
|
||||
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
|
||||
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Passenger', value: passengerName },
|
||||
{ label: 'Route', value: `${t.schedule?.originStation?.name || '?'} → ${t.schedule?.destinationStation?.name || '?'}` },
|
||||
{ label: 'Amount', value: formatCurrency(b?.totalMinor || 0, b?.currency || 'ETB') },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-emerald-200 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
|
||||
Close
|
||||
</ActionButton>
|
||||
<div className="space-y-6">
|
||||
{/* Booking */}
|
||||
<section>
|
||||
<SectionHeader title="Booking Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Booking Ref" value={b?.bookingRef} mono />
|
||||
<Field label="Booking Type" value={(b?.bookingType || 'ONE_WAY').replace(/_/g, ' ')} />
|
||||
<Field label="Payment Status" value={b?.paymentIntent?.status || 'N/A'} />
|
||||
<Field label="Contact Phone" value={b?.contactPhone || b?.passenger?.phone} />
|
||||
<Field label="Contact Email" value={b?.contactEmail || b?.passenger?.email} truncate />
|
||||
<Field label="Adults" value={String(b?.adultCount ?? 0)} />
|
||||
<Field label="Children" value={String(b?.childCount ?? 0)} />
|
||||
<Field label="Booking ID" value={b?.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trip */}
|
||||
<section>
|
||||
<SectionHeader title="Trip Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Origin" value={t.schedule?.originStation?.name} />
|
||||
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
|
||||
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
|
||||
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
|
||||
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
|
||||
<Field label="Schedule ID" value={t.scheduleId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Seat */}
|
||||
<section>
|
||||
<SectionHeader title="Seat Information" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2 md:col-span-1 flex flex-col items-center justify-center">
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Seat</p>
|
||||
<p className="text-2xl font-mono font-bold text-emerald-800 dark:text-emerald-300">{t.seat?.seatNumber || '—'}</p>
|
||||
</div>
|
||||
<Field label="Coach" value={t.seat?.coach?.number} mono />
|
||||
<Field label="Class" value={t.seat?.coach?.coachType?.name || t.seat?.coach?.coachType?.type} />
|
||||
<Field label="Seat ID" value={t.seatId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Round-trip */}
|
||||
{isRoundTrip && (
|
||||
<section>
|
||||
<SectionHeader title="Round-Trip Legs" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Leg Status" value={(b?.returnLegStatus || '—').replace(/_/g, ' ')} />
|
||||
<Field label="Outbound Boarded" value={b?.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
|
||||
<Field label="Return Boarded" value={b?.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Validation */}
|
||||
<section>
|
||||
<SectionHeader title="Validation & Timestamps" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
|
||||
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
|
||||
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
|
||||
<Field label="Created" value={formatDateTime(t.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
|
||||
<Field label="Ticket ID" value={t.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
|
||||
{/* Export Modal */}
|
||||
|
||||
@@ -2,15 +2,30 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Eye, ShieldCheck, ShieldOff } 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 { verifaydaApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function VerifaydaPage() {
|
||||
const [filters, setFilters] = useState({ search: '', verified: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['verifayda', filters],
|
||||
@@ -18,11 +33,19 @@ export default function VerifaydaPage() {
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
|
||||
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' },
|
||||
{ key: 'verified', label: 'Status', render: (ver: any) => <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>{ver.verified ? 'Verified' : 'Failed'}</Badge> },
|
||||
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
|
||||
];
|
||||
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
|
||||
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || ver.returnedName || 'N/A' },
|
||||
{ key: 'verified', label: 'Status', render: (ver: any) => (
|
||||
<Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{ver.verified ? 'Verified' : 'Failed'}
|
||||
</Badge>
|
||||
)},
|
||||
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (v: any) => setSelected(v), variant: 'secondary' as const, icon: Eye },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
|
||||
|
||||
<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 by National ID..." 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.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
|
||||
<option value="">All</option>
|
||||
<option value="true">Verified</option>
|
||||
<option value="false">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input type="text" placeholder="Search by National ID..." 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.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
|
||||
<option value="">All</option>
|
||||
<option value="true">Verified</option>
|
||||
<option value="false">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No verifayda integration found"
|
||||
emptyMessage="No verification records found"
|
||||
/>
|
||||
|
||||
{/* Verifayda Details Modal */}
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Verification Details" size="xl">
|
||||
{selected && (() => {
|
||||
const v = selected;
|
||||
const isVerified = !!v.verified;
|
||||
const grad = isVerified ? 'from-emerald-600 to-emerald-700' : 'from-red-600 to-red-700';
|
||||
const name = v.fullName || v.returnedName || 'N/A';
|
||||
return (
|
||||
<div>
|
||||
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
{isVerified
|
||||
? <ShieldCheck className="w-7 h-7 text-white" />
|
||||
: <ShieldOff className="w-7 h-7 text-white" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xl font-bold truncate">{name}</p>
|
||||
<p className="text-white/70 text-sm font-mono">{v.nationalId}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{isVerified ? '✓ Verified' : '✗ Failed'}
|
||||
</Badge>
|
||||
<p className="text-white/70 text-xs mt-1">{formatDateTime(v.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'National ID', value: v.nationalId || '—' },
|
||||
{ label: 'Date of Birth', value: v.dateOfBirth || v.returnedDob || '—' },
|
||||
{ label: 'Nationality', value: v.nationality || 'Ethiopian' },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-white/70 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Verification Result" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isVerified
|
||||
? <ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||
: <ShieldOff className="w-4 h-4 text-red-500 shrink-0" />}
|
||||
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{isVerified ? 'Verified' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Verified At" value={formatDateTime(v.createdAt)} />
|
||||
<Field label="Failure Reason" value={v.failureReason || (isVerified ? 'N/A' : 'Verification failed')} truncate />
|
||||
<Field label="Response Code" value={v.responseCode || 'N/A'} mono />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Identity Data (from Fayda)" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="National ID" value={v.nationalId} mono />
|
||||
<Field label="Full Name" value={v.fullName || v.returnedName} />
|
||||
<Field label="Date of Birth" value={v.dateOfBirth || v.returnedDob} />
|
||||
<Field label="Gender" value={v.gender || v.returnedGender} />
|
||||
<Field label="Nationality" value={v.nationality || 'Ethiopian'} />
|
||||
<Field label="Phone" value={v.phone || v.returnedPhone} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Linked Passenger" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Passenger Name" value={v.passenger?.fullName || v.user?.fullName} />
|
||||
<Field label="Email" value={v.passenger?.email || v.user?.email} truncate />
|
||||
<Field label="Passenger ID" value={v.passengerId || v.passenger?.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="System" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Record ID" value={v.id} mono truncate />
|
||||
<Field label="Created" value={formatDateTime(v.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(v.updatedAt)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,30 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Download, Eye, Wallet } 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 { walletApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export default function WalletPage() {
|
||||
const [filters, setFilters] = useState({ search: '' });
|
||||
const [selected, setSelected] = useState<any>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['wallet', filters],
|
||||
@@ -18,10 +33,25 @@ export default function WalletPage() {
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
|
||||
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') },
|
||||
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
];
|
||||
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
|
||||
<div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
|
||||
</div>
|
||||
)},
|
||||
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => (
|
||||
<span className="font-semibold">{formatCurrency(account.balanceMinor, account.currency || 'ETB')}</span>
|
||||
)},
|
||||
{ key: 'status', label: 'Status', render: (account: any) => (
|
||||
<Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{account.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
)},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -35,21 +65,113 @@ export default function WalletPage() {
|
||||
|
||||
<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">Search</label>
|
||||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={data?.items || data || []}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage="No wallet management found"
|
||||
emptyMessage="No wallet accounts found"
|
||||
/>
|
||||
|
||||
{/* Wallet Details Modal */}
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Wallet Account Details" size="xl">
|
||||
{selected && (() => {
|
||||
const w = selected;
|
||||
const balance = w.balanceMinor ?? 0;
|
||||
const passengerName = w.passenger?.fullName || w.user?.fullName || 'N/A';
|
||||
return (
|
||||
<div>
|
||||
<div className="from-blue-600 to-blue-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
|
||||
<p className="text-blue-200 text-sm">{w.passenger?.email || w.user?.email || ''}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{w.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Current Balance', value: formatCurrency(balance, w.currency || 'ETB') },
|
||||
{ label: 'Currency', value: w.currency || 'ETB' },
|
||||
{ label: 'Total Topped Up', value: formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB') },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
|
||||
<p className="text-blue-200 text-xs">{label}</p>
|
||||
<p className="text-white text-sm font-bold truncate">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<SectionHeader title="Balance" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3 col-span-2">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-400 mb-1">Current Balance</p>
|
||||
<p className="text-xl font-bold text-blue-800 dark:text-blue-300">{formatCurrency(balance, w.currency || 'ETB')}</p>
|
||||
</div>
|
||||
<Field label="Total Topped Up" value={formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB')} />
|
||||
<Field label="Total Spent" value={formatCurrency(w.totalSpent ?? 0, w.currency || 'ETB')} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Account Details" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Field label="Currency" value={w.currency || 'ETB'} />
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground mb-2">Status</p>
|
||||
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{w.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Field label="Locked" value={w.isLocked ? 'Yes' : 'No'} />
|
||||
<Field label="Lock Reason" value={w.lockReason || 'N/A'} truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Passenger" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Full Name" value={w.passenger?.fullName || w.user?.fullName} />
|
||||
<Field label="Email" value={w.passenger?.email || w.user?.email} truncate />
|
||||
<Field label="Phone" value={w.passenger?.phone || w.user?.phone} />
|
||||
<Field label="Passenger ID" value={w.passengerId || w.passenger?.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="Timestamps & IDs" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Created" value={formatDateTime(w.createdAt)} />
|
||||
<Field label="Last Updated" value={formatDateTime(w.updatedAt)} />
|
||||
<Field label="Account ID" value={w.id} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
|
||||
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -373,3 +373,9 @@ export const reportsApi = {
|
||||
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
|
||||
},
|
||||
};
|
||||
|
||||
// System Config API
|
||||
export const systemConfigApi = {
|
||||
getAll: () => apiClient.get<Record<string, string>>('/system-config'),
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data),
|
||||
};
|
||||
|
||||
@@ -60,6 +60,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-up {
|
||||
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
background-color: hsl(var(--card));
|
||||
|
||||
@@ -37,4 +37,4 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
|
||||
BIN
apps/edr-passenger-web/portal/public/bed.png
Normal file
BIN
apps/edr-passenger-web/portal/public/bed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -142,8 +142,8 @@ export default function ResultsPage() {
|
||||
? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
|
||||
: outboundSchedules.length > 0;
|
||||
|
||||
const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } }));
|
||||
const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => {
|
||||
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } }));
|
||||
};
|
||||
|
||||
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
|
||||
@@ -156,7 +156,7 @@ export default function ResultsPage() {
|
||||
}
|
||||
|
||||
// Find the coach type to get pricing info
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id);
|
||||
const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.id);
|
||||
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
|
||||
|
||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||
@@ -175,7 +175,7 @@ export default function ResultsPage() {
|
||||
baseFareChild: minFare,
|
||||
selectedSeatClass: selectedCoachType.name,
|
||||
selectedSeatClassName: selectedCoachType.name,
|
||||
selectedCoachId: selectedCoachType.id,
|
||||
selectedCoachTypeId: selectedCoachType.id,
|
||||
selectedCoachTypeCode: selectedCoachType.code,
|
||||
selectedCoachTypeName: selectedCoachType.name,
|
||||
};
|
||||
@@ -533,14 +533,14 @@ export default function ResultsPage() {
|
||||
{coachTypes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
|
||||
{coachTypes.map((coachType: any, index: number) => {
|
||||
const isSelected = selectedCoachType?.id === coachType.coachId;
|
||||
const isSelected = selectedCoachType?.id === coachType.coachTypeCode;
|
||||
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={coachType.coachId}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachId, coachType.coachTypeCode, coachType.coachTypeName)}
|
||||
onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeCode, coachType.coachTypeName)}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'
|
||||
|
||||
@@ -364,11 +364,10 @@ export default function ReviewPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
|
||||
|
||||
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0;
|
||||
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0;
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => {
|
||||
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum) => {
|
||||
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
|
||||
return sum + farePerPassenger;
|
||||
}, 0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21567
pnpm-lock.yaml
generated
21567
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user