mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,18 +1 @@
|
||||
-- Remove duplicate JourneySegment rows, keeping the one with the lowest id
|
||||
-- (earliest created) per (scheduleId, seatId, departureStationId) group.
|
||||
-- This cleans up any existing double-bookings before the unique index is applied.
|
||||
DELETE FROM passenger."JourneySegment"
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM passenger."JourneySegment"
|
||||
WHERE "seatId" IS NOT NULL
|
||||
GROUP BY "scheduleId", "seatId", "departureStationId"
|
||||
)
|
||||
AND "seatId" IS NOT NULL;
|
||||
|
||||
-- Prevents two confirmed bookings from occupying the same seat on the same
|
||||
-- schedule hop — the hard DB backstop against application-level race conditions.
|
||||
-- Partial index: seatId IS NOT NULL excludes free-child rows that have no seat.
|
||||
CREATE UNIQUE INDEX "JourneySegment_scheduleId_seatId_departureStationId_key"
|
||||
ON passenger."JourneySegment" ("scheduleId", "seatId", "departureStationId")
|
||||
WHERE "seatId" IS NOT NULL;
|
||||
-- Migration already applied directly to the database.
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
-- Rename StopStatus enum values to reflect segment-level booking lifecycle.
|
||||
-- UPCOMING → OPEN (segment is bookable)
|
||||
-- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings)
|
||||
-- CURRENT → BOARDED (train has departed this stop)
|
||||
-- COMPLETED stays as-is
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN';
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED';
|
||||
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED';
|
||||
|
||||
-- Add per-route check-in window. Each route can define how many minutes before
|
||||
-- a stop's planned departure check-in is closed. Defaults to 30 minutes.
|
||||
ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30;
|
||||
-- Migration already applied directly to the database.
|
||||
|
||||
@@ -1 +1 @@
|
||||
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER;
|
||||
-- Migration already applied directly to the database.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Migration already applied directly to the database.
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[scheduleId,seatId,departureStationId]` on the table `JourneySegment` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- Deduplicate before applying the unique index.
|
||||
-- Keeps the row with the lowest id per (scheduleId, seatId, departureStationId) group.
|
||||
DELETE FROM passenger."JourneySegment"
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id)
|
||||
FROM passenger."JourneySegment"
|
||||
WHERE "seatId" IS NOT NULL
|
||||
GROUP BY "scheduleId", "seatId", "departureStationId"
|
||||
)
|
||||
AND "seatId" IS NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "JourneySegment_scheduleId_seatId_departureStationId_key"
|
||||
ON "JourneySegment"("scheduleId", "seatId", "departureStationId");
|
||||
@@ -580,7 +580,7 @@ model BookingSeat {
|
||||
bookingId String
|
||||
seatId String
|
||||
leg Int @default(1) // 1=outbound/leg-1, 2=return/leg-2
|
||||
scheduleId String? // which schedule this seat belongs to
|
||||
scheduleId String // which schedule this seat belongs to
|
||||
passengerName String
|
||||
dateOfBirth DateTime?
|
||||
passengerCategory PassengerCategory @default(ADULT)
|
||||
@@ -599,6 +599,7 @@ model BookingSeat {
|
||||
displayFareMinor Int?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
@@unique([scheduleId, seatId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export class AgentsService {
|
||||
seats: {
|
||||
create: dto.passengers.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.fullName,
|
||||
idDocumentType: p.idDocumentType as IdDocumentType | undefined,
|
||||
idDocumentNumber: p.idDocumentNumber
|
||||
|
||||
@@ -320,8 +320,8 @@ export class BookingsService {
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalMinor: b.totalMinor,
|
||||
currency: b.currency || null,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||
currency: b.displayCurrency ?? b.currency ?? null,
|
||||
displayCurrency: b.displayCurrency ?? null,
|
||||
displayTotalMinor: b.displayTotalMinor ?? null,
|
||||
adultCount: b.adultCount,
|
||||
@@ -578,7 +578,7 @@ export class BookingsService {
|
||||
|
||||
const mappedPkg = pkgItems.map((b: any) => ({
|
||||
id: b.id, bookingRef: b.bookingRef, status: b.status,
|
||||
totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency,
|
||||
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||
@@ -732,8 +732,8 @@ export class BookingsService {
|
||||
id: b.id,
|
||||
bookingRef: b.bookingRef,
|
||||
status: b.status,
|
||||
totalMinor: b.totalMinor,
|
||||
currency: b.currency || b.displayCurrency,
|
||||
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||
currency: b.displayCurrency || b.currency,
|
||||
displayCurrency: b.displayCurrency,
|
||||
displayTotalMinor: b.displayTotalMinor,
|
||||
contactEmail: b.contactEmail,
|
||||
@@ -866,6 +866,15 @@ export class BookingsService {
|
||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
// For package bookings where per-seat fares weren't supplied, back-derive the
|
||||
// per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the
|
||||
// actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
|
||||
if (dto.packageId && seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
// seatFareMinor is in display currency — sum is already the display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
@@ -901,6 +910,7 @@ export class BookingsService {
|
||||
seats: {
|
||||
create: passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
@@ -1050,6 +1060,19 @@ export class BookingsService {
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||
: dto.reviewedTotalMinor;
|
||||
// For package bookings where per-seat fares weren't supplied, back-derive the
|
||||
// per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects
|
||||
// the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
|
||||
if (dto.packageId) {
|
||||
const seatedCount = passengersData.filter(p => p.outboundSeatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (allRTFaresProvided && !dto.packageId) {
|
||||
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
@@ -1822,8 +1845,8 @@ export class BookingsService {
|
||||
id: pkgBooking.id,
|
||||
bookingRef: pkgBooking.bookingRef,
|
||||
status: pkgBooking.status,
|
||||
totalMinor: pkgBooking.totalMinor,
|
||||
currency: pkgBooking.currency || pkgBooking.displayCurrency,
|
||||
totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor,
|
||||
currency: pkgBooking.displayCurrency || pkgBooking.currency,
|
||||
adultCount: pkgBooking.passengerCount,
|
||||
childCount: 0,
|
||||
displayCurrency: pkgBooking.displayCurrency,
|
||||
@@ -1852,7 +1875,7 @@ export class BookingsService {
|
||||
fullName: p.passengerName,
|
||||
category: 'ADULT',
|
||||
leg: 1,
|
||||
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
|
||||
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
|
||||
verifaydaVerified: false,
|
||||
seat: null,
|
||||
})),
|
||||
|
||||
@@ -231,6 +231,14 @@ export class GuestBookingService {
|
||||
let resolvedTotalMinor: number;
|
||||
if (dto.reviewedTotalMinor != null) {
|
||||
displayTotalMinor = dto.reviewedTotalMinor;
|
||||
// For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor
|
||||
// so BookingSeat records store the actual berth price, not the tier minimum.
|
||||
if (isPackageOneway && seatedPassengers.length > 0) {
|
||||
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
|
||||
});
|
||||
}
|
||||
} else if (allFaresProvided) {
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||
} else {
|
||||
@@ -291,6 +299,7 @@ export class GuestBookingService {
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
passengerCategory: p.category,
|
||||
@@ -515,6 +524,17 @@ export class GuestBookingService {
|
||||
totalMinor = displayCurrency !== Currency.ETB
|
||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||
: displayTotalMinor;
|
||||
// For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor.
|
||||
if (isPackageRoundTrip) {
|
||||
const seatedCount = passengersData.filter(p => p.seatId).length;
|
||||
if (seatedCount > 0) {
|
||||
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
|
||||
passengersWithFares.forEach(p => {
|
||||
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
|
||||
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
|
||||
@ApiTags('Dashboard')
|
||||
@Controller('dashboard')
|
||||
@@ -10,7 +11,7 @@ export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
|
||||
@Get('backoffice-stats')
|
||||
@PassengerAdmin()
|
||||
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||
|
||||
@@ -11,12 +11,13 @@ export class DashboardService {
|
||||
) {}
|
||||
|
||||
async getBackofficeStats() {
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||
await Promise.all([
|
||||
this.prisma.booking.count(),
|
||||
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
||||
this.prisma.ticket.count(),
|
||||
this.prisma.passenger.count(),
|
||||
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
|
||||
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||
SELECT
|
||||
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||
@@ -56,6 +57,7 @@ export class DashboardService {
|
||||
totalPackageTickets,
|
||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||
totalPassengers,
|
||||
blockedSeatsCount,
|
||||
revenueByCurrency: toMap(revenueRows),
|
||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||
};
|
||||
|
||||
@@ -444,8 +444,8 @@ export class PackagesService {
|
||||
passengerCount,
|
||||
adultCount,
|
||||
childCount,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
status: 'PENDING_PAYMENT',
|
||||
|
||||
@@ -12,8 +12,15 @@ import {
|
||||
PaymentIntentSnapshot,
|
||||
PaymentReferenceType,
|
||||
PaymentService,
|
||||
ProviderStatus,
|
||||
} from "@edr/types";
|
||||
|
||||
/** Side-by-side DB row + live provider status from the payment service diagnostic endpoints. */
|
||||
export interface PaymentDiagnostic {
|
||||
db: Record<string, unknown> | null;
|
||||
provider: ProviderStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the payment microservice (apps/edr-payment-api) — the passenger app's
|
||||
* side of the Phase 6 cutover (docs/payment-service §10). Domain validation stays here;
|
||||
@@ -55,6 +62,28 @@ export class PaymentClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /payments/diagnostic?… — DB intent row + live provider status for a domain reference,
|
||||
* side by side. Returns { db: null, provider: null } when the payment service has no intent.
|
||||
*/
|
||||
async getDiagnosticByReference(
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<PaymentDiagnostic> {
|
||||
const query = new URLSearchParams({
|
||||
service: PaymentService.PASSENGER,
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
try {
|
||||
return await this.call("GET", `/payments/diagnostic?${query.toString()}`);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404)
|
||||
return { db: null, provider: null };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider (CAC Bank).
|
||||
* A wrong/expired OTP comes back as 400 from the payment service; surface that as a
|
||||
|
||||
@@ -133,6 +133,33 @@ export class PaymentsController {
|
||||
return this.service.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
@Get("status/:bookingRefOrId")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary: "Get payment status by booking id or booking reference (PNR)",
|
||||
description:
|
||||
"Accepts either a booking UUID or a booking reference / PNR (e.g. EDR-20240001), " +
|
||||
"resolves it to the booking, and returns the authoritative payment status pulled from " +
|
||||
"the payment microservice.",
|
||||
})
|
||||
getStatusByBookingRefOrId(@Param("bookingRefOrId") bookingRefOrId: string) {
|
||||
return this.service.getIntentByBookingRefOrId(bookingRefOrId);
|
||||
}
|
||||
|
||||
@Get("diagnostic/:bookingRefOrId")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Get { db, provider } by booking id or booking reference (PNR) — diagnostic",
|
||||
description:
|
||||
"Accepts a booking UUID or a booking reference / PNR (e.g. EDR-20240001), resolves it to " +
|
||||
"the booking, and returns { db, provider }: the payment service's stored intent row and a " +
|
||||
"live provider status query, side by side. Pure read — does not reconcile the booking.",
|
||||
})
|
||||
getPaymentDiagnostic(@Param("bookingRefOrId") bookingRefOrId: string) {
|
||||
return this.service.getPaymentDiagnosticByBookingRefOrId(bookingRefOrId);
|
||||
}
|
||||
|
||||
@Post(":bookingId/confirm")
|
||||
@SetMetadata("isPublic", true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -118,6 +118,7 @@ describe("Payments E2E", () => {
|
||||
data: {
|
||||
bookingId: booking.id,
|
||||
seatId: seat.id,
|
||||
scheduleId: schedule.id,
|
||||
passengerName: "Test Passenger",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import {
|
||||
PaymentClientService,
|
||||
PaymentDiagnostic,
|
||||
} from "./payment-client.service";
|
||||
import { CurrencyService } from "../currency/currency.service";
|
||||
import { AuditService } from "../../common/audit.service";
|
||||
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
|
||||
@@ -534,6 +537,51 @@ export class PaymentsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
|
||||
* Resolves the PNR to its booking id, then pulls the authoritative status from the payment
|
||||
* microservice (via {@link getIntentByBookingId}).
|
||||
*/
|
||||
async getIntentByBookingRefOrId(
|
||||
bookingRefOrId: string,
|
||||
): Promise<IntentStatusDto> {
|
||||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||||
return this.getIntentByBookingId(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
|
||||
* stored intent row and a live provider status query, side by side ({ db, provider }).
|
||||
* Pure read — does not reconcile or confirm the booking.
|
||||
*/
|
||||
async getPaymentDiagnosticByBookingRefOrId(
|
||||
bookingRefOrId: string,
|
||||
): Promise<PaymentDiagnostic> {
|
||||
const bookingId = await this.resolveBookingId(bookingRefOrId);
|
||||
return this.paymentClient.getDiagnosticByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
bookingId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
|
||||
private async resolveBookingId(bookingRefOrId: string): Promise<string> {
|
||||
const isUuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
bookingRefOrId,
|
||||
);
|
||||
if (isUuid) return bookingRefOrId;
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: bookingRefOrId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
|
||||
}
|
||||
return booking.id;
|
||||
}
|
||||
|
||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
||||
const local = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId },
|
||||
|
||||
@@ -18,6 +18,12 @@ export class ReportsController {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
|
||||
@@ -191,6 +191,122 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getOccupancyBySchedule(scheduleId: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
include: {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
train: true,
|
||||
coachAssignments: {
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
include: {
|
||||
seats: {
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
|
||||
const totalPassengers = allBookingSeats.length;
|
||||
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
// Per-coach breakdown
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const c = assignment.coach;
|
||||
coachMap.set(c.id, {
|
||||
coachNumber: c.number,
|
||||
coachType: (c as any).coachType?.name ?? 'Unknown',
|
||||
totalSeats: c.seats.length,
|
||||
booked: 0,
|
||||
});
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const coachId = bs.seat?.coachId;
|
||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||
}
|
||||
const byCoach = [...coachMap.values()].map(c => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Per-origin station breakdown (using booking's originStationId)
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).originStation?.name
|
||||
?? stationId;
|
||||
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
|
||||
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
// Per-destination station breakdown
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).destinationStation?.name
|
||||
?? stationId;
|
||||
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
|
||||
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
|
||||
// Per-class breakdown
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
classMap.get(typeName)!.booked++;
|
||||
}
|
||||
const byClass = [...classMap.values()].map(c => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
origin: (schedule as any).originStation?.name,
|
||||
destination: (schedule as any).destinationStation?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
arrivalAt: schedule.arrivalAt,
|
||||
},
|
||||
summary: { totalSeats, totalPassengers, occupancyRate },
|
||||
byCoach,
|
||||
byClass,
|
||||
byOrigin,
|
||||
byDestination,
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
}
|
||||
|
||||
@@ -54,11 +54,16 @@ export class CreateScheduleDto {
|
||||
plannedTimes?: PlannedStopTimeDto[];
|
||||
}
|
||||
|
||||
export class CoachAssignmentDto {
|
||||
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
|
||||
@ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
@ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[];
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ type: [PlannedStopTimeDto], description: 'Planned times per stop — when provided, replaces all existing stop times for the schedule' })
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto)
|
||||
|
||||
@@ -535,8 +535,8 @@ export class SearchService {
|
||||
discountMinor: fare.discountMinor,
|
||||
taxesFeesMinor: 0,
|
||||
loyaltyRedemptionMinor: loyaltyMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
@@ -653,8 +653,8 @@ export class SearchService {
|
||||
passengers: passengerLines,
|
||||
subtotalMinor,
|
||||
discountMinor,
|
||||
totalMinor,
|
||||
currency: 'ETB',
|
||||
totalMinor: displayTotalMinor,
|
||||
currency: displayCurrency,
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
};
|
||||
|
||||
@@ -1020,7 +1020,7 @@ export class SeatsService {
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId: schedule.id },
|
||||
{ scheduleId: null, booking: { scheduleId: schedule.id } },
|
||||
{ booking: { scheduleId: schedule.id } },
|
||||
],
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
|
||||
@@ -53,6 +53,7 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'originStationId', required: false })
|
||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'departureDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@@ -64,6 +65,7 @@ export class TicketsController {
|
||||
@Query('originStationId') originStationId?: string,
|
||||
@Query('destinationStationId') destinationStationId?: string,
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('departureDate') departureDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@@ -76,6 +78,7 @@ export class TicketsController {
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
@@ -41,10 +41,16 @@ export class TicketsService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters.originStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
||||
where.booking = { ...where.booking, originStationId: filters.originStationId };
|
||||
}
|
||||
if (filters.destinationStationId) {
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
||||
where.booking = { ...where.booking, destinationStationId: filters.destinationStationId };
|
||||
}
|
||||
if (filters.departureDate) {
|
||||
const start = new Date(filters.departureDate);
|
||||
const end = new Date(filters.departureDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } };
|
||||
}
|
||||
if (filters.arrivalDate) {
|
||||
const start = new Date(filters.arrivalDate);
|
||||
@@ -70,7 +76,7 @@ export class TicketsService {
|
||||
include: {
|
||||
booking: {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
passenger: { include: { travelerProfiles: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
@@ -146,6 +152,18 @@ export class TicketsService {
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
originStation: (() => {
|
||||
const id = t.booking?.originStationId;
|
||||
if (!id) return t.booking?.schedule?.originStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
||||
})(),
|
||||
destinationStation: (() => {
|
||||
const id = t.booking?.destinationStationId;
|
||||
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
||||
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
||||
})(),
|
||||
},
|
||||
schedule: t.booking?.schedule,
|
||||
seat: t.seat ? {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
|
||||
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
@@ -99,7 +99,8 @@ function DashboardPageContent() {
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
retry: 1,
|
||||
staleTime: 60000,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
@@ -143,10 +144,20 @@ function DashboardPageContent() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/boarding"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
title="Boarding / Gate Scan"
|
||||
>
|
||||
<ScanLine className="h-4 w-4 text-[rgb(20,113,76)]" />
|
||||
<span className="hidden sm:inline">Boarding</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{statsError && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../../dashboard/layout';
|
||||
|
||||
export default function PassengersLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
||||
|
||||
function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) {
|
||||
return (
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<div className={`rounded-lg p-1.5 ${color}`}><Icon className="h-4 w-4" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
|
||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PassengersReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
|
||||
const { data: schedules = [] } = useQuery<any[]>({
|
||||
queryKey: ['schedules-list'],
|
||||
queryFn: () => apiClient.get('/schedules'),
|
||||
select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []),
|
||||
});
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['occupancy-report', scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const report = data as any;
|
||||
|
||||
const doExport = () => {
|
||||
if (!report) return;
|
||||
const rows = [
|
||||
['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'],
|
||||
...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]),
|
||||
];
|
||||
const csv = rows.map(r => r.map((v: any) => `"${v}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
|
||||
<p className="text-muted-foreground mt-1">Select a schedule to view passenger occupancy breakdown</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule Selector */}
|
||||
<div className="card">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<label className="label">Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
>
|
||||
<option value="">— Select a schedule —</option>
|
||||
{schedules.map((s: any) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.train?.name ?? s.train?.number ?? 'Train'} · {s.originStation?.name} → {s.destinationStation?.name} · {s.departureAt ? new Date(s.departureAt).toLocaleString() : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading…</p>}
|
||||
{report && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetching && (
|
||||
<div className="card py-12 text-center text-muted-foreground">Loading passengers data…</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* Schedule Info */}
|
||||
<div className="card flex items-center gap-4">
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
|
||||
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">{report.schedule.trainName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report.schedule.origin} → {report.schedule.destination} · Departure: {formatDateTime(report.schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label="Total Seats"
|
||||
value={report.summary.totalSeats}
|
||||
icon={Armchair}
|
||||
color="bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Passengers"
|
||||
value={report.summary.totalPassengers}
|
||||
icon={Users}
|
||||
color="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Occupancy Rate"
|
||||
value={`${report.summary.occupancyRate}%`}
|
||||
sub={`${report.summary.totalSeats - report.summary.totalPassengers} seats available`}
|
||||
icon={TrendingUp}
|
||||
color="bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* By Coach */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Occupancy by Coach</h3>
|
||||
{report.byCoach.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={report.byCoach} layout="vertical" margin={{ left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="coachNumber" tick={{ fontSize: 11 }} width={56} tickFormatter={(v) => `Coach ${v}`} />
|
||||
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
|
||||
<Bar dataKey="occupancyRate" radius={[0, 3, 3, 0]}>
|
||||
{report.byCoach.map((_: any, i: number) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<table className="w-full mt-3 text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||
<th className="text-left py-1.5 font-medium">Coach</th>
|
||||
<th className="text-left py-1.5 font-medium">Type</th>
|
||||
<th className="text-right py-1.5 font-medium">Booked</th>
|
||||
<th className="text-right py-1.5 font-medium">Total</th>
|
||||
<th className="text-right py-1.5 font-medium">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byCoach.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-1.5 font-mono font-semibold">Coach {c.coachNumber}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{c.coachType}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No coach data</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By Class */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Occupancy by Class</h3>
|
||||
{report.byClass.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={report.byClass}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="className" tick={{ fontSize: 11 }} />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
|
||||
<Bar dataKey="occupancyRate" radius={[3, 3, 0, 0]}>
|
||||
{report.byClass.map((_: any, i: number) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<table className="w-full mt-3 text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||
<th className="text-left py-1.5 font-medium">Class</th>
|
||||
<th className="text-right py-1.5 font-medium">Booked</th>
|
||||
<th className="text-right py-1.5 font-medium">Total</th>
|
||||
<th className="text-right py-1.5 font-medium">Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byClass.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-1.5 font-medium">{c.className}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No class data</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By Origin */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Passengers by Boarding Station</h3>
|
||||
{report.byOrigin.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||
<th className="text-left py-1.5 font-medium">Station</th>
|
||||
<th className="text-right py-1.5 font-medium">Passengers</th>
|
||||
<th className="text-right py-1.5 font-medium">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byOrigin.map((o: any, i: number) => {
|
||||
const pct = report.summary.totalPassengers > 0
|
||||
? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1)
|
||||
: '0';
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
|
||||
{o.stationName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums font-semibold">{o.passengers}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No boarding station data</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* By Destination */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Passengers by Alighting Station</h3>
|
||||
{report.byDestination.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||
<th className="text-left py-1.5 font-medium">Station</th>
|
||||
<th className="text-right py-1.5 font-medium">Passengers</th>
|
||||
<th className="text-right py-1.5 font-medium">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byDestination.map((d: any, i: number) => {
|
||||
const pct = report.summary.totalPassengers > 0
|
||||
? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1)
|
||||
: '0';
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
|
||||
{d.stationName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums font-semibold">{d.passengers}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No alighting station data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!report && !isFetching && scheduleId && (
|
||||
<div className="card py-12 text-center text-muted-foreground">No data found for this schedule.</div>
|
||||
)}
|
||||
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the occupancy report</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
@@ -46,6 +47,12 @@ export default function SeatStatusReportPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
queryKey: ['seat-report-bookings'],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
@@ -150,7 +157,7 @@ export default function SeatStatusReportPage() {
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -189,6 +196,19 @@ export default function SeatStatusReportPage() {
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{stats?.blockedSeatsCount ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Globally blocked</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -71,6 +71,7 @@ export default function TicketsPage() {
|
||||
status: filters.status || undefined,
|
||||
originStationId: filters.originStationId || undefined,
|
||||
destinationStationId: filters.destinationStationId || undefined,
|
||||
departureDate: filters.departureDate || undefined,
|
||||
arrivalDate: filters.arrivalDate || undefined,
|
||||
dateFrom: filters.dateFrom || undefined,
|
||||
dateTo: filters.dateTo || undefined,
|
||||
@@ -358,11 +359,13 @@ export default function TicketsPage() {
|
||||
render: (ticket: any) => {
|
||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
const returnDeparture = ticket.booking?.returnSchedule?.departureAt;
|
||||
const origin = ticket.booking?.originStation?.name || ticket.schedule?.originStation?.name || 'N/A';
|
||||
const destination = ticket.booking?.destinationStation?.name || ticket.schedule?.destinationStation?.name || 'N/A';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
||||
{origin} → {destination}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{!isRoundTrip ? (
|
||||
@@ -551,7 +554,7 @@ export default function TicketsPage() {
|
||||
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<div>
|
||||
<label className="label">Search</label>
|
||||
<input
|
||||
@@ -589,36 +592,14 @@ export default function TicketsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Arrival Date</label>
|
||||
<label className="label">Departure Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={filters.arrivalDate}
|
||||
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
|
||||
value={filters.departureDate}
|
||||
onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mt-3">
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="USED">Used</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Coach</label>
|
||||
<select
|
||||
@@ -632,6 +613,28 @@ export default function TicketsPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
|
||||
onClick={() => setShowExtraFilters(v => !v)}>
|
||||
{showExtraFilters ? 'Hide Filters ▲' : 'More Filters ▼'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showExtraFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="USED">Used</option>
|
||||
<option value="CANCELLED">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Issued From</label>
|
||||
<input type="date" className="input" value={filters.dateFrom}
|
||||
@@ -794,8 +797,8 @@ export default function TicketsPage() {
|
||||
<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="Origin" value={t.booking?.originStation?.name || t.schedule?.originStation?.name} />
|
||||
<Field label="Destination" value={t.booking?.destinationStation?.name || 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} />
|
||||
|
||||
@@ -123,6 +123,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
items: [
|
||||
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export const dashboardApi = {
|
||||
totalNormalTickets: number;
|
||||
totalPackageTickets: number;
|
||||
totalPassengers: number;
|
||||
blockedSeatsCount: number;
|
||||
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
}>('/dashboard/backoffice-stats');
|
||||
|
||||
@@ -949,10 +949,9 @@ export default function SeatsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// No fare change — but for package bookings still sync the tier price to the
|
||||
// actual berth fare (handles the case where the first seat picked matches the
|
||||
// stored price but we still want it explicitly confirmed).
|
||||
if (isPackageBooking && packageId && newFare !== packageTierPriceMinor) {
|
||||
// No fare change — for package bookings still sync the tier price to the
|
||||
// actual berth fare so the correct amount is always stored.
|
||||
if (isPackageBooking && packageId) {
|
||||
setPackageContext(
|
||||
packageId,
|
||||
priceTierId ?? '',
|
||||
@@ -962,6 +961,8 @@ export default function SeatsPage() {
|
||||
packageDepartureStationName ?? undefined,
|
||||
);
|
||||
}
|
||||
} else if (isPackageBooking && packageId) {
|
||||
// newFare is null (seat class data unavailable) — leave stored price as-is.
|
||||
}
|
||||
|
||||
commitSeatAssignment(seatId);
|
||||
|
||||
@@ -401,7 +401,6 @@ const PKG_CHILDREN_PER_ADULT = 5;
|
||||
|
||||
function PassengerCountModal({
|
||||
tier,
|
||||
minPriceMinor,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
@@ -410,7 +409,6 @@ function PassengerCountModal({
|
||||
stations,
|
||||
}: {
|
||||
tier: PriceTier;
|
||||
minPriceMinor: number;
|
||||
onClose: () => void;
|
||||
onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void;
|
||||
loading: boolean;
|
||||
@@ -423,11 +421,9 @@ function PassengerCountModal({
|
||||
const [departureStationId, setDepartureStationId] = useState('');
|
||||
const [showStationError, setShowStationError] = useState(false);
|
||||
const remaining = tier.availableSeats - tier.bookedSeats;
|
||||
// First child per adult travels free (no seat); additional children pay full adult fare
|
||||
const freeChildren = Math.min(childCount, adultCount);
|
||||
const paidChildren = Math.max(0, childCount - adultCount);
|
||||
// Only paid children need seats; free children share with an adult
|
||||
const totalMinor = (adultCount * minPriceMinor + paidChildren * minPriceMinor) * priceMultiplier;
|
||||
const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -444,7 +440,7 @@ function PassengerCountModal({
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.name ?? tier.label.trim()}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
@@ -646,6 +642,8 @@ export default function PackageDetailPage() {
|
||||
);
|
||||
|
||||
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
||||
// Store the group's cheapest tier price as a placeholder — the actual berth fare
|
||||
// (Upper/Middle/Lower) will be resolved and saved when the user picks a seat.
|
||||
setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName);
|
||||
|
||||
router.push("/booking/passengers");
|
||||
@@ -696,7 +694,6 @@ export default function PackageDetailPage() {
|
||||
{passengerModalOpen && representativeTier && (
|
||||
<PassengerCountModal
|
||||
tier={representativeTier}
|
||||
minPriceMinor={selectedGroup?.minPrice ?? representativeTier.priceMinor}
|
||||
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
|
||||
onConfirm={handleBookNow}
|
||||
loading={bookingContextLoading}
|
||||
@@ -896,7 +893,7 @@ export default function PackageDetailPage() {
|
||||
|
||||
{/* ── Sidebar — desktop ── */}
|
||||
<div className="hidden lg:block">
|
||||
<div className="sticky top-20">
|
||||
<div>
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
||||
|
||||
@@ -8,8 +8,12 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { PaymentIntentSnapshot } from "@edr/types";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
PaymentIntentSnapshot,
|
||||
ProviderMethod,
|
||||
ProviderStatus,
|
||||
} from "@edr/types";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import {
|
||||
InitiatePaymentRequestDto,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
} from "./dto/initiate-payment.dto";
|
||||
import { ConfirmPaymentDto } from "./dto/confirm-payment.dto";
|
||||
import { IntentsService } from "./intents.service";
|
||||
import { PaymentIntent } from "./entities/payment-intent.entity";
|
||||
|
||||
/**
|
||||
* Internal surface — called only by the domain apps (service-authenticated), never by
|
||||
@@ -68,6 +73,41 @@ export class IntentsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("diagnostic")
|
||||
@ApiOperation({
|
||||
summary: "DB row + live provider status by domain reference (diagnostic)",
|
||||
description:
|
||||
"Returns { db, provider } for a domain reference (service + referenceType + referenceId): " +
|
||||
"the active stored intent row and a live provider status query, side by side. Pure read — " +
|
||||
"does not mutate the intent. `db` is null when no active intent exists for the reference.",
|
||||
})
|
||||
async getDiagnosticByReference(
|
||||
@Query() query: IntentReferenceQueryDto,
|
||||
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
|
||||
return this.intentsService.getDiagnosticByReference(
|
||||
query.service,
|
||||
query.referenceType,
|
||||
query.referenceId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("by-merchant-order/:merchantOrderId")
|
||||
@ApiOperation({
|
||||
summary: "DB row + live provider status by merchant order id (diagnostic)",
|
||||
description:
|
||||
"Returns { db, provider } for a provider-facing merchant order id (PSG-/FRT-…): the " +
|
||||
"stored intent row and a live provider status query, side by side. Pure read — does not " +
|
||||
"mutate the intent. `db` is null when no intent has this merchant order id; supply " +
|
||||
"`?provider=` in that case so the provider can still be queried by merchant order id.",
|
||||
})
|
||||
@ApiQuery({ name: "provider", enum: ProviderMethod, required: false })
|
||||
async getByMerchantOrderId(
|
||||
@Param("merchantOrderId") merchantOrderId: string,
|
||||
@Query("provider") provider?: ProviderMethod,
|
||||
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
|
||||
return this.intentsService.getByMerchantOrderId(merchantOrderId, provider);
|
||||
}
|
||||
|
||||
@Post("intents/:id/confirm")
|
||||
@ApiOperation({
|
||||
summary: "Confirm an OTP-based payment intent (e.g. CAC Bank)",
|
||||
|
||||
@@ -341,6 +341,90 @@ export class IntentsService {
|
||||
return this.toSnapshot(await this.refreshIfStale(intent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic lookup by domain reference (service + referenceType + referenceId). Returns the
|
||||
* stored intent AND a LIVE provider status query side by side — the reference-keyed twin of
|
||||
* {@link getByMerchantOrderId}, used by the domain apps to resolve a booking/shipment without
|
||||
* knowing the merchant order id. Pure read (no state-machine mutation).
|
||||
*
|
||||
* - `db`: the active stored intent for the reference, or `null` when none exists.
|
||||
* - `provider`: the raw provider status response (queried using the intent's own provider), or
|
||||
* `null` when there is no intent or the query fails.
|
||||
*/
|
||||
async getDiagnosticByReference(
|
||||
service: PaymentService,
|
||||
referenceType: PaymentReferenceType,
|
||||
referenceId: string,
|
||||
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
|
||||
const intent = await this.intentsRepository.findActiveByReference(
|
||||
service,
|
||||
referenceType,
|
||||
referenceId,
|
||||
);
|
||||
const providerStatus = intent
|
||||
? await this.queryProviderForMerchantOrder(
|
||||
intent.merchantOrderId,
|
||||
intent,
|
||||
undefined,
|
||||
)
|
||||
: null;
|
||||
return { db: intent ?? null, provider: providerStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic lookup by provider-facing merchant order id (PSG-/FRT-…). Returns the stored
|
||||
* intent AND a LIVE provider status query side by side, so the caller can compare what the
|
||||
* platform believes against what the provider currently reports. This is a pure read — it
|
||||
* does NOT mutate the intent (no state-machine transition, no outbox event).
|
||||
*
|
||||
* - `db`: the full stored intent row, or `null` when no intent has this merchant order id.
|
||||
* - `provider`: the raw provider status response. When there is a DB row its provider is
|
||||
* used; when there is no DB row a `providerHint` must be supplied to know which provider
|
||||
* to ask (the merchant-order prefix only identifies the service). `null` if the provider
|
||||
* is unknown or the query fails.
|
||||
*/
|
||||
async getByMerchantOrderId(
|
||||
merchantOrderId: string,
|
||||
providerHint?: ProviderMethod,
|
||||
): Promise<{ db: PaymentIntent | null; provider: ProviderStatus | null }> {
|
||||
const intent =
|
||||
await this.intentsRepository.findByMerchantOrderId(merchantOrderId);
|
||||
|
||||
const providerStatus = await this.queryProviderForMerchantOrder(
|
||||
merchantOrderId,
|
||||
intent,
|
||||
providerHint,
|
||||
);
|
||||
|
||||
return { db: intent ?? null, provider: providerStatus };
|
||||
}
|
||||
|
||||
/** Best-effort live provider status for a merchant order id; never throws (returns null). */
|
||||
private async queryProviderForMerchantOrder(
|
||||
merchantOrderId: string,
|
||||
intent: PaymentIntent | null,
|
||||
providerHint?: ProviderMethod,
|
||||
): Promise<ProviderStatus | null> {
|
||||
try {
|
||||
if (intent) {
|
||||
const provider = this.providers.get(intent.provider);
|
||||
if (!provider) return null;
|
||||
return await this.queryProviderStatus(intent);
|
||||
}
|
||||
// No DB row — fall back to the caller-supplied provider hint keyed on merchantOrderId.
|
||||
if (!providerHint) return null;
|
||||
const provider = this.providers.get(providerHint);
|
||||
if (!provider) return null;
|
||||
return await provider.queryStatus(merchantOrderId);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`provider status query failed for merchantOrderId ${merchantOrderId}: ${message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull-side reconciliation: when a polled intent is non-terminal and stale, ask the
|
||||
* provider for the truth and run the answer through the state machine. The browser
|
||||
|
||||
530
booking-checker.html
Normal file
530
booking-checker.html
Normal file
@@ -0,0 +1,530 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Booking Checker</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 0.4rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
input[type="text"], textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
input[type="text"]:focus, textarea:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16,185,129,0.15);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 140px;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||
|
||||
button {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.65rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #059669; }
|
||||
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
||||
|
||||
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||
|
||||
#status { font-size: 0.85rem; color: #64748b; }
|
||||
|
||||
.progress-bar-wrap {
|
||||
width: 100%;
|
||||
background: #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
height: 6px;
|
||||
margin-top: 0.75rem;
|
||||
display: none;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #10b981;
|
||||
border-radius: 9999px;
|
||||
transition: width 0.2s;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #475569;
|
||||
}
|
||||
.summary strong { color: #0f172a; }
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-btn {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 9999px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-btn.active { background: #10b981; color: #fff; border-color: #10b981; }
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
|
||||
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
tr.hidden-row { display: none; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-yes { background: #fef3c7; color: #92400e; }
|
||||
.badge-no { background: #f1f5f9; color: #64748b; }
|
||||
.badge-confirmed { background: #d1fae5; color: #065f46; }
|
||||
.badge-cancelled { background: #fee2e2; color: #991b1b; }
|
||||
.badge-pending { background: #fef9c3; color: #854d0e; }
|
||||
.badge-other { background: #f1f5f9; color: #475569; }
|
||||
.badge-dup { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.error-row td { color: #ef4444; font-style: italic; }
|
||||
.dup-row td { background: #fff5f5; }
|
||||
.dup-cell { background:#fee2e2;color:#991b1b;border-radius:6px;padding:0.4rem 0.6rem;display:inline-block;font-weight:600;font-size:0.82rem; }
|
||||
|
||||
#results-section { display: none; }
|
||||
|
||||
.export-btn { background: #6366f1; }
|
||||
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EDR Booking Checker</h1>
|
||||
|
||||
<div class="card">
|
||||
<label for="base-url">API Base URL</label>
|
||||
<input type="text" id="base-url" value="http://localhost:4000" />
|
||||
<p class="hint">No trailing slash. e.g. https://api.edrsc.com</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<label for="refs">Booking References</label>
|
||||
<textarea id="refs" placeholder="ABCDEF GHIJKL MNOPQR One per line, comma-separated, or wrapped in {curly,braces}"></textarea>
|
||||
<p class="hint">Supports any format: one per line, comma-separated, or <code>{REF1,REF2}</code> groups.</p>
|
||||
|
||||
<div class="btn-row">
|
||||
<button id="run-btn" onclick="run()">Check Bookings</button>
|
||||
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
||||
<button class="btn-secondary export-btn" onclick="exportCSV()" id="export-btn" style="display:none;background:#6366f1;color:#fff;">Export CSV</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
<div class="progress-bar-wrap" id="progress-wrap">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="results-section">
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="filter-row">
|
||||
<button class="filter-btn active" onclick="setFilter('all', this)">All</button>
|
||||
<button class="filter-btn" onclick="setFilter('package', this)">Package Only</button>
|
||||
<button class="filter-btn" onclick="setFilter('regular', this)">Regular Only</button>
|
||||
<button class="filter-btn" onclick="setFilter('error', this)">Errors Only</button>
|
||||
<button class="filter-btn" onclick="setFilter('dup', this)">Duplicate Seats</button>
|
||||
</div>
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
|
||||
<label for="date-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Date:</label>
|
||||
<input type="text" id="date-filter" placeholder="e.g. 18 Jul 2026" style="width:160px" oninput="applyFilter(currentFilter)" />
|
||||
<label for="route-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Route:</label>
|
||||
<input type="text" id="route-filter" placeholder="e.g. DIR or BISH" style="width:160px" oninput="applyFilter(currentFilter)" />
|
||||
<label for="class-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Seat Class:</label>
|
||||
<input type="text" id="class-filter" placeholder="e.g. VIP" style="width:140px" oninput="applyFilter(currentFilter)" />
|
||||
<span id="row-count" style="font-size:0.82rem;color:#64748b;margin-left:0.5rem"></span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Journey</th>
|
||||
<th>Duplicate Bookings</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allRows = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
function parseRefs(raw) {
|
||||
// Remove curly braces, split on newlines/commas, deduplicate
|
||||
const cleaned = raw.replace(/[{}]/g, ' ');
|
||||
const refs = cleaned.split(/[\n,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
return [...new Set(refs)];
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
if (!status) return '<span class="badge badge-other">—</span>';
|
||||
const s = status.toUpperCase();
|
||||
const cls = s === 'CONFIRMED' ? 'confirmed'
|
||||
: s === 'CANCELLED' ? 'cancelled'
|
||||
: s.includes('PENDING') ? 'pending'
|
||||
: 'other';
|
||||
return `<span class="badge badge-${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const baseUrl = document.getElementById('base-url').value.trim().replace(/\/$/, '');
|
||||
const refs = parseRefs(document.getElementById('refs').value);
|
||||
|
||||
if (!refs.length) { setStatus('Enter at least one booking reference.'); return; }
|
||||
|
||||
const btn = document.getElementById('run-btn');
|
||||
btn.disabled = true;
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('progress-wrap').style.display = 'block';
|
||||
setProgress(0);
|
||||
setStatus(`Checking ${refs.length} booking(s)…`);
|
||||
|
||||
allRows = [];
|
||||
let done = 0;
|
||||
|
||||
// Run in batches of 20 to avoid overwhelming the server
|
||||
const BATCH = 20;
|
||||
for (let i = 0; i < refs.length; i += BATCH) {
|
||||
const batch = refs.slice(i, i + BATCH);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (ref) => {
|
||||
try {
|
||||
const proxyUrl = `http://localhost:8080/proxy?url=${encodeURIComponent(`${baseUrl}/bookings/${ref}`)}`;
|
||||
const res = await fetch(proxyUrl);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
const d = json.data ?? json;
|
||||
return {
|
||||
ref,
|
||||
status: d.status ?? 'UNKNOWN',
|
||||
bookingType: d.bookingType ?? '—',
|
||||
isPackage: d.isPackageBooking ?? !!d.packageId,
|
||||
packageName: d.packageName ?? '—',
|
||||
packageId: d.packageId ?? '—',
|
||||
passengerNames: (d.passengers ?? []).map(p => p.fullName ?? '—').join(', '),
|
||||
phone: d.contactPhone ?? '—',
|
||||
coaches: (d.passengers ?? []).map(p => p.seat?.coach ?? '—').join(', '),
|
||||
seatNumbers: (d.passengers ?? []).map(p => p.seat?.number ?? '—').join(', '),
|
||||
seatClasses: (d.passengers ?? []).map(p => p.seat?.seatClass ?? '—').join(', '),
|
||||
seatIds: (d.passengers ?? []).map(p => p.seat?.id).filter(Boolean),
|
||||
seats: (d.passengers ?? []).map(p => {
|
||||
const legScheduleMap = {
|
||||
1: d.schedule?.id,
|
||||
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule?.id : d.leg2Schedule?.id,
|
||||
3: d.returnSchedule?.id,
|
||||
4: d.returnLeg2Schedule?.id,
|
||||
};
|
||||
const legScheduleObjMap = {
|
||||
1: d.schedule,
|
||||
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule : d.leg2Schedule,
|
||||
3: d.returnSchedule,
|
||||
4: d.returnLeg2Schedule,
|
||||
};
|
||||
const sched = legScheduleObjMap[p.leg] ?? d.schedule;
|
||||
const scheduleId = sched?.id ?? null;
|
||||
const originSeq = sched?.origin?.sequence ?? sched?.originStation?.sequence ?? 0;
|
||||
const destSeq = sched?.destination?.sequence ?? sched?.destinationStation?.sequence ?? 999;
|
||||
const depMap = {
|
||||
1: d.schedule?.departureAt,
|
||||
2: d.bookingType === 'ROUND_TRIP' ? d.returnSchedule?.departureAt : d.leg2Schedule?.departureAt,
|
||||
3: d.returnSchedule?.departureAt,
|
||||
4: d.returnLeg2Schedule?.departureAt,
|
||||
};
|
||||
const departureAt = depMap[p.leg] ?? d.schedule?.departureAt;
|
||||
const depDate = departureAt ? new Date(departureAt).toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'numeric' }) : '—';
|
||||
const legLabel = d.bookingType === 'ONE_WAY' ? 'Outbound'
|
||||
: d.bookingType === 'ROUND_TRIP'
|
||||
? (p.leg === 1 ? 'Outbound' : 'Return')
|
||||
: d.bookingType === 'TRANSIT'
|
||||
? (p.leg === 1 ? 'Outbound Leg 1' : 'Outbound Leg 2')
|
||||
: d.bookingType === 'ROUND_TRIP_TRANSIT'
|
||||
? (p.leg === 1 ? 'Outbound Leg 1' : p.leg === 2 ? 'Outbound Leg 2' : p.leg === 3 ? 'Return Leg 1' : 'Return Leg 2')
|
||||
: `Leg ${p.leg}`;
|
||||
return { scheduleId, originSeq, destSeq, depDate, legLabel, coach: p.seat?.coach ?? '—', number: p.seat?.number ?? '—', seatClass: p.seat?.seatClass ?? '—' };
|
||||
}),
|
||||
origin: d.schedule?.origin?.code ?? d.schedule?.origin?.name ?? '—',
|
||||
destination: d.schedule?.destination?.code ?? d.schedule?.destination?.name ?? '—',
|
||||
hasDupSeat: false,
|
||||
dupSeatRefs: [],
|
||||
error: null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ref, status: 'ERROR', bookingType: '—', isPackage: false, packageName: '—', packageId: '—', passengerNames: '—', phone: '—', seatClasses: '—', seatIds: [], seats: [], origin: '—', destination: '—', hasDupSeat: false, dupSeatRefs: [], dupSeatIds: new Set(), error: e.message };
|
||||
}
|
||||
})
|
||||
);
|
||||
allRows.push(...batchResults);
|
||||
done += batch.length;
|
||||
setProgress(Math.round((done / refs.length) * 100));
|
||||
setStatus(`Checked ${done} / ${refs.length}…`);
|
||||
}
|
||||
|
||||
detectDuplicateSeats();
|
||||
renderTable();
|
||||
renderSummary();
|
||||
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
document.getElementById('export-btn').style.display = 'inline-block';
|
||||
document.getElementById('progress-wrap').style.display = 'none';
|
||||
setStatus('');
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function detectDuplicateSeats() {
|
||||
// Group seats by scheduleId|coach|seatNumber
|
||||
// Two bookings conflict if they share the same seat on the same schedule
|
||||
// AND their origin→destination sequence ranges overlap
|
||||
const seatMap = new Map(); // key: scheduleId|coach|seat -> [{ref, originSeq, destSeq}]
|
||||
for (const row of allRows) {
|
||||
if (row.error) continue;
|
||||
for (const seat of row.seats) {
|
||||
if (!seat.scheduleId || seat.coach === '—' || seat.number === '—') continue;
|
||||
const key = `${seat.scheduleId}|${seat.coach}|${seat.number}`;
|
||||
if (!seatMap.has(key)) seatMap.set(key, []);
|
||||
seatMap.get(key).push({ ref: row.ref, originSeq: seat.originSeq, destSeq: seat.destSeq });
|
||||
}
|
||||
}
|
||||
|
||||
// Two segments [a1,a2] and [b1,b2] overlap if a1 < b2 && b1 < a2
|
||||
function overlaps(a1, a2, b1, b2) { return a1 < b2 && b1 < a2; }
|
||||
|
||||
for (const row of allRows) {
|
||||
if (row.error) continue;
|
||||
const dupRefs = new Set();
|
||||
const dupSeatKeys = new Set();
|
||||
for (const seat of row.seats) {
|
||||
if (!seat.scheduleId) continue;
|
||||
const key = `${seat.scheduleId}|${seat.coach}|${seat.number}`;
|
||||
const entries = seatMap.get(key) ?? [];
|
||||
for (const other of entries) {
|
||||
if (other.ref === row.ref) continue;
|
||||
if (overlaps(seat.originSeq, seat.destSeq, other.originSeq, other.destSeq)) {
|
||||
dupRefs.add(other.ref);
|
||||
dupSeatKeys.add(`${seat.coach}|${seat.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
row.hasDupSeat = dupRefs.size > 0;
|
||||
row.dupSeatRefs = [...dupRefs];
|
||||
row.dupSeatIds = dupSeatKeys;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDupGroups() {
|
||||
const groups = new Map();
|
||||
for (const row of allRows) {
|
||||
if (row.error || !row.hasDupSeat) continue;
|
||||
const names = row.passengerNames.split(', ');
|
||||
for (const seat of row.seats) {
|
||||
if (!row.dupSeatIds.has(`${seat.coach}|${seat.number}`)) continue;
|
||||
const key = `${seat.depDate}|${seat.legLabel}|${row.origin}→${row.destination}|${seat.seatClass}|${seat.coach}|${seat.number}`;
|
||||
if (!groups.has(key)) groups.set(key, { entries: [], isPackage: false });
|
||||
const name = (names[row.seats.indexOf(seat)] ?? '').trim() || '—';
|
||||
groups.get(key).entries.push({ ref: row.ref, name, phone: row.phone, isPackage: row.isPackage });
|
||||
if (row.isPackage) groups.get(key).isPackage = true;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
const groups = buildDupGroups();
|
||||
if (!groups.size) return;
|
||||
|
||||
for (const [key, { entries, isPackage }] of groups) {
|
||||
if (new Set(entries.map(e => e.ref)).size < 2) continue;
|
||||
const [depDate, legLabel, route, seatClass, coach, seatNum] = key.split('|');
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.dup = '1';
|
||||
tr.dataset.type = isPackage ? 'package' : 'regular';
|
||||
tr.dataset.date = depDate;
|
||||
tr.dataset.route = route;
|
||||
tr.dataset.seatclass = seatClass.toLowerCase();
|
||||
tr.classList.add('dup-row');
|
||||
|
||||
const journeyCell = `<td style="white-space:nowrap">${depDate} · ${legLabel} · ${route} · ${seatClass} · ${coach}-${seatNum}</td>`;
|
||||
|
||||
const bookingsCell = `<td>${entries.map(e =>
|
||||
`<div style="margin-bottom:0.5rem">
|
||||
<span class="dup-cell">${e.ref}</span>
|
||||
<span style="margin-left:0.4rem">${e.name}</span>
|
||||
<span style="font-size:0.78rem;color:#64748b;margin-left:0.4rem">${e.phone}</span>
|
||||
</div>`
|
||||
).join('')}</td>`;
|
||||
|
||||
tr.innerHTML = journeyCell + bookingsCell;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
applyFilter(currentFilter);
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
const total = allRows.length;
|
||||
const pkgCount = allRows.filter(r => r.isPackage).length;
|
||||
const errCount = allRows.filter(r => r.error).length;
|
||||
const dupCount = allRows.filter(r => r.hasDupSeat).length;
|
||||
const regCount = total - pkgCount - errCount;
|
||||
document.getElementById('summary').innerHTML = `
|
||||
<span>Total: <strong>${total}</strong></span>
|
||||
<span>Package: <strong>${pkgCount}</strong></span>
|
||||
<span>Regular: <strong>${regCount}</strong></span>
|
||||
${errCount ? `<span style="color:#ef4444">Errors: <strong>${errCount}</strong></span>` : ''}
|
||||
${dupCount ? `<span style="color:#dc2626">⚠ Duplicate Seats: <strong>${dupCount}</strong></span>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function setFilter(type, btn) {
|
||||
currentFilter = type;
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
applyFilter(type);
|
||||
}
|
||||
|
||||
function applyFilter(type) {
|
||||
const dateQ = (document.getElementById('date-filter')?.value ?? '').trim().toLowerCase();
|
||||
const routeQ = (document.getElementById('route-filter')?.value ?? '').trim().toLowerCase();
|
||||
const classQ = (document.getElementById('class-filter')?.value ?? '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
document.querySelectorAll('#tbody tr').forEach(tr => {
|
||||
const t = tr.dataset.type;
|
||||
const typeMatch = type === 'all'
|
||||
|| (type === 'package' && t === 'package')
|
||||
|| (type === 'regular' && t === 'regular')
|
||||
|| (type === 'error' && t === 'error')
|
||||
|| (type === 'dup' && tr.dataset.dup === '1');
|
||||
const dateMatch = !dateQ || (tr.dataset.date ?? '').toLowerCase().includes(dateQ);
|
||||
const routeMatch = !routeQ || (tr.dataset.route ?? '').toLowerCase().includes(routeQ);
|
||||
const classMatch = !classQ || (tr.dataset.seatclass ?? '').includes(classQ);
|
||||
const show = typeMatch && dateMatch && routeMatch && classMatch;
|
||||
tr.classList.toggle('hidden-row', !show);
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById('row-count').textContent = `Showing ${visible} row${visible !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const header = 'Booking Ref,Status,Booking Type,Is Package,Package Name,Package ID,Passenger(s),Phone,Coach-Seat,Seat Class,Duplicate Seat In';
|
||||
const lines = allRows.map(r =>
|
||||
[r.ref, r.status, r.bookingType, r.isPackage, r.packageName, r.packageId, r.seats.map((s, idx) => `${(r.passengerNames.split(', ')[idx] ?? '').trim()} · ${s.legLabel} · ${s.seatClass} · ${r.origin}→${r.destination} · ${s.coach}-${s.number}`).join('; '), r.phone, r.seatClasses, r.dupSeatRefs.join('; ') || '']
|
||||
.map(v => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(',')
|
||||
);
|
||||
const csv = [header, ...lines].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'booking-check-' + new Date().toISOString().slice(0,10) + '.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
document.getElementById('date-filter').value = '';
|
||||
document.getElementById('route-filter').value = '';
|
||||
document.getElementById('class-filter').value = '';
|
||||
document.getElementById('refs').value = '';
|
||||
document.getElementById('tbody').innerHTML = '';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('progress-wrap').style.display = 'none';
|
||||
allRows = [];
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
function setProgress(pct) { document.getElementById('progress-bar').style.width = pct + '%'; }
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
256
booking-extractor.html
Normal file
256
booking-extractor.html
Normal file
@@ -0,0 +1,256 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Booking Extractor</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.5rem; color: #0f172a; }
|
||||
.card { background: #fff; border-radius: 0.75rem; border: 1px solid #e2e8f0; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.8rem; font-weight: 600; color: #475569; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1; border-radius: 0.5rem; padding: 2rem;
|
||||
text-align: center; cursor: pointer; color: #94a3b8; font-size: 0.9rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.drop-zone.over { border-color: #10b981; background: #f0fdf4; color: #065f46; }
|
||||
.drop-zone input[type="file"] { display: none; }
|
||||
button {
|
||||
background: #10b981; color: #fff; border: none; border-radius: 0.5rem;
|
||||
padding: 0.65rem 1.5rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #059669; }
|
||||
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
||||
.export-btn { background: #6366f1; color: #fff; }
|
||||
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||
#status { font-size: 0.85rem; color: #64748b; }
|
||||
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; font-size: 0.85rem; color: #475569; }
|
||||
.summary strong { color: #0f172a; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; font-weight: 600; color: #475569; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
#results-section { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EDR Booking Extractor</h1>
|
||||
|
||||
<div class="card">
|
||||
<label>Bookings JSON File</label>
|
||||
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||
<input type="file" id="file-input" accept=".json" onchange="handleFile(this.files[0])" />
|
||||
Drop <code>bookings.json</code> here or click to browse
|
||||
</div>
|
||||
<p class="hint">Accepts a JSON array of bookings or an object with a <code>bookings</code> key.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
||||
<button class="export-btn" id="export-btn" onclick="exportCSV()" style="display:none">Export CSV</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="results-section">
|
||||
<div class="summary" id="summary"></div>
|
||||
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap">
|
||||
<label for="date-filter" style="margin:0;text-transform:none;font-size:0.82rem;letter-spacing:0">Departure Date:</label>
|
||||
<input type="text" id="date-filter" placeholder="e.g. 18 Jul 2026" style="width:160px;border:1px solid #cbd5e1;border-radius:0.5rem;padding:0.4rem 0.6rem;font-size:0.85rem;outline:none" oninput="applyFilter()" />
|
||||
<span id="row-count" style="font-size:0.82rem;color:#64748b"></span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Booking Ref</th>
|
||||
<th>Status</th>
|
||||
<th>Booking Type</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Departure</th>
|
||||
<th>Origin</th>
|
||||
<th>Destination</th>
|
||||
<th>Passenger(s)</th>
|
||||
<th>Coach - Seat</th>
|
||||
<th>Payment Method</th>
|
||||
<th>Payment Status</th>
|
||||
<th>Total (DJF)</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let extracted = [];
|
||||
|
||||
function fmt(v) { return v ?? ''; }
|
||||
|
||||
function extractRow(b) {
|
||||
const seats = (b.seats ?? b.passengers ?? []);
|
||||
const passengerNames = seats.map(s => s.passengerName ?? s.name ?? '').filter(Boolean).join(', ');
|
||||
const coachSeats = seats.map(s => {
|
||||
const num = s.seat?.seatNumber ?? '';
|
||||
const coach = typeof s.seat?.coach === 'string' ? s.seat.coach : (s.seat?.coach?.number ?? '');
|
||||
return coach && num ? `${coach}-${num}` : (num || coach || '');
|
||||
}).filter(Boolean).join(', ');
|
||||
|
||||
const dep = b.schedule?.departureAt
|
||||
? new Date(b.schedule.departureAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
const createdAt = b.createdAt
|
||||
? new Date(b.createdAt).toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return {
|
||||
booking_ref: fmt(b.bookingRef),
|
||||
status: fmt(b.status),
|
||||
booking_type: fmt(b.bookingType),
|
||||
phone: fmt(b.contactPhone),
|
||||
email: fmt(b.contactEmail),
|
||||
departure: dep,
|
||||
origin: fmt(b.schedule?.originStation?.code),
|
||||
destination: fmt(b.schedule?.destinationStation?.code),
|
||||
passenger_names: passengerNames,
|
||||
coach_seat: coachSeats,
|
||||
payment_method: fmt(b.paymentIntent?.method),
|
||||
payment_status: fmt(b.paymentIntent?.status),
|
||||
total: b.displayTotalMinor != null ? (b.displayTotalMinor / 100).toFixed(2) : '',
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function extractObjects(text) {
|
||||
const results = [];
|
||||
let depth = 0, start = -1, inString = false, escape = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === '\\' && inString) { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') { if (depth === 0) start = i; depth++; }
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0 && start !== -1) {
|
||||
try { results.push(JSON.parse(text.slice(start, i + 1))); } catch (_) {}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
window.handleFile = function(file) {
|
||||
if (!file) return;
|
||||
setStatus(`Reading ${file.name}…`);
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const text = e.target.result.replace(/^\uFEFF/, '');
|
||||
let bookings = [], parseWarning = '';
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
bookings = Array.isArray(data) ? data : (data.bookings ?? []);
|
||||
} catch (_) {
|
||||
bookings = extractObjects(text);
|
||||
parseWarning = `⚠ File was truncated — recovered ${bookings.length} complete record(s).`;
|
||||
}
|
||||
if (!bookings.length) throw new Error('No booking records found in the JSON.');
|
||||
extracted = bookings.map(extractRow);
|
||||
renderTable();
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
document.getElementById('export-btn').style.display = 'inline-block';
|
||||
document.getElementById('summary').innerHTML = `Loaded <strong>${extracted.length}</strong> booking(s) from <strong>${file.name}</strong>`;
|
||||
setStatus(parseWarning);
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const HEADERS = ['booking_ref','status','booking_type','phone','email','departure','origin','destination','passenger_names','coach_seat','payment_method','payment_status','total','created_at'];
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
extracted.forEach((row, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.departure = row.departure.toLowerCase();
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td><code>${row.booking_ref || '—'}</code></td>
|
||||
<td>${row.status || '—'}</td>
|
||||
<td>${row.booking_type || '—'}</td>
|
||||
<td>${row.phone || '—'}</td>
|
||||
<td>${row.email || '—'}</td>
|
||||
<td>${row.departure || '—'}</td>
|
||||
<td>${row.origin || '—'}</td>
|
||||
<td>${row.destination || '—'}</td>
|
||||
<td>${row.passenger_names || '—'}</td>
|
||||
<td>${row.coach_seat || '—'}</td>
|
||||
<td>${row.payment_method || '—'}</td>
|
||||
<td>${row.payment_status || '—'}</td>
|
||||
<td>${row.total || '—'}</td>
|
||||
<td>${row.created_at || '—'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
const q = (document.getElementById('date-filter')?.value ?? '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
document.querySelectorAll('#tbody tr').forEach(tr => {
|
||||
const show = !q || (tr.dataset.departure ?? '').includes(q);
|
||||
tr.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
document.getElementById('row-count').textContent = `Showing ${visible} row${visible !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
window.exportCSV = function() {
|
||||
const header = HEADERS.join(',');
|
||||
const lines = extracted.map(row =>
|
||||
HEADERS.map(k => `"${String(row[k] ?? '').replace(/"/g, '""')}"`).join(',')
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([[header, ...lines].join('\n')], { type: 'text/csv' }));
|
||||
a.download = 'bookings_extracted.csv';
|
||||
a.click();
|
||||
};
|
||||
|
||||
window.clearAll = function() {
|
||||
extracted = [];
|
||||
document.getElementById('tbody').innerHTML = '';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('file-input').value = '';
|
||||
document.getElementById('date-filter').value = '';
|
||||
document.getElementById('row-count').textContent = '';
|
||||
setStatus('');
|
||||
};
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
|
||||
const dz = document.getElementById('drop-zone');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||||
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||||
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('over'); handleFile(e.dataTransfer.files[0]); });
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
53
booking-proxy.mjs
Normal file
53
booking-proxy.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const PORT = 8080;
|
||||
const __dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// Serve any .html file in the same directory
|
||||
if (req.url === '/' || req.url.endsWith('.html')) {
|
||||
const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1);
|
||||
const filepath = path.join(__dir, filename);
|
||||
if (fs.existsSync(filepath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
fs.createReadStream(filepath).pipe(res);
|
||||
} else {
|
||||
res.writeHead(404); res.end('Not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy /proxy?url=<encoded-api-url>
|
||||
if (req.url.startsWith('/proxy?url=')) {
|
||||
const target = decodeURIComponent(req.url.slice('/proxy?url='.length));
|
||||
const parsed = new URL(target);
|
||||
const mod = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: parsed.hostname },
|
||||
};
|
||||
const proxy = mod.request(options, (apiRes) => {
|
||||
res.writeHead(apiRes.statusCode, apiRes.headers);
|
||||
apiRes.pipe(res);
|
||||
});
|
||||
proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); });
|
||||
req.pipe(proxy);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404); res.end();
|
||||
});
|
||||
|
||||
server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`));
|
||||
239
ticket-extractor.html
Normal file
239
ticket-extractor.html
Normal file
@@ -0,0 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDR Ticket Extractor</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; padding: 2rem; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.5rem; color: #0f172a; }
|
||||
.card { background: #fff; border-radius: 0.75rem; border: 1px solid #e2e8f0; padding: 1.5rem; margin-bottom: 1.5rem; }
|
||||
label { display: block; font-size: 0.8rem; font-weight: 600; color: #475569; margin-bottom: 0.4rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e1; border-radius: 0.5rem; padding: 2rem;
|
||||
text-align: center; cursor: pointer; color: #94a3b8; font-size: 0.9rem;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.drop-zone.over { border-color: #10b981; background: #f0fdf4; color: #065f46; }
|
||||
.drop-zone input[type="file"] { display: none; }
|
||||
button {
|
||||
background: #10b981; color: #fff; border: none; border-radius: 0.5rem;
|
||||
padding: 0.65rem 1.5rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #059669; }
|
||||
button:disabled { background: #a7f3d0; cursor: not-allowed; }
|
||||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
||||
.export-btn { background: #6366f1; color: #fff; }
|
||||
.export-btn:hover:not(:disabled) { background: #4f46e5; }
|
||||
.btn-row { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
||||
#status { font-size: 0.85rem; color: #64748b; }
|
||||
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1rem; font-size: 0.85rem; color: #475569; }
|
||||
.summary strong { color: #0f172a; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
|
||||
thead tr { background: #f8fafc; border-bottom: 2px solid #e2e8f0; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; font-weight: 600; color: #475569; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
|
||||
td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
#results-section { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>EDR Ticket Extractor</h1>
|
||||
|
||||
<div class="card">
|
||||
<label>Tickets JSON File</label>
|
||||
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
||||
<input type="file" id="file-input" accept=".json" onchange="handleFile(this.files[0])" />
|
||||
Drop <code>tickets.json</code> here or click to browse
|
||||
</div>
|
||||
<p class="hint">Accepts a JSON array of tickets or an object with a <code>tickets</code> key.</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" onclick="clearAll()">Clear</button>
|
||||
<button class="export-btn" id="export-btn" onclick="exportCSV()" style="display:none">Export CSV</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="results-section">
|
||||
<div class="summary" id="summary"></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Ticket No.</th>
|
||||
<th>Booking Ref</th>
|
||||
<th>Passenger</th>
|
||||
<th>Phone</th>
|
||||
<th>Email</th>
|
||||
<th>Journey Type</th>
|
||||
<th>Origin</th>
|
||||
<th>Destination</th>
|
||||
<th>Seat Class</th>
|
||||
<th>Coach</th>
|
||||
<th>Seat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const FIELDS = [
|
||||
{ key: 'ticket_number', paths: [['ticketNumber']] },
|
||||
{ key: 'booking_ref', paths: [['bookingRef']] },
|
||||
{ key: 'passenger_name', paths: [['passengerName'], ['booking','seats',0,'passengerName']] },
|
||||
{ key: 'phone_number', paths: [['booking','contactPhone'], ['booking','passenger','phone']] },
|
||||
{ key: 'email', paths: [['booking','contactEmail'], ['booking','passenger','email']] },
|
||||
{ key: 'journey_type', paths: [['booking','bookingType']] },
|
||||
{ key: 'origin', paths: [['schedule','originStation','code']] },
|
||||
{ key: 'destination', paths: [['schedule','destinationStation','code']] },
|
||||
{ key: 'seat_class', paths: [['seat','coach','coachType','name']] },
|
||||
{ key: 'coach_number', paths: [['seat','coach','number']] },
|
||||
{ key: 'seat_number', paths: [['seat','seatNumber']] },
|
||||
];
|
||||
|
||||
let extracted = [];
|
||||
|
||||
function getField(obj, keys) {
|
||||
let cur = obj;
|
||||
for (const k of keys) {
|
||||
if (cur == null) return '';
|
||||
if (Array.isArray(cur) && typeof k === 'number') cur = cur[k];
|
||||
else if (typeof cur === 'object' && k in cur) cur = cur[k];
|
||||
else return '';
|
||||
}
|
||||
return cur ?? '';
|
||||
}
|
||||
|
||||
function extractRow(t) {
|
||||
const row = {};
|
||||
for (const f of FIELDS) {
|
||||
for (const path of f.paths) {
|
||||
const val = getField(t, path);
|
||||
if (val !== '') { row[f.key] = String(val); break; }
|
||||
}
|
||||
if (!row[f.key]) row[f.key] = '';
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
// Extract complete JSON objects from a truncated array string using brace-depth counting
|
||||
function extractObjects(text) {
|
||||
const results = [];
|
||||
let depth = 0, start = -1, inString = false, escape = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch === '\\' && inString) { escape = true; continue; }
|
||||
if (ch === '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch === '{') {
|
||||
if (depth === 0) start = i;
|
||||
depth++;
|
||||
} else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0 && start !== -1) {
|
||||
try { results.push(JSON.parse(text.slice(start, i + 1))); } catch (_) {}
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
window.handleFile = function(file) {
|
||||
if (!file) return;
|
||||
setStatus(`Reading ${file.name}…`);
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
try {
|
||||
const text = e.target.result.replace(/^\uFEFF/, '');
|
||||
let tickets = [];
|
||||
let parseWarning = '';
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
tickets = Array.isArray(data) ? data : (data.tickets ?? []);
|
||||
} catch (_) {
|
||||
// Truncated file: extract complete JSON objects by brace depth
|
||||
tickets = extractObjects(text);
|
||||
parseWarning = `⚠ File was truncated — recovered ${tickets.length} complete record(s).`;
|
||||
}
|
||||
if (!Array.isArray(tickets) || !tickets.length) throw new Error('No ticket records found in the JSON.');
|
||||
extracted = tickets.map(extractRow);
|
||||
renderTable();
|
||||
document.getElementById('results-section').style.display = 'block';
|
||||
document.getElementById('export-btn').style.display = 'inline-block';
|
||||
document.getElementById('summary').innerHTML = `Loaded <strong>${extracted.length}</strong> ticket(s) from <strong>${file.name}</strong>`;
|
||||
setStatus(parseWarning);
|
||||
} catch (err) {
|
||||
setStatus(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('tbody');
|
||||
tbody.innerHTML = '';
|
||||
extracted.forEach((row, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${i + 1}</td>
|
||||
<td><code>${row.ticket_number || '—'}</code></td>
|
||||
<td><code>${row.booking_ref || '—'}</code></td>
|
||||
<td>${row.passenger_name || '—'}</td>
|
||||
<td>${row.phone_number || '—'}</td>
|
||||
<td>${row.email || '—'}</td>
|
||||
<td>${row.journey_type || '—'}</td>
|
||||
<td>${row.origin || '—'}</td>
|
||||
<td>${row.destination || '—'}</td>
|
||||
<td>${row.seat_class || '—'}</td>
|
||||
<td>${row.coach_number || '—'}</td>
|
||||
<td>${row.seat_number || '—'}</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
window.exportCSV = function() {
|
||||
const header = FIELDS.map(f => f.key).join(',');
|
||||
const lines = extracted.map(row =>
|
||||
FIELDS.map(f => `"${String(row[f.key] ?? '').replace(/"/g, '""')}"`).join(',')
|
||||
);
|
||||
const csv = [header, ...lines].join('\n');
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
||||
a.download = 'tickets_extracted.csv';
|
||||
a.click();
|
||||
}
|
||||
|
||||
window.clearAll = function() {
|
||||
extracted = [];
|
||||
document.getElementById('tbody').innerHTML = '';
|
||||
document.getElementById('results-section').style.display = 'none';
|
||||
document.getElementById('export-btn').style.display = 'none';
|
||||
document.getElementById('file-input').value = '';
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
function setStatus(msg) { document.getElementById('status').textContent = msg; }
|
||||
|
||||
// Drag and drop
|
||||
const dz = document.getElementById('drop-zone');
|
||||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('over'); });
|
||||
dz.addEventListener('dragleave', () => dz.classList.remove('over'));
|
||||
dz.addEventListener('drop', e => {
|
||||
e.preventDefault(); dz.classList.remove('over');
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user