Merge pull request #264 from Tria-plc/alpha

Update seatmap configuration
This commit is contained in:
Eyob T.
2026-06-24 12:07:09 +03:00
committed by GitHub
36 changed files with 17838 additions and 8428 deletions

View File

@@ -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;

View File

@@ -283,15 +283,17 @@ model Passenger {
userId String? @unique userId String? @unique
iamUserId String? @unique iamUserId String? @unique
defaultTravelerProfileId String? defaultTravelerProfileId String?
preferredLanguage String? preferredLanguage String?
blockedUntil DateTime? blockedUntil DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
bookings Booking[] user User @relation(fields: [userId], references: [id])
loyalty LoyaltyAccount? bookings Booking[]
wallet WalletAccount? loyalty LoyaltyAccount?
notifications Notification[] wallet WalletAccount?
travelerProfiles TravelerProfile[] notifications Notification[]
savedRoutes SavedRoute[] travelerProfiles TravelerProfile[]
savedRoutes SavedRoute[]
packageBookings PackageBooking[]
@@index([userId]) @@index([userId])
@@index([iamUserId]) @@index([iamUserId])
@@schema("passenger") @@schema("passenger")
@@ -370,6 +372,8 @@ model TrainSchedule {
liveStatus TripLiveStatus? liveStatus TripLiveStatus?
menuItems MenuItem[] menuItems MenuItem[]
journeySegments JourneySegment[] journeySegments JourneySegment[]
outboundPackages TravelPackage[] @relation("PackageOutbound")
returnPackages TravelPackage[] @relation("PackageReturn")
@@index([departureAt, originStationId]) @@index([departureAt, originStationId])
@@schema("passenger") @@schema("passenger")
@@ -1337,3 +1341,135 @@ model FaydaVerificationSession {
@@index([expiresAt]) @@index([expiresAt])
@@schema("passenger") @@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")
}

View File

@@ -670,6 +670,70 @@ async function seedFraudRules() {
console.log(`${rules.length} fraud detection rules created`); 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, // 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 — // 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. // and the API startup that follows it — are never blocked by one bad step.
@@ -691,7 +755,8 @@ async function main() {
['fare rules', seedFareRules], ['fare rules', seedFareRules],
['segment fares', seedSegmentFares], ['segment fares', seedSegmentFares],
['currency', seedCurrency], ['currency', seedCurrency],
['notification templates', seedNotificationTemplates] ['notification templates', seedNotificationTemplates],
['kulubbi package', seedKulubbiPackage],
]; ];
let failed = 0; let failed = 0;

View File

@@ -57,6 +57,8 @@ import { FareEngineModule } from './modules/fare-engine/fare-engine.module';
import { VerifaydaModule } from './modules/verifayda/verifayda.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { AuditModuleFeature } from './modules/audit/audit.module'; import { AuditModuleFeature } from './modules/audit/audit.module';
import { CurrenciesModule } from './modules/currencies/currencies.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({ @Module({
imports: [ imports: [
@@ -116,6 +118,8 @@ import { CurrenciesModule } from './modules/currencies/currencies.module';
VerifaydaModule, VerifaydaModule,
AuditModuleFeature, AuditModuleFeature,
CurrenciesModule, CurrenciesModule,
SystemConfigModule,
PackagesModule,
], ],
providers: [ providers: [
EdrPassengerOrgSeeder, EdrPassengerOrgSeeder,

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiResponse } from '@nestjs/swagger';
import { FleetService } from './fleet.service'; import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard'; import { JwtGuard } from '../../common/jwt.guard';
@ApiTags('Fleet') @ApiTags('Fleet')
@@ -317,6 +317,39 @@ export class FleetController {
return this.service.removeAssignment(id); return this.service.removeAssignment(id);
} }
@Post('seatmap/generate')
@ApiOperation({
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
description: `Generates a structured seat map for bed coaches without persisting anything.
**ECONOMY_BED**: 6 beds per room — Left(Lower/Middle/Upper) + Right(Lower/Middle/Upper)
**VIP_BED**: 4 beds per room — Left(Lower/Upper) + Right(Lower/Upper)
Use this to preview the full flat seat list before creating coaches.`,
})
@ApiBody({ type: GenerateSeatMapDto })
@ApiResponse({
status: 201,
description: 'Generated seat map preview',
schema: {
example: {
coachCount: 1, roomsPerCoach: 2, roomType: 'ECONOMY_BED', bedsPerRoom: 6, totalBeds: 12,
seats: [
{ seat_id: 'C1-C1-R1-S1', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'LOWER', sequence_number: 1, status: 'AVAILABLE' },
{ seat_id: 'C1-C1-R1-S2', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'MIDDLE', sequence_number: 2, status: 'AVAILABLE' },
{ seat_id: 'C1-C1-R1-S3', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'LEFT', bed_type: 'UPPER', sequence_number: 3, status: 'AVAILABLE' },
{ seat_id: 'C1-C1-R1-S4', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'LOWER', sequence_number: 4, status: 'AVAILABLE' },
{ seat_id: 'C1-C1-R1-S5', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'MIDDLE', sequence_number: 5, status: 'AVAILABLE' },
{ seat_id: 'C1-C1-R1-S6', coach_id: 'C1', room_id: 'C1-R1', category: 'ECONOMY_BED', position: 'RIGHT', bed_type: 'UPPER', sequence_number: 6, status: 'AVAILABLE' },
],
},
},
})
generateSeatMap(@Body() dto: GenerateSeatMapDto) {
return this.service.generateSeatMapPreview(dto);
}
@Get('analytics') @Get('analytics')
@ApiOperation({ summary: 'Fleet analytics and occupancy metrics' }) @ApiOperation({ summary: 'Fleet analytics and occupancy metrics' })
@ApiResponse({ status: 200, description: 'Occupancy statistics' }) @ApiResponse({ status: 200, description: 'Occupancy statistics' })

View File

@@ -12,9 +12,20 @@ export class CreateTrainDto {
export class CreateCoachDto { export class CreateCoachDto {
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string; @ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string; @ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
@ApiProperty({ example: '2+2', description: 'Seat arrangement (e.g., "2+2", "3+2")' }) @IsString() arrangement: string; @ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
@ApiProperty({ example: 60, description: 'Total seat capacity' }) @IsInt() capacity: number; @ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
@ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string; @ApiPropertyOptional({ example: 'ACTIVE', description: 'Status: ACTIVE, INACTIVE' }) @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({
enum: ['ECONOMY_BED', 'VIP_BED'],
description: 'Bed coach category. Set to generate bed/sleeper compartments instead of regular seats. Overrides name-based detection.',
example: 'VIP_BED',
})
@IsOptional() @IsString() bedCategory?: 'ECONOMY_BED' | 'VIP_BED';
@ApiPropertyOptional({
example: 4,
description: 'Beds per compartment/room. Must be even (split equally left/right). Defaults: VIP_BED=4, ECONOMY_BED=6. Only applies when bedCategory is set.',
})
@IsOptional() @IsInt() bedsPerRoom?: number;
} }
export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) { export class UpdateCoachDto extends PartialType(OmitType(CreateCoachDto, ['number'] as const)) {
@@ -67,3 +78,14 @@ export class UpdateClassDto {
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
} }
export class GenerateSeatMapDto {
@ApiProperty({ example: 2, description: 'Number of coaches' })
@IsInt() coachCount: number;
@ApiProperty({ example: 9, description: 'Number of rooms (compartments) per coach' })
@IsInt() roomsPerCoach: number;
@ApiProperty({ enum: ['ECONOMY_BED', 'VIP_BED'], example: 'ECONOMY_BED', description: 'ECONOMY_BED = 6 beds/room (L/M/U × Left/Right), VIP_BED = 4 beds/room (L/U × Left/Right)' })
@IsString() roomType: 'ECONOMY_BED' | 'VIP_BED';
}

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service'; import { PrismaService } from '../../common/prisma.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto } from './fleet.dto'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { SeatKind } from '@prisma/client'; import { SeatKind } from '@prisma/client';
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2] // Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
@@ -33,41 +33,97 @@ function isAisleCol(colIndex: number, groups: number[]): boolean {
return false; return false;
} }
function buildSeats(coachId: string, coachNumber: string, arrangement: string, capacity: number, seatClass?: string): SeatRow[] { type BedCategory = 'ECONOMY_BED' | 'VIP_BED' | null;
// Default beds per room for each category when not explicitly configured
const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
VIP_BED: 4,
ECONOMY_BED: 6,
};
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
function detectBedCategory(coachTypeName: string): BedCategory {
const name = coachTypeName.toLowerCase();
const isBed = name.includes('bed') || name.includes('sleeper') || name.includes('couchette');
if (!isBed) return null;
if (name.includes('vip')) return 'VIP_BED';
return 'ECONOMY_BED';
}
// Resolves bed type names per side from beds-per-side count:
// 2/side → ['LOWER','UPPER'] (VIP style)
// 3/side → ['LOWER','MIDDLE','UPPER'] (Economy style)
function resolveBedTypes(bedsPerSide: number): string[] {
if (bedsPerSide === 1) return ['LOWER'];
if (bedsPerSide === 2) return ['LOWER', 'UPPER'];
if (bedsPerSide === 3) return ['LOWER', 'MIDDLE', 'UPPER'];
return Array.from({ length: bedsPerSide }, (_, i) => {
if (i === 0) return 'LOWER';
if (i === bedsPerSide - 1) return 'UPPER';
return 'MIDDLE';
});
}
// Generates the flat seat/bed list for a bed coach.
// Row = room number; col = position-relative label (L1, L2 … R1, R2 …).
function buildBedSeats(
coachId: string,
capacity: number,
bedsPerRoom: number,
): SeatRow[] {
const bedsPerSide = bedsPerRoom / 2;
const bedTypeNames = resolveBedTypes(bedsPerSide);
const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [
...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })),
...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })),
];
const roomCount = Math.ceil(capacity / bedsPerRoom);
const seats: SeatRow[] = [];
let seatNumber = 1;
for (let room = 1; room <= roomCount; room++) {
const posCount: Record<string, number> = {};
for (let slot = 0; slot < bedsPerRoom && seats.length < capacity; slot++) {
const { position, bedType } = layout[slot];
posCount[position] = (posCount[position] ?? 0) + 1;
const col = `${position[0]}${posCount[position]}`;
seats.push({
coachId,
row: room,
col,
seatNumber: `${seatNumber}`,
kind: SeatKind.STANDARD,
bedPosition: bedType.toLowerCase(),
isWindow: false,
isAisle: false,
});
seatNumber++;
}
}
return seats;
}
function buildRegularSeats(coachId: string, arrangement: string, capacity: number): SeatRow[] {
const cols = seatCols(arrangement); const cols = seatCols(arrangement);
const groups = parseArrangement(arrangement); const groups = parseArrangement(arrangement);
const seats: SeatRow[] = []; const seats: SeatRow[] = [];
let row = 1; let row = 1;
let seatNumber = 1; let seatNumber = 1;
let seatIndex = 0; let seatIndex = 0;
const isBedCoach = seatClass?.toLowerCase().includes('bed');
const totalCols = cols.length;
while (seatIndex < capacity) { while (seatIndex < capacity) {
for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) { for (let ci = 0; ci < cols.length && seatIndex < capacity; ci++) {
const col = cols[ci]; const col = cols[ci];
let bedPosition = null;
// Set bedPosition for bed coaches based on ROW cycling (not seat number)
if (isBedCoach) {
if (totalCols === 3) {
// Economy bed (3-row cycle): upper, middle, lower
if (row % 3 === 1) bedPosition = 'upper';
else if (row % 3 === 2) bedPosition = 'middle';
else bedPosition = 'lower';
} else if (totalCols === 2) {
// VIP bed (2-row cycle): upper, lower
bedPosition = row % 2 === 1 ? 'upper' : 'lower';
}
}
seats.push({ seats.push({
coachId, coachId,
row, row,
col, col,
seatNumber: `${seatNumber}`, seatNumber: `${seatNumber}`,
kind: SeatKind.STANDARD, kind: SeatKind.STANDARD,
bedPosition, bedPosition: null,
isWindow: isWindowCol(ci, groups),
isAisle: isAisleCol(ci, groups),
}); });
seatNumber++; seatNumber++;
seatIndex++; seatIndex++;
@@ -84,6 +140,8 @@ type SeatRow = {
seatNumber: string; seatNumber: string;
kind: SeatKind; kind: SeatKind;
bedPosition?: string | null; bedPosition?: string | null;
isWindow?: boolean;
isAisle?: boolean;
}; };
@Injectable() @Injectable()
@@ -332,8 +390,20 @@ export class FleetService {
}); });
if (dto.capacity > 0) { if (dto.capacity > 0) {
const seatClass = coach.coachType?.name || ''; // dto.bedCategory takes priority; fall back to name-based detection
const seats = buildSeats(coach.id, coach.number, dto.arrangement, dto.capacity, seatClass); const bedCategory: BedCategory = dto.bedCategory ?? detectBedCategory(coach.coachType?.name || '');
let seats: SeatRow[];
if (bedCategory) {
const bedsPerRoom = dto.bedsPerRoom ?? DEFAULT_BEDS_PER_ROOM[bedCategory];
if (bedsPerRoom < 2 || bedsPerRoom % 2 !== 0) {
throw new BadRequestException('bedsPerRoom must be an even number ≥ 2');
}
seats = buildBedSeats(coach.id, dto.capacity, bedsPerRoom);
} else {
seats = buildRegularSeats(coach.id, dto.arrangement, dto.capacity);
}
await this.prisma.seat.createMany({ data: seats }); await this.prisma.seat.createMany({ data: seats });
} }
@@ -425,6 +495,50 @@ export class FleetService {
return this.prisma.coachAssignment.delete({ where: { id } }); return this.prisma.coachAssignment.delete({ where: { id } });
} }
async generateSeatMapPreview(dto: GenerateSeatMapDto) {
const { coachCount, roomsPerCoach, roomType } = dto;
const bedsPerRoom = DEFAULT_BEDS_PER_ROOM[roomType];
const bedTypeNames = resolveBedTypes(bedsPerRoom / 2);
const layout: Array<{ position: 'LEFT' | 'RIGHT'; bedType: string }> = [
...bedTypeNames.map(bt => ({ position: 'LEFT' as const, bedType: bt })),
...bedTypeNames.map(bt => ({ position: 'RIGHT' as const, bedType: bt })),
];
const seats: object[] = [];
let globalSeq = 1;
for (let c = 1; c <= coachCount; c++) {
const coachLabel = `C${c}`;
for (let r = 1; r <= roomsPerCoach; r++) {
const roomLabel = `R${r}`;
const posCount: Record<string, number> = {};
for (let s = 0; s < bedsPerRoom; s++) {
const { position, bedType } = layout[s];
posCount[position] = (posCount[position] ?? 0) + 1;
seats.push({
seat_id: `${coachLabel}-${roomLabel}-S${globalSeq}`,
coach_id: coachLabel,
room_id: `${coachLabel}-${roomLabel}`,
category: roomType,
position,
col: `${position[0]}${posCount[position]}`,
bed_type: bedType,
sequence_number: globalSeq,
status: 'AVAILABLE',
});
globalSeq++;
}
}
}
return {
coachCount,
roomsPerCoach,
roomType,
bedsPerRoom,
totalBeds: seats.length,
seats,
};
}
async getAnalytics() { async getAnalytics() {
const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([ const [totalTrains, totalSchedules, totalSeats, bookedSeats] = await Promise.all([
this.prisma.train.count(), this.prisma.train.count(),

View File

@@ -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);
}
}

View 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[];
}

View File

@@ -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 {}

View 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 };
}
}

