mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Extra luggage, tourism package, manage my trip and other updates
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;
|
||||
@@ -298,6 +298,7 @@ model Passenger {
|
||||
notifications Notification[]
|
||||
travelerProfiles TravelerProfile[]
|
||||
savedRoutes SavedRoute[]
|
||||
packageBookings PackageBooking[]
|
||||
|
||||
@@index([userId])
|
||||
@@schema("passenger")
|
||||
@@ -376,6 +377,8 @@ model TrainSchedule {
|
||||
liveStatus TripLiveStatus?
|
||||
menuItems MenuItem[]
|
||||
journeySegments JourneySegment[]
|
||||
outboundPackages TravelPackage[] @relation("PackageOutbound")
|
||||
returnPackages TravelPackage[] @relation("PackageReturn")
|
||||
|
||||
@@index([departureAt, originStationId])
|
||||
@@schema("passenger")
|
||||
@@ -1349,3 +1352,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;
|
||||
|
||||
@@ -42,6 +42,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: [
|
||||
@@ -91,6 +93,8 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
|
||||
VerifaydaModule,
|
||||
AuditModuleFeature,
|
||||
CurrenciesModule,
|
||||
SystemConfigModule,
|
||||
PackagesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { SeatsController } from './seats.controller';
|
||||
import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { IamModule } from '../../common/iam.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule, HttpModule, IamModule],
|
||||
imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -3,12 +3,14 @@ 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) {
|
||||
@@ -169,7 +171,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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
35
pnpm-lock.yaml
generated
35
pnpm-lock.yaml
generated
@@ -634,6 +634,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.59.0
|
||||
version: 5.101.0(react@18.3.1)
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.17.0
|
||||
@@ -643,12 +646,21 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^3.0.0
|
||||
version: 3.6.0
|
||||
jspdf:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
jspdf-autotable:
|
||||
specifier: ^5.0.8
|
||||
version: 5.0.8(jspdf@4.2.1)
|
||||
lucide-react:
|
||||
specifier: ^0.446.0
|
||||
version: 0.446.0(react@18.3.1)
|
||||
next:
|
||||
specifier: ^14.2.0
|
||||
version: 14.2.35(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
qrcode.react:
|
||||
specifier: ^3.1.0
|
||||
version: 3.2.0(react@18.3.1)
|
||||
@@ -8646,9 +8658,17 @@ packages:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jspdf-autotable@5.0.8:
|
||||
resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==}
|
||||
peerDependencies:
|
||||
jspdf: ^2 || ^3 || ^4
|
||||
|
||||
jspdf@3.0.4:
|
||||
resolution: {integrity: sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==}
|
||||
|
||||
jspdf@4.2.1:
|
||||
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
|
||||
|
||||
jsprim@1.4.2:
|
||||
resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==}
|
||||
engines: {node: '>=0.6.0'}
|
||||
@@ -22968,6 +22988,10 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
semver: 7.8.2
|
||||
|
||||
jspdf-autotable@5.0.8(jspdf@4.2.1):
|
||||
dependencies:
|
||||
jspdf: 4.2.1
|
||||
|
||||
jspdf@3.0.4:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
@@ -22979,6 +23003,17 @@ snapshots:
|
||||
dompurify: 3.4.8
|
||||
html2canvas: 1.4.1
|
||||
|
||||
jspdf@4.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
fast-png: 6.4.0
|
||||
fflate: 0.8.3
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.4.8
|
||||
html2canvas: 1.4.1
|
||||
|
||||
jsprim@1.4.2:
|
||||
dependencies:
|
||||
assert-plus: 1.0.0
|
||||
|
||||
Reference in New Issue
Block a user