View File

@@ -835,7 +835,7 @@ export class PaymentsService {
status: 'CONFIRMED', status: 'CONFIRMED',
totalMinor: booking.totalMinor, totalMinor: booking.totalMinor,
currency: booking.currency, currency: booking.currency,
}, } as any,
}); });
const journeySegments: any[] = []; const journeySegments: any[] = [];

View File

@@ -1,38 +1,64 @@
import { Body, Controller, Delete, Get, Param, Post, Patch, Query, UseGuards } from '@nestjs/common'; import {
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; Body,
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; Controller,
import { SeatsService } from './seats.service'; Delete,
import { HoldSeatsDto } from './seats.dto'; Get,
import { JwtGuard } from '../../common/jwt.guard'; Param,
import { IamGuard } from '../../common/iam-adapter'; Post,
Patch,
Query,
UseGuards,
} from "@nestjs/common";
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
ApiQuery,
ApiResponse,
} from "@nestjs/swagger";
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
import { SeatsService } from "./seats.service";
import { HoldSeatsDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
import { IamGuard } from "../../common/iam-adapter";
@ApiTags('Seats') @ApiTags("Seats")
@Controller('seats') @Controller("seats")
export class SeatsController { export class SeatsController {
constructor(private service: SeatsService) {} constructor(private service: SeatsService) {}
// ── Seat Map ────────────────────────────────────────────────────────────── // ── Seat Map ──────────────────────────────────────────────────────────────
@Get('seatmap/:scheduleId') @Get("seatmap/:scheduleId")
@IsPublic()
@ApiOperation({ @ApiOperation({
summary: 'Get seat map with real-time availability by class', summary: "Get seat map filtered by coach type",
description: `Returns seat map for a schedule with availability by seat class: description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
- Economy Regular
- Economy Bed
- VIP Bed
Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
}) })
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' }) @ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
@ApiQuery({ name: 'coachId', required: false, description: 'Filter by coach UUID' }) @ApiQuery({
@ApiResponse({ status: 200, description: 'Returns coaches with seats and seat class info' }) name: "coachTypeId",
getSeatMap(@Param('scheduleId') scheduleId: string, @Query('coachId') coachId?: string) { return this.service.getSeatMap(scheduleId, coachId); } required: false,
description:
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
})
@ApiResponse({
status: 200,
description:
"List of coaches of the given type with their seats and availability",
})
getSeatMap(
@Param("scheduleId") scheduleId: string,
@Query("coachTypeId") coachTypeId?: string,
) {
return this.service.getSeatMap(scheduleId, coachTypeId);
}
// ── Hold / Release ──────────────────────────────────────────────────────── // ── Hold / Release ────────────────────────────────────────────────────────
@Get('holds') @Get("holds")
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ @ApiOperation({
summary: 'List active seat holds with full leg context', summary: "List active seat holds with full leg context",
description: `Returns all non-expired holds enriched with: description: `Returns all non-expired holds enriched with:
- **schedule**: train number, departure/arrival, full route origin→destination - **schedule**: train number, departure/arrival, full route origin→destination
- **leg**: the specific origin→destination this hold covers (station name, code, stop sequence) - **leg**: the specific origin→destination this hold covers (station name, code, stop sequence)
@@ -41,25 +67,45 @@ Shows seat status: AVAILABLE, BOOKED, HELD, BLOCKED`
This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`, This makes it clear which segment of the route each seat is held for, enabling segment-based reuse of the same seat on non-overlapping legs.`,
}) })
@ApiQuery({ name: 'scheduleId', required: false, description: 'Filter by TrainSchedule UUID' }) @ApiQuery({
@ApiQuery({ name: 'passengerId', required: false, description: 'Filter by Passenger UUID' }) name: "scheduleId",
@ApiResponse({ status: 200, description: 'Active holds with schedule, leg, and seat details' }) required: false,
description: "Filter by TrainSchedule UUID",
})
@ApiQuery({
name: "passengerId",
required: false,
description: "Filter by Passenger UUID",
})
@ApiResponse({
status: 200,
description: "Active holds with schedule, leg, and seat details",
})
getHolds( getHolds(
@Query('scheduleId') scheduleId?: string, @Query("scheduleId") scheduleId?: string,
@Query('passengerId') passengerId?: string, @Query("passengerId") passengerId?: string,
) { return this.service.getHolds(scheduleId, passengerId); } ) {
return this.service.getHolds(scheduleId, passengerId);
}
@Get('holds/:holdId') @Get("holds/:holdId")
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Get a single hold with full leg context' }) @ApiBearerAuth("JWT-auth")
@ApiParam({ name: 'holdId', description: 'SeatHold UUID' }) @ApiOperation({ summary: "Get a single hold with full leg context" })
@ApiResponse({ status: 200, description: 'Hold with schedule, leg, and seat details' }) @ApiParam({ name: "holdId", description: "SeatHold UUID" })
@ApiResponse({ status: 404, description: 'Hold not found' }) @ApiResponse({
getHold(@Param('holdId') holdId: string) { return this.service.getHold(holdId); } status: 200,
description: "Hold with schedule, leg, and seat details",
})
@ApiResponse({ status: 404, description: "Hold not found" })
getHold(@Param("holdId") holdId: string) {
return this.service.getHold(holdId);
}
@Post('hold') @Post("hold")
@ApiOperation({ @ApiOperation({
summary: 'Hold seats for 15 minutes before booking (Public - Guest booking supported)', summary:
"Hold seats for 15 minutes before booking (Public - Guest booking supported)",
description: `Temporarily reserves seats for a passenger to complete booking. description: `Temporarily reserves seats for a passenger to complete booking.
**Features:** **Features:**
@@ -67,74 +113,107 @@ This makes it clear which segment of the route each seat is held for, enabling s
- Auto-release after expiry - Auto-release after expiry
- Prevents double booking - Prevents double booking
- Required before creating booking - Required before creating booking
- **Public endpoint** - No authentication required (supports guest booking)` - **Public endpoint** - No authentication required (supports guest booking)`,
}) })
@ApiResponse({ status: 201, description: 'Seats held successfully with holdId' }) @ApiResponse({
@ApiResponse({ status: 409, description: 'One or more seats unavailable' }) status: 201,
holdSeats(@Body() dto: HoldSeatsDto) { return this.service.holdSeats(dto); } description: "Seats held successfully with holdId",
})
@ApiResponse({ status: 409, description: "One or more seats unavailable" })
holdSeats(@Body() dto: HoldSeatsDto) {
return this.service.holdSeats(dto);
}
@Delete('hold/:holdId') @Delete("hold/:holdId")
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @UseGuards(JwtGuard)
@ApiOperation({ summary: 'Release a seat hold' }) @ApiBearerAuth("JWT-auth")
@ApiParam({ name: 'holdId', description: 'Hold UUID' }) @ApiOperation({ summary: "Release a seat hold" })
@ApiResponse({ status: 200, description: 'Hold released' }) @ApiParam({ name: "holdId", description: "Hold UUID" })
@ApiResponse({ status: 404, description: 'Hold not found' }) @ApiResponse({ status: 200, description: "Hold released" })
releaseHold(@Param('holdId') holdId: string) { return this.service.releaseHold(holdId); } @ApiResponse({ status: 404, description: "Hold not found" })
releaseHold(@Param("holdId") holdId: string) {
return this.service.releaseHold(holdId);
}
// ── Seat Block / Unblock ─────────────────────────────────────────────────── // ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(':seatId/block') @Post(":seatId/block")
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') @UseGuards(IamGuard)
@ApiOperation({ summary: 'Block a seat (e.g., maintenance, damage)' }) @ApiBearerAuth("IAM-auth")
@ApiParam({ name: 'seatId', description: 'Seat UUID' }) @ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
@ApiResponse({ status: 200, description: 'Seat blocked' }) @ApiParam({ name: "seatId", description: "Seat UUID" })
blockSeat(@Param('seatId') seatId: string, @Body() body: { reason: string }) { @ApiResponse({ status: 200, description: "Seat blocked" })
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
return this.service.blockSeat(seatId, body.reason); return this.service.blockSeat(seatId, body.reason);
} }
@Delete(':seatId/block') @Delete(":seatId/block")
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') @UseGuards(IamGuard)
@ApiOperation({ summary: 'Unblock a seat' }) @ApiBearerAuth("IAM-auth")
@ApiParam({ name: 'seatId', description: 'Seat UUID' }) @ApiOperation({ summary: "Unblock a seat" })
@ApiResponse({ status: 200, description: 'Seat unblocked' }) @ApiParam({ name: "seatId", description: "Seat UUID" })
unblockSeat(@Param('seatId') seatId: string) { @ApiResponse({ status: 200, description: "Seat unblocked" })
unblockSeat(@Param("seatId") seatId: string) {
return this.service.unblockSeat(seatId); return this.service.unblockSeat(seatId);
} }
// ── Remove Seat ──────────────────────────────────────────────────────────── // ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(':seatId/remove') @Patch(":seatId/remove")
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') @UseGuards(IamGuard)
@ApiOperation({ summary: 'Remove a seat by marking with negative seatNumber' }) @ApiBearerAuth("IAM-auth")
@ApiParam({ name: 'seatId', description: 'Seat UUID' }) @ApiOperation({
@ApiResponse({ status: 200, description: 'Seat removed (seatNumber negated), shows as empty space' }) summary: "Remove a seat by marking with negative seatNumber",
@ApiResponse({ status: 404, description: 'Seat not found' }) })
removeSeat(@Param('seatId') seatId: string) { @ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({
status: 200,
description: "Seat removed (seatNumber negated), shows as empty space",
})
@ApiResponse({ status: 404, description: "Seat not found" })
removeSeat(@Param("seatId") seatId: string) {
return this.service.removeSeat(seatId); return this.service.removeSeat(seatId);
} }
@Patch(':seatId/undo-remove') @Patch(":seatId/undo-remove")
@UseGuards(IamGuard) @ApiBearerAuth('IAM-auth') @UseGuards(IamGuard)
@ApiOperation({ summary: 'Undo seat removal by restoring original seatNumber' }) @ApiBearerAuth("IAM-auth")
@ApiParam({ name: 'seatId', description: 'Seat UUID' }) @ApiOperation({
@ApiResponse({ status: 200, description: 'Seat restored (negative seatNumber removed)' }) summary: "Undo seat removal by restoring original seatNumber",
@ApiResponse({ status: 404, description: 'Seat not found' }) })
@ApiResponse({ status: 400, description: 'Seat is not removed' }) @ApiParam({ name: "seatId", description: "Seat UUID" })
undoRemoveSeat(@Param('seatId') seatId: string) { @ApiResponse({
status: 200,
description: "Seat restored (negative seatNumber removed)",
})
@ApiResponse({ status: 404, description: "Seat not found" })
@ApiResponse({ status: 400, description: "Seat is not removed" })
undoRemoveSeat(@Param("seatId") seatId: string) {
return this.service.undoRemoveSeat(seatId); return this.service.undoRemoveSeat(seatId);
} }
@Get('export/csv/:scheduleId') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Export seats as CSV' }) @Get("export/csv/:scheduleId")
async exportCSV(@Param('scheduleId') scheduleId: string) { @UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Export seats as CSV" })
async exportCSV(@Param("scheduleId") scheduleId: string) {
const csv = await this.service.exportSeatsCSV(scheduleId); const csv = await this.service.exportSeatsCSV(scheduleId);
return { csv, filename: `seats-${scheduleId}.csv` }; return { csv, filename: `seats-${scheduleId}.csv` };
} }
@Post('import/preview') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Preview CSV import' }) @Post("import/preview")
@UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Preview CSV import" })
previewCSV(@Body() body: { csv: string }) { previewCSV(@Body() body: { csv: string }) {
return this.service.previewSeatsCSV(body.csv); return this.service.previewSeatsCSV(body.csv);
} }
@Post('import/commit') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Commit CSV import' }) @Post("import/commit")
importCSV(@Body() body: { scheduleId: string; csv: string; commit: boolean }) { @UseGuards(JwtGuard)
@ApiBearerAuth("JWT-auth")
@ApiOperation({ summary: "Commit CSV import" })
importCSV(
@Body() body: { scheduleId: string; csv: string; commit: boolean },
) {
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit); return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
} }
} }

View File

@@ -3,9 +3,10 @@ import { HttpModule } from '@nestjs/axios';
import { SeatsController } from './seats.controller'; import { SeatsController } from './seats.controller';
import { SeatsService } from './seats.service'; import { SeatsService } from './seats.service';
import { SegmentsModule } from '../segments/segments.module'; import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({ @Module({
imports: [SegmentsModule, HttpModule], imports: [SegmentsModule, HttpModule, IamModule, SystemConfigModule],
controllers: [SeatsController], controllers: [SeatsController],
providers: [SeatsService], providers: [SeatsService],
exports: [SeatsService], exports: [SeatsService],

View File

@@ -3,17 +3,28 @@ import { PrismaService } from '../../common/prisma.service';
import { HoldSeatsDto } from './seats.dto'; import { HoldSeatsDto } from './seats.dto';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { SegmentsService } from '../segments/segments.service'; import { SegmentsService } from '../segments/segments.service';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
@Injectable() @Injectable()
export class SeatsService { export class SeatsService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private segmentsService: SegmentsService, private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {} ) {}
async getSeatMap(scheduleId: string, coachId?: string, originStationId?: string, destinationStationId?: string) { async getSeatMap(scheduleId: string, coachTypeId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const assignments = await this.prisma.coachAssignment.findMany({ const assignments = await this.prisma.coachAssignment.findMany({
where: { scheduleId, ...(coachId ? { coachId } : {}) }, where: {
scheduleId,
...(coachTypeId ? { coach: { coachTypeId } } : {}),
},
include: { include: {
coach: { coach: {
include: { include: {
@@ -26,48 +37,110 @@ export class SeatsService {
}); });
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, originStationId, destinationStationId); const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
return { return {
coaches: assignments.map((a) => { coaches: assignments.map((a) => {
const allSeats = a.coach.seats; const allSeats = a.coach.seats;
const coachTypeName = a.coach.coachType?.name ?? '';
const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name); const seatClassNames = a.coach.coachType.seatClasses.map((sc: any) => sc.name);
const isBedCoach = this.isBedCoach(coachTypeName);
// Compute actual beds-per-room from first room to correctly identify VIP (4) vs Economy (6)
const bedsPerRoom = isBedCoach
? allSeats.filter((s: any) => s.row === (allSeats[0] as any)?.row).length
: 0;
const bedCategory = isBedCoach ? this.getBedCategory(coachTypeName, bedsPerRoom) : null;
return { const mappedSeats = allSeats.map((s: any) => ({
id: a.coach.id, id: s.id,
assignmentId: a.id, seatNumber: s.seatNumber,
coachNumber: a.coach.number, label: s.seatNumber,
label: a.coach.number, status: effectiveStatuses.get(s.id) ?? s.status,
mode: a.coach.status, kind: s.kind,
name: `Coach ${a.coach.number}`, row: s.row,
seatClasses: seatClassNames, col: s.col,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard', isWindow: s.isWindow,
isAisle: s.isAisle,
// Bed-specific fields
...(isBedCoach ? {
room_id: `${a.coach.id}-R${s.row}`,
category: bedCategory,
position: this.colToPosition(s.col),
bed_type: this.bedPositionToType(s.bedPosition),
bedPosition: s.bedPosition,
} : {
bedPosition: s.bedPosition,
}),
}));
const base = {
id: a.coach.id,
assignmentId: a.id,
coachNumber: a.coach.number,
label: a.coach.number,
mode: a.coach.status,
name: `Coach ${a.coach.number}`,
coachTypeName,
isBedCoach,
bedCategory,
seatClasses: seatClassNames,
seatClass: seatClassNames.length > 0 ? seatClassNames[0] : 'Standard',
positionNumber: a.positionNumber, positionNumber: a.positionNumber,
seatArrangement: a.coach.arrangement, seatArrangement: a.coach.arrangement,
totalSeats: a.coach.capacity, totalSeats: a.coach.capacity,
seats: allSeats.map((s) => ({
id: s.id,
seatNumber: s.seatNumber,
number: s.seatNumber,
label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status,
kind: s.kind,
row: s.row,
col: s.col,
isWindow: s.isWindow,
isAisle: s.isAisle,
bedPosition: s.bedPosition,
coach: {
id: a.coach.id,
coachNumber: a.coach.number,
label: a.coach.number,
},
})),
}; };
if (isBedCoach) {
// Group seats into rooms; row = room number
const roomMap = new Map<number, any[]>();
for (const seat of mappedSeats) {
if (!roomMap.has(seat.row)) roomMap.set(seat.row, []);
roomMap.get(seat.row)!.push(seat);
}
const rooms = Array.from(roomMap.entries())
.sort(([a], [b]) => a - b)
.map(([roomNumber, beds]) => ({
room_id: `${a.coach.id}-R${roomNumber}`,
roomNumber,
category: bedCategory,
totalBeds: beds.length,
beds,
}));
return { ...base, rooms, seats: mappedSeats };
}
return { ...base, seats: mappedSeats };
}), }),
}; };
} }
private isBedCoach(coachTypeName: string): boolean {
const n = coachTypeName.toLowerCase();
return n.includes('bed') || n.includes('sleeper') || n.includes('couchette');
}
private getBedCategory(coachTypeName: string, bedsPerRoom?: number): 'ECONOMY_BED' | 'VIP_BED' {
const n = coachTypeName.toLowerCase();
// Explicit VIP name check first
if (n.includes('vip')) return 'VIP_BED';
// Fall back to actual beds-per-room count: 4 = VIP, 6 = Economy
if (bedsPerRoom === 4) return 'VIP_BED';
return 'ECONOMY_BED';
}
// col format: L1, L2, L3, R1, R2, R3
private colToPosition(col: string): 'LEFT' | 'RIGHT' {
return col?.startsWith('R') ? 'RIGHT' : 'LEFT';
}
private bedPositionToType(bedPosition: string | null): 'LOWER' | 'MIDDLE' | 'UPPER' | null {
if (!bedPosition) return null;
const map: Record<string, 'LOWER' | 'MIDDLE' | 'UPPER'> = {
lower: 'LOWER', middle: 'MIDDLE', upper: 'UPPER',
};
return map[bedPosition.toLowerCase()] ?? null;
}
async resolveEffectiveStatuses( async resolveEffectiveStatuses(
scheduleId: string, scheduleId: string,
seatIds: string[], seatIds: string[],
@@ -169,7 +242,8 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length) if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list'); 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 hold = await this.prisma.$transaction(async (tx) => {
const seats = await tx.seat.findMany({ const seats = await tx.seat.findMany({
@@ -426,7 +500,7 @@ export class SeatsService {
// Delete the Journey (and its JourneySegments) scoped to this booking. // Delete the Journey (and its JourneySegments) scoped to this booking.
async releaseSeats(bookingId: string) { async releaseSeats(bookingId: string) {
await this.prisma.journey.deleteMany({ where: { bookingId } }); await this.prisma.journey.deleteMany({ where: { bookingId } as any });
} }
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> { async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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();
}
}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api'; import { agentsApi } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils'; 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() { export default function AgentsPage() {
const [filters, setFilters] = useState({ search: '', active: '' }); const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['agents', filters], queryKey: ['agents', filters],
@@ -30,7 +45,7 @@ export default function AgentsPage() {
render: (agent: any) => ( render: (agent: any) => (
<div> <div>
<div className="font-medium">{agent.user?.fullName || 'N/A'}</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> </div>
), ),
}, },
@@ -51,19 +66,21 @@ export default function AgentsPage() {
]; ];
const actions = [ const actions = [
{
label: 'View Details',
onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const,
icon: Eye,
},
{ {
label: 'View Shifts', label: 'View Shifts',
onClick: (agent: any) => { onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/shifts`; },
window.location.href = `/agents/${agent.id}/shifts`;
},
variant: 'secondary' as const, variant: 'secondary' as const,
icon: Clock, icon: Clock,
}, },
{ {
label: 'View Commissions', label: 'View Commissions',
onClick: (agent: any) => { onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
window.location.href = `/agents/${agent.id}/commissions`;
},
variant: 'secondary' as const, variant: 'secondary' as const,
icon: DollarSign, icon: DollarSign,
}, },
@@ -119,6 +136,97 @@ export default function AgentsPage() {
loading={isLoading} loading={isLoading}
emptyMessage="No agents found" 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> </div>
); );
} }

View File

@@ -252,111 +252,132 @@ export default function AuditLogsPage() {
{/* Details Modal */} {/* Details Modal */}
<Modal <Modal
isOpen={showDetailsModal} isOpen={showDetailsModal}
onClose={() => { onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
setShowDetailsModal(false); title="Audit Log Details"
setSelectedLog(null); size="xl"
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
> >
<div className="space-y-4"> {selectedLog && (() => {
{/* Basic Info */} const l = selectedLog;
<div className="grid grid-cols-2 gap-4"> const actionColor: Record<string, string> = {
<div> CREATE: 'from-emerald-600 to-emerald-700',
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label> UPDATE: 'from-blue-600 to-blue-700',
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p> DELETE: 'from-red-600 to-red-700',
</div> LOGIN: 'from-violet-600 to-violet-700',
<div> LOGOUT: 'from-gray-600 to-gray-700',
<label className="text-xs font-semibold text-muted-foreground">Action</label> };
<p className="text-sm mt-1"> const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
<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>
{/* User Info */} const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
{selectedLog?.user && ( <div className="bg-muted/40 rounded-lg p-3">
<div className="border-t pt-4"> <p className="text-xs text-muted-foreground mb-1">{label}</p>
<h4 className="text-sm font-semibold mb-2">User Information</h4> <p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
<div className="grid grid-cols-2 gap-4"> </div>
<div> );
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p> 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>
<div> <div className="mt-4 grid grid-cols-2 gap-3">
<label className="text-xs font-semibold text-muted-foreground">Email</label> <div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-sm mt-1">{selectedLog?.user?.email}</p> <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> </div>
</div>
)}
{/* Network Info */} <div className="space-y-6">
{(selectedLog?.ipAddress || selectedLog?.userAgent) && ( <section>
<div className="border-t pt-4"> <SectionHeader title="Event Details" />
<h4 className="text-sm font-semibold mb-2">Network Information</h4> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-2"> <Field label="Action" value={l.action} />
{selectedLog?.ipAddress && ( <Field label="Entity Type" value={l.entityType} mono />
<div> <Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
<label className="text-xs font-semibold text-muted-foreground">IP Address</label> <Field label="Timestamp" value={formatDateTime(l.createdAt)} />
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
</div> </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> {(l.ipAddress || l.userAgent) && (
<label className="text-xs font-semibold text-muted-foreground">User Agent</label> <section>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground"> <SectionHeader title="Network Information" />
{selectedLog?.userAgent} <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
</p> <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> </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>
</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> </Modal>
</div> </div>
); );

View File

@@ -2,15 +2,37 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react'; import { AlertTriangle, CheckCircle, Ban, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { fraudApi } from '@/lib/api'; import { fraudApi } from '@/lib/api';
import { formatDateTime } 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 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() { export default function FraudDetectionPage() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' }); const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -40,10 +62,7 @@ export default function FraudDetectionPage() {
const handleBlockUser = async (alert: any) => { const handleBlockUser = async (alert: any) => {
if (confirm(`Block user ${alert.user?.email}?`)) { if (confirm(`Block user ${alert.user?.email}?`)) {
await blockUserMutation.mutateAsync({ await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
userId: alert.userId,
reason: `Fraud alert: ${alert.ruleType}`,
});
} }
}; };
@@ -52,7 +71,7 @@ export default function FraudDetectionPage() {
key: 'severity', key: 'severity',
label: 'Severity', label: 'Severity',
render: (alert: any) => ( 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} {alert.severity}
</Badge> </Badge>
), ),
@@ -62,7 +81,7 @@ export default function FraudDetectionPage() {
label: 'Rule Type', label: 'Rule Type',
render: (alert: any) => ( render: (alert: any) => (
<div className="flex items-center gap-2"> <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> <span>{alert.ruleType}</span>
</div> </div>
), ),
@@ -80,9 +99,7 @@ export default function FraudDetectionPage() {
{ {
key: 'description', key: 'description',
label: 'Description', label: 'Description',
render: (alert: any) => ( render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
<span className="text-sm">{alert.description || alert.details}</span>
),
}, },
{ {
key: 'status', key: 'status',
@@ -102,6 +119,12 @@ export default function FraudDetectionPage() {
]; ];
const actions = [ const actions = [
{
label: 'View Details',
onClick: (alert: any) => setSelected(alert),
variant: 'secondary' as const,
icon: Eye,
},
{ {
label: 'Acknowledge', label: 'Acknowledge',
onClick: handleAcknowledge, onClick: handleAcknowledge,
@@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="label">Search</label> <label className="label">Search</label>
<input <input type="text" placeholder="Search alerts..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
type="text"
placeholder="Search alerts..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div> </div>
<div> <div>
<label className="label">Severity</label> <label className="label">Severity</label>
<select <select className="input" value={filters.severity} onChange={(e) => setFilters({ ...filters, severity: e.target.value })}>
className="input"
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<option value="">All Severities</option> <option value="">All Severities</option>
<option value="LOW">Low</option> <option value="LOW">Low</option>
<option value="MEDIUM">Medium</option> <option value="MEDIUM">Medium</option>
@@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
</div> </div>
<div> <div>
<label className="label">Status</label> <label className="label">Status</label>
<select <select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option> <option value="">All Status</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="acknowledged">Acknowledged</option> <option value="acknowledged">Acknowledged</option>
@@ -174,6 +183,121 @@ export default function FraudDetectionPage() {
loading={isLoading} loading={isLoading}
emptyMessage="No fraud alerts found" 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> </div>
); );
} }

View File

@@ -4,141 +4,295 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store'; import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-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() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isMounted, setIsMounted] = useState(false); const [isMounted, setIsMounted] = useState(false);
const router = useRouter(); const [emailFocused, setEmailFocused] = useState(false);
const { login } = useAuthStore(); const [passwordFocused, setPasswordFocused] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const { isDark, toggleTheme } = useTheme(); const { isDark, toggleTheme } = useTheme();
useEffect(() => { useEffect(() => { setIsMounted(true); }, []);
setIsMounted(true);
}, []);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
await login(email, password); await login(email, password);
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: any) {
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.'; setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
if (!isMounted) { if (!isMounted) return null;
return null;
}
return ( return (
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]"> <div className="flex min-h-screen bg-white dark:bg-gray-950">
{/* Full Screen Banner Background */}
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
{/* Content Overlay */} {/* ── LEFT PANEL — form ── */}
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16"> <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">
<div className="w-full max-w-sm">
{/* Login Card with Shadow */} {/* Top bar */}
<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"> <div className="flex items-center justify-between px-8 py-4 lg:px-10">
{/* Card Header with Logo, App Name and Theme Toggle */} {/* Logo — always visible on the form panel */}
<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-2.5">
<div className="flex items-center gap-3"> <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">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md"> <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<Train className="h-9 w-9 text-white" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8 2 5 5 5 8v8l2 2h10l2-2V8c0-3-3-6-7-6z" />
</div> <path strokeLinecap="round" strokeLinejoin="round" d="M8 17v2M16 17v2M5 12h14" />
<div> <circle cx="9" cy="9" r="1" fill="currentColor" />
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2> <circle cx="15" cy="9" r="1" fill="currentColor" />
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p> </svg>
</div> </div>
</div> <div>
<div className="text-xs font-bold text-gray-900 dark:text-white tracking-wide leading-none">ETHIO-DJIBOUTI</div>
<button <div className="text-[12px] text-gray-400 dark:text-gray-500 tracking-widest uppercase leading-none mt-0.5">Railway</div>
onClick={toggleTheme} </div>
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors" </div>
aria-label="Toggle theme"
> <button
{isDark ? ( onClick={toggleTheme}
<Sun className="w-5 h-5 text-yellow-500" /> 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"
<Moon className="w-5 h-5 text-gray-700" /> >
)} {isDark
</button> ? <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> </div>
{/* Card Body */} {/* Error */}
<div className="p-6"> {error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
<div className="mb-8"> <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">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2> <div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p> <span className="text-white text-[10px] font-bold">!</span>
</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}
</div> </div>
)} <p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div></div>
)}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label> {/* 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 <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => { setEmail(e.target.value); setError(''); }}
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" onFocus={() => setEmailFocused(true)}
placeholder="name@email.com" 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 required
autoComplete="email"
/> />
</div> </div>
</div>
<div> {/* Password field */}
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label> <div>
<div className="relative"> <div className="flex items-center justify-between mb-2">
<input <label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
type={showPassword ? 'text' : 'password'} Password
value={password} </label>
onChange={(e) => setPassword(e.target.value)} <button
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" type="button"
placeholder="••••••••" className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
required >
/> Forgot password?
<button </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>
</div> </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 {/* Submit */}
type="submit" <button
disabled={loading} type="submit"
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" 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"
{loading ? 'Signing in...' : 'Sign in'} style={{ background: loading || !email || !password
</button> ? 'rgb(20,113,76)'
</form> : `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> </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>
</div> </div>
); );

View File

@@ -2,15 +2,44 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { loyaltyApi } from '@/lib/api'; 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() { export default function LoyaltyPage() {
const [filters, setFilters] = useState({ search: '', tier: '' }); const [filters, setFilters] = useState({ search: '', tier: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['loyalty', filters], queryKey: ['loyalty', filters],
@@ -18,11 +47,24 @@ export default function LoyaltyPage() {
}); });
const columns = [ const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, { key: 'passenger', label: 'Passenger', render: (account: any) => (
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> }, <div>
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 }, <div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 }, <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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div> <div>
<div> <label className="label">Tier</label>
<label className="label">Tier</label> <select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}> <option value="">All Tiers</option>
<option value="">All Tiers</option> <option value="BRONZE">Bronze</option>
<option value="BRONZE">Bronze</option> <option value="SILVER">Silver</option>
<option value="SILVER">Silver</option> <option value="GOLD">Gold</option>
<option value="GOLD">Gold</option> <option value="PLATINUM">Platinum</option>
<option value="PLATINUM">Platinum</option> </select>
</select> </div>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={Array.isArray(data) ? data : (data?.items || [])} data={Array.isArray(data) ? data : (data?.items || [])}
columns={columns} columns={columns}
actions={actions}
loading={isLoading} 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> </div>
); );
} }

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react'; import { Download, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable'; import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
@@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api'; import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; 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() { export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' }); const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
@@ -86,6 +100,10 @@ export default function PaymentsPage() {
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) }, { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -129,11 +147,103 @@ export default function PaymentsPage() {
<DataTable <DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])} data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns} columns={columns}
actions={[]} actions={paymentActions}
loading={isLoading} loading={isLoading}
emptyMessage="No payments found" 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 */} {/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md"> <Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -1,11 +1,48 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Save, Users } from 'lucide-react'; import { Save } from 'lucide-react';
import Link from 'next/link'; import { systemConfigApi } from '@/lib/api';
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -14,31 +51,24 @@ export default function SettingsPage() {
<h1 className="text-2xl font-bold text-foreground">Settings</h1> <h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage system settings and configurations</p> <p className="text-muted-foreground">Manage system settings and configurations</p>
</div> </div>
<button className="btn btn-primary flex items-center gap-2"> {activeTab !== 'configurations' && (
<Save className="h-4 w-4" /> <button className="btn btn-primary flex items-center gap-2">
Save Changes <Save className="h-4 w-4" />
</button> Save Changes
</button>
)}
</div> </div>
<div className="flex gap-2 border-b border-border"> <div className="flex gap-2 border-b border-border">
<button {tabs.map((tab) => (
onClick={() => setActiveTab('general')} <button
className={`px-4 py-2 font-medium ${activeTab === 'general' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`} key={tab.id}
> onClick={() => setActiveTab(tab.id)}
General className={`px-4 py-2 font-medium ${activeTab === tab.id ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
</button> >
<button {tab.label}
onClick={() => setActiveTab('payment')} </button>
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>
</div> </div>
{activeTab === 'general' && ( {activeTab === 'general' && (
@@ -139,6 +169,46 @@ export default function SettingsPage() {
</div> </div>
</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> </div>
); );
} }

View File

@@ -23,6 +23,19 @@ export default function TicketsPage() {
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false); const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null); 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 [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState(''); const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState(''); const [exportDateTo, setExportDateTo] = useState('');
@@ -534,111 +547,116 @@ export default function TicketsPage() {
isOpen={detailsModalOpen} isOpen={detailsModalOpen}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }} onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details" title="Ticket Details"
size="lg" size="xl"
> >
{selectedTicket && ( {selectedTicket && (() => {
<div className="space-y-6"> const t = selectedTicket;
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> const b = t.booking;
<div> const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
<p className="text-sm text-muted-foreground">Ticket Number</p> const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p> return (
</div> <div>
<div> {/* Gradient header */}
<p className="text-sm text-muted-foreground">Status</p> <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="mt-1"> <div className="flex items-start justify-between gap-4">
<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">
<div> <div>
<p className="text-sm text-muted-foreground">Leg Status</p> <p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p> <p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div> </div>
<div> <div className="text-right shrink-0">
<p className="text-sm text-muted-foreground">Outbound Boarded</p> <Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p> {t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</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> </div>
</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>
)}
<div className="flex justify-end gap-2 pt-4"> <div className="space-y-6">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}> {/* Booking */}
Close <section>
</ActionButton> <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>
</div> );
)} })()}
</Modal> </Modal>
{/* Export Modal */} {/* Export Modal */}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { verifaydaApi } from '@/lib/api'; 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() { export default function VerifaydaPage() {
const [filters, setFilters] = useState({ search: '', verified: '' }); const [filters, setFilters] = useState({ search: '', verified: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['verifayda', filters], queryKey: ['verifayda', filters],
@@ -18,11 +33,19 @@ export default function VerifaydaPage() {
}); });
const columns = [ const columns = [
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> }, { 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: '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: 'verified', label: 'Status', render: (ver: any) => (
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) }, <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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<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 })} />
<input type="text" placeholder="Search by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div> <div>
<div> <label className="label">Status</label>
<label className="label">Status</label> <select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}> <option value="">All</option>
<option value="">All</option> <option value="true">Verified</option>
<option value="true">Verified</option> <option value="false">Failed</option>
<option value="false">Failed</option> </select>
</select> </div>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={data?.items || data || []} data={data?.items || data || []}
columns={columns} columns={columns}
actions={actions}
loading={isLoading} 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> </div>
); );
} }

View File

@@ -2,15 +2,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge'; import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton'; import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { walletApi } from '@/lib/api'; import { walletApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils'; 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() { export default function WalletPage() {
const [filters, setFilters] = useState({ search: '' }); const [filters, setFilters] = useState({ search: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['wallet', filters], queryKey: ['wallet', filters],
@@ -18,10 +33,25 @@ export default function WalletPage() {
}); });
const columns = [ const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' }, { key: 'passenger', label: 'Passenger', render: (account: any) => (
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') }, <div>
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> }, <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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -35,21 +65,113 @@ export default function WalletPage() {
<div className="card"> <div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<div> <label className="label">Search</label>
<label className="label">Search</label> <input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} /> </div>
</div>
</div> </div>
</div> </div>
<DataTable <DataTable
data={data?.items || data || []} data={data?.items || data || []}
columns={columns} columns={columns}
actions={actions}
loading={isLoading} 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> </div>
); );
} }

View File

@@ -373,3 +373,9 @@ export const reportsApi = {
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] }; 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),
};

View File

@@ -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 { @layer components {
.card { .card {
background-color: hsl(var(--card)); background-color: hsl(var(--card));

View File

@@ -37,4 +37,4 @@ module.exports = {
}, },
}, },
plugins: [], plugins: [],
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -142,8 +142,8 @@ export default function ResultsPage() {
? (outboundSchedules.length > 0 && inboundSchedules.length > 0) ? (outboundSchedules.length > 0 && inboundSchedules.length > 0)
: outboundSchedules.length > 0; : outboundSchedules.length > 0;
const handleSelectCoachType = (scheduleId: string, coachId: string, coachTypeCode: string, coachTypeName: string) => { const handleSelectCoachType = (scheduleId: string, coachTypeCode: string, coachTypeName: string) => {
setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachId, code: coachTypeCode, name: coachTypeName } })); setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeCode, code: coachTypeCode, name: coachTypeName } }));
}; };
const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => {
@@ -156,7 +156,7 @@ export default function ResultsPage() {
} }
// Find the coach type to get pricing info // Find the coach type to get pricing info
const coachType = schedule.coachTypes?.find(ct => ct.coachId === selectedCoachType.id); const coachType = schedule.coachTypes?.find(ct => ct.coachTypeCode === selectedCoachType.id);
const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0; const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map(c => c.baseFareMinor)) : 0;
const hours = Math.floor((schedule.durationMinutes || 0) / 60); const hours = Math.floor((schedule.durationMinutes || 0) / 60);
@@ -175,7 +175,7 @@ export default function ResultsPage() {
baseFareChild: minFare, baseFareChild: minFare,
selectedSeatClass: selectedCoachType.name, selectedSeatClass: selectedCoachType.name,
selectedSeatClassName: selectedCoachType.name, selectedSeatClassName: selectedCoachType.name,
selectedCoachId: selectedCoachType.id, selectedCoachTypeId: selectedCoachType.id,
selectedCoachTypeCode: selectedCoachType.code, selectedCoachTypeCode: selectedCoachType.code,
selectedCoachTypeName: selectedCoachType.name, selectedCoachTypeName: selectedCoachType.name,
}; };
@@ -533,14 +533,14 @@ export default function ResultsPage() {
{coachTypes.length > 0 ? ( {coachTypes.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-2">
{coachTypes.map((coachType: any, index: number) => { {coachTypes.map((coachType: any, index: number) => {
const isSelected = selectedCoachType?.id === coachType.coachId; const isSelected = selectedCoachType?.id === coachType.coachTypeCode;
const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0; const minPrice = coachType.classes.length ? Math.min(...coachType.classes.map((c: any) => c.baseFareMinor)) : 0;
const CoachIcon = getCoachIcon(coachType.coachTypeName); const CoachIcon = getCoachIcon(coachType.coachTypeName);
return ( return (
<button <button
key={coachType.coachId} key={coachType.coachId}
onClick={() => handleSelectCoachType(scheduleId, coachType.coachId, coachType.coachTypeCode, coachType.coachTypeName)} onClick={() => handleSelectCoachType(scheduleId, coachType.coachTypeCode, coachType.coachTypeName)}
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${ className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
isSelected isSelected
? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]' ? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]'

View File

@@ -364,11 +364,10 @@ export default function ReviewPage() {
return null; return null;
} }
const displaySchedule = isRoundTrip ? outboundSchedule : selectedSchedule;
const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0; const outboundBaseFare = isRoundTrip && outboundSchedule ? passengers.reduce((sum) => sum + (outboundSchedule.baseFareAdult || 0), 0) : 0;
const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0; const inboundBaseFare = isRoundTrip && inboundSchedule ? passengers.reduce((sum) => sum + (inboundSchedule.baseFareAdult || 0), 0) : 0;
const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum, _, i) => { const baseFare = isRoundTrip ? (outboundBaseFare + inboundBaseFare) : passengers.reduce((sum) => {
const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0; const farePerPassenger = selectedSchedule?.baseFareAdult || (selectedSchedule as any)?.fareAdult || (selectedSchedule as any)?.price || 0;
return sum + farePerPassenger; return sum + farePerPassenger;
}, 0); }, 0);

File diff suppressed because it is too large Load Diff

21567
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff