This commit is contained in:
Roba Boru
2026-07-16 13:54:22 +03:00
33 changed files with 2000 additions and 503 deletions

View File

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "SupplementaryCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"providerTxnId" TEXT,
"notes" TEXT,
"createdBy" TEXT NOT NULL,
"paidAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status");
-- AddForeignKey
ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -567,6 +567,7 @@ model Booking {
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
supplementaryCharges SupplementaryCharge[]
journey Journey?
@@index([passengerId, status])
@@ -1234,6 +1235,28 @@ model BaggageBooking {
@@schema("passenger")
}
model SupplementaryCharge {
id String @id @default(uuid())
bookingId String
reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION"
amountMinor Int
currency String @default("ETB")
status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED
paymentToken String @unique @default(uuid())
providerTxnId String?
notes String?
createdBy String
paidAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
@@index([paymentToken])
@@index([status])
@@schema("passenger")
}
model ExcessBaggageCharge {
id String @id @default(uuid())
bookingId String

View File

@@ -152,7 +152,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -296,7 +296,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -409,7 +409,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -554,7 +554,7 @@ export class BookingsService {
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
@@ -612,7 +612,7 @@ export class BookingsService {
passenger: { select: { id: true, iamUserId: true } },
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
paymentIntent: true,
seats: { include: { seat: true } },
seats: { include: { seat: { include: { coach: true } } } },
package: { select: { id: true, name: true, code: true } },
priceTier: { select: { id: true, label: true, priceMinor: true } },
},
@@ -671,7 +671,7 @@ export class BookingsService {
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: s.seat.coach?.number ?? null } : null }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
@@ -683,7 +683,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -704,6 +704,15 @@ export class BookingsService {
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
passengers: uniquePassengers,
seats: booking.seats.map((s: any) => ({
passengerName: s.passengerName,
passengerCategory: s.passengerCategory,
leg: s.leg ?? 1,
fareMinor: s.fareMinor,
idDocumentType: s.idDocumentType,
verifaydaVerified: s.verifaydaVerified,
seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null,
})),
schedule: {
train: booking.schedule.train,
originStation: (booking as any).originStationId
@@ -1874,7 +1883,7 @@ export class BookingsService {
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,

View File

@@ -1,14 +1,23 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
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';
@ApiTags('Dashboard')
@Controller('dashboard')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
export class DashboardController {
constructor(private service: DashboardService) {}
@Get(':passengerId') @ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
@Get('backoffice-stats')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
getBackofficeStats() { return this.service.getBackofficeStats(); }
@Get(':passengerId')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get home dashboard aggregate for passenger' })
getHomeDashboard(@Param('passengerId') id: string) { return this.service.getHomeDashboard(id); }
}

View File

@@ -10,6 +10,57 @@ export class DashboardService {
@InjectDataSource() private dataSource: DataSource,
) {}
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, 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.$queryRaw<{ currency: string; total: bigint }[]>`
SELECT
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total
FROM passenger."Booking"
WHERE status IN ('CONFIRMED', 'BOARDED')
AND "packageId" IS NULL
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`,
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
SELECT
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
SUM(COALESCE("displayTotalMinor", "totalMinor")) AS total
FROM passenger."Booking"
WHERE status IN ('CONFIRMED', 'BOARDED')
AND "packageId" IS NOT NULL
AND id IN (SELECT "bookingId" FROM passenger."PaymentIntent" WHERE status = 'SUCCEEDED')
GROUP BY COALESCE("displayCurrency"::text, "currency"::text)
`,
]);
const totalPackageTickets = await this.prisma.ticket.count({
where: { booking: { packageId: { not: null } } },
});
const toMap = (rows: { currency: string; total: bigint }[]) =>
Object.entries(
rows.reduce((m, r) => { m[r.currency] = Number(r.total); return m; }, {} as Record<string, number>),
).map(([currency, totalMinor]) => ({ currency, totalMinor }));
return {
totalBookings,
totalPackageBookings,
totalNormalBookings: totalBookings - totalPackageBookings,
totalTickets,
totalPackageTickets,
totalNormalTickets: totalTickets - totalPackageTickets,
totalPassengers,
revenueByCurrency: toMap(revenueRows),
packageRevenueByCurrency: toMap(packageRevenueRows),
};
}
async getHomeDashboard(passengerId: string) {
const now = new Date();
const [passenger, upcomingBooking, wallet, promos, weatherAlerts, stationSignals, savedRoutes] = await Promise.all([

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
import {
LogExcessBaggageDto,
@@ -12,8 +12,8 @@ import { PassengerAdmin } from '../../common/passenger-guards';
class UpsertBaggageAllowanceDto {
@IsString() seatClassId: string;
@IsInt() @IsPositive() maxWeightKg: number;
@IsInt() @IsPositive() maxPiecesCount: number;
@IsOptional() @IsInt() maxWeightKg?: number;
@IsOptional() @IsInt() maxPiecesCount?: number;
@IsInt() @IsPositive() excessFeePerKg: number;
}

View File

@@ -42,7 +42,6 @@ export class ExcessBaggageService {
const booking = await this.prisma.booking.findUnique({
where: { id: dto.bookingId },
include: {
seats: { take: 1, include: { seat: { include: { coach: { include: { coachType: true } } } } } },
passenger: { include: { user: true } },
},
});
@@ -51,20 +50,9 @@ export class ExcessBaggageService {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to log excess baggage');
}
// Resolve fee per kg from BaggageAllowance via seat class
const coachTypeId = booking.seats[0]?.seat?.coach?.coachTypeId;
let feePerKgMinor = 5000; // 50 ETB default fallback (in minor)
if (coachTypeId) {
const seatClass = await this.prisma.seatClass.findFirst({
where: { coachTypeId },
});
if (seatClass) {
const allowance = await this.prisma.baggageAllowance.findFirst({
where: { seatClassId: seatClass.id },
});
if (allowance) feePerKgMinor = allowance.excessFeePerKg;
}
}
const allowance = await this.prisma.baggageAllowance.findFirst({ orderBy: { createdAt: 'asc' } });
if (!allowance) throw new BadRequestException('No excess baggage rate configured. Please set a rate in Tariff Rates.');
const feePerKgMinor = allowance.excessFeePerKg;
const totalMinor = feePerKgMinor * dto.excessWeightKg;
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
@@ -289,11 +277,16 @@ export class ExcessBaggageService {
return allowances.map(a => ({ ...a, seatClass: scMap.get(a.seatClassId) ?? null }));
}
async upsertAllowance(dto: { seatClassId: string; maxWeightKg: number; maxPiecesCount: number; excessFeePerKg: number }) {
return this.prisma.baggageAllowance.upsert({
where: { seatClassId: dto.seatClassId } as any,
update: { maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
create: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg, maxPiecesCount: dto.maxPiecesCount, excessFeePerKg: dto.excessFeePerKg },
async upsertAllowance(dto: { seatClassId: string; maxWeightKg?: number; maxPiecesCount?: number; excessFeePerKg: number }) {
const existing = await this.prisma.baggageAllowance.findFirst({ where: { seatClassId: dto.seatClassId } });
if (existing) {
return this.prisma.baggageAllowance.update({
where: { id: existing.id },
data: { maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
});
}
return this.prisma.baggageAllowance.create({
data: { seatClassId: dto.seatClassId, maxWeightKg: dto.maxWeightKg ?? 0, maxPiecesCount: dto.maxPiecesCount ?? 0, excessFeePerKg: dto.excessFeePerKg },
});
}
@@ -302,7 +295,7 @@ export class ExcessBaggageService {
}
async deleteAllowance(id: string) {
await this.prisma.baggageAllowance.delete({ where: { id } });
await this.prisma.baggageAllowance.deleteMany({ where: { id } });
return { deleted: true };
}

View File

@@ -39,12 +39,34 @@ import {
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
class CreateSupplementaryChargeDto {
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
class WaiveSupplementaryChargeDto {
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
class PaySupplementaryChargeDto {
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
}
@ApiTags("Payment")
@Controller("payments")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PaymentsController {
constructor(private service: PaymentsService) {}
constructor(
private service: PaymentsService,
private supplementaryService: SupplementaryChargesService,
) {}
@Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin])
@@ -315,6 +337,92 @@ export class PaymentsController {
}
}
// ── Supplementary Charges ──────────────────────────────────────────────────
@Post('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(
@Body() dto: CreateSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
return this.supplementaryService.create({
...dto,
createdBy: iamUserId ?? 'staff',
});
}
@Get('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List supplementary charges (staff only)' })
@ApiQuery({ name: 'bookingRef', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
listSupplementaryCharges(
@Query('bookingRef') bookingRef?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.supplementaryService.getAll({
bookingRef,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});
}
@Get('supplementary/by-token/:token')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' })
getSupplementaryByToken(@Param('token') token: string) {
return this.supplementaryService.getByToken(token);
}
@Post('supplementary/by-token/:token/pay')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
paySupplementaryCharge(
@Param('token') token: string,
@Body() dto: PaySupplementaryChargeDto,
) {
return this.supplementaryService.pay(token, dto.method, dto.platform);
}
@Post('supplementary/:id/mark-paid')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
markSupplementaryPaid(
@Param('id') id: string,
@Body() body: { providerTxnId?: string },
) {
return this.supplementaryService.markPaid(id, body.providerTxnId);
}
@Post('supplementary/:id/waive')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
waiveSupplementaryCharge(
@Param('id') id: string,
@Body() dto: WaiveSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
}
@Post('supplementary/:id/resend')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
resendSupplementaryLink(@Param('id') id: string) {
return this.supplementaryService.resendLink(id);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>

View File

@@ -12,6 +12,7 @@ import {
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
@@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module";
import { CurrencyModule } from "../currency/currency.module";
import { AuditModule } from "../../common/audit.module";
import { NotificationsModule } from "../notifications/notifications.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
function rabbitMQImport(): DynamicModule[] {
@@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] {
TicketsModule,
CurrencyModule,
AuditModule,
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
NotificationsModule,
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
@@ -65,6 +67,7 @@ function rabbitMQImport(): DynamicModule[] {
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
SupplementaryChargesService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,

View File

@@ -833,19 +833,46 @@ export class PaymentsService {
return { alreadyFinalized: false };
}
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
if (event.eventType === 'payment.failed') {
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
return { processed: true };
}
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
if (!charge) {
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
return { processed: false, reason: 'charge-not-found' };
}
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
return { processed: true };
}
async handlePaymentEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (
event.service !== PaymentServiceEnum.PASSENGER ||
event.referenceType !== PaymentReferenceType.BOOKING
) {
if (event.service !== PaymentServiceEnum.PASSENGER) {
this.logger.warn(
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
return this.handleSupplementaryChargeEvent(event);
}
if (event.referenceType !== PaymentReferenceType.BOOKING) {
this.logger.warn(
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.eventType === "payment.failed") {
const intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },

View File

@@ -0,0 +1,193 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
@Injectable()
export class SupplementaryChargesService {
private readonly logger = new Logger(SupplementaryChargesService.name);
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private smsClient: SmsClientService,
private emailClient: EmailClientService,
private paymentClient: PaymentClientService,
) {}
async create(dto: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
createdBy: string;
}) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: dto.bookingRef },
include: { passenger: { include: { user: true } } },
});
if (!booking) throw new NotFoundException('Booking not found');
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge');
}
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const charge = await this.prisma.supplementaryCharge.create({
data: {
bookingId: booking.id,
reason: dto.reason,
amountMinor: dto.amountMinor,
notes: dto.notes ?? null,
createdBy: dto.createdBy,
expiresAt,
},
});
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
await this.sendLink(charge, booking.bookingRef, phone, email);
await this.auditService.log({
action: 'CREATE',
entityType: 'SupplementaryCharge',
entityId: charge.id,
newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason },
});
return charge;
}
async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) {
const { bookingRef, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (status) where.status = status;
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
await this.prisma.supplementaryCharge.updateMany({
where: { status: 'PENDING', expiresAt: { lt: new Date() } },
data: { status: 'EXPIRED' },
});
const [items, total] = await Promise.all([
this.prisma.supplementaryCharge.findMany({
where,
include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.supplementaryCharge.count({ where }),
]);
return { items, total, page, pageSize };
}
async getByToken(token: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
include: { booking: { select: { bookingRef: true } } },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid');
if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived');
if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) {
if (charge.status === 'PENDING') {
await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } });
}
throw new BadRequestException('This payment link has expired');
}
return charge;
}
async markPaid(id: string, providerTxnId?: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } });
return updated;
}
async pay(token: string, method: string, platform?: 'web' | 'mobile') {
const charge = await this.getByToken(token); // validates status/expiry
const paymentMethod = method as ProviderMethod;
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
referenceId: charge.id,
orderRef: `SC-${charge.id.substring(0, 8)}`,
amountMinor: charge.amountMinor,
currency: charge.currency,
provider: paymentMethod,
platform,
returnUrl,
failureUrl,
});
return snapshot;
}
async waive(id: string, notes: string, waivedBy: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge');
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { status: 'WAIVED', notes },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } });
return updated;
}
async resendLink(id: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges');
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail);
return { sent: true };
}
private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) {
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`;
const amount = (charge.amountMinor / 100).toFixed(2);
const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`;
if (phone) {
try { await this.smsClient.sendSms({ to: phone, message: msg }); }
catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); }
}
if (email) {
try {
await this.emailClient.sendEmail({
to: email,
subject: `EDR — Outstanding balance for booking ${bookingRef}`,
text: msg,
});
} catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); }
}
if (!phone && !email) {
this.logger.warn(`No contact info for supplementary charge ${charge.id}`);
}
}
}

View File

@@ -2,15 +2,29 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
import { TicketsService } from './tickets.service';
import { JwtGuard } from '../../common/jwt.guard';
import { PassengerAdmin } from '../../common/passenger-guards';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Tickets')
@Controller('tickets')
export class TicketsController {
constructor(private service: TicketsService) {}
@Post('smart-assign/:bookingId')
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Smart seat assignment + ticket generation',
description:
'Keeps original seats if still free, auto-reassigns to an available seat of the same coach type if taken, ' +
'or throws 409 if the schedule is fully booked in that class.',
})
smartAssignAndGenerate(@Param('bookingId') bookingId: string) {
return this.service.smartAssignAndGenerate(bookingId);
}
@Post('generate/:bookingId')
@PassengerAdmin()
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
@ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Generate ticket for booking (confirmation page)',

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, ConflictException, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
@@ -174,6 +174,109 @@ export class TicketsService {
};
}
// Smart seat assignment for conflict resolution:
// 1. If the original seat is still free → keep it and generate
// 2. If the original seat is taken → find a truly available seat in the same coach type
// (excludes: confirmed/boarded bookings, active holds, seat blocks, BOOKED/HELD/REMOVED status)
// 3. If no seats of that class remain → throw so the agent is notified
async smartAssignAndGenerate(bookingId: string) {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
seats: {
include: {
seat: { include: { coach: { include: { coachType: true } } } },
},
},
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Seats taken by other confirmed/boarded bookings on this schedule
const takenByOthers = await this.prisma.bookingSeat.findMany({
where: {
booking: { id: { not: bookingId }, status: { in: ['CONFIRMED', 'BOARDED'] } },
seat: { coach: { assignments: { some: { scheduleId: booking.scheduleId } } } },
},
select: { seatId: true },
}).then(rows => new Set(rows.map(r => r.seatId)));
// Seats held by any active SeatHold (not yet expired)
const heldSeatIds = await this.prisma.seatHold.findMany({
where: { expiresAt: { gt: new Date() } },
select: { seatIds: true },
}).then(rows => new Set(rows.flatMap(r => r.seatIds)));
// Seats with an active SeatBlock
const blockedSeatIds = await this.prisma.seatBlock.findMany({
select: { seatId: true },
}).then(rows => new Set(rows.map(r => r.seatId)));
// Union of all unavailable seat IDs (excluding the booking's own seats)
const ownSeatIds = new Set((booking as any).seats.map((bs: any) => bs.seatId as string));
const unavailableIds = new Set([
...[...takenByOthers].filter(id => !ownSeatIds.has(id)),
...[...heldSeatIds],
...[...blockedSeatIds],
]);
const reassigned: { seatNumber: string; newSeatNumber: string }[] = [];
for (const bs of (booking as any).seats) {
const originalSeatId: string = bs.seatId;
// Case 1: original seat is still free — nothing to do
if (!takenByOthers.has(originalSeatId) && !heldSeatIds.has(originalSeatId) && !blockedSeatIds.has(originalSeatId)) continue;
// Case 2: original seat is unavailable — find a truly available seat in the same coach type
const coachTypeId: string | undefined = bs.seat?.coach?.coachTypeId;
const candidate = await this.prisma.seat.findFirst({
where: {
status: 'AVAILABLE',
seatNumber: { not: '' },
NOT: [
{ seatNumber: { startsWith: '-' } },
{ id: { in: [...unavailableIds] } },
],
coach: {
assignments: { some: { scheduleId: booking.scheduleId } },
...(coachTypeId ? { coachTypeId } : {}),
},
},
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
// Case 3: no seats left in that class
if (!candidate) {
const className = bs.seat?.coach?.coachType?.name ?? 'the same class';
throw new ConflictException(
`No available seats remaining in ${className} on this schedule. Please contact the passenger to arrange an alternative.`,
);
}
await this.prisma.bookingSeat.update({
where: { id: bs.id },
data: { seatId: candidate.id },
});
// Mark the newly assigned seat as taken so subsequent passengers in the
// same booking don't get assigned the same seat.
unavailableIds.add(candidate.id);
reassigned.push({ seatNumber: bs.seat.seatNumber, newSeatNumber: candidate.seatNumber });
}
await this.auditService.log({
action: 'UPDATE',
entityType: 'Booking',
entityId: bookingId,
newData: { smartReassigned: true, changes: reassigned },
});
return this.generate(bookingId);
}
async generate(bookingId: string) {
if (!bookingId) throw new BadRequestException('Booking ID is required');
@@ -231,11 +334,29 @@ export class TicketsService {
}
}
// Check for seat conflicts before deleting existing tickets or issuing new ones
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
const conflictingSeats = await this.prisma.bookingSeat.findMany({
where: {
seatId: { in: seatIds },
booking: {
id: { not: bookingId },
status: { in: ['CONFIRMED', 'BOARDED'] },
},
},
include: { seat: true },
});
if (conflictingSeats.length > 0) {
const labels = [...new Set(conflictingSeats.map((s: any) => s.seat.seatNumber))].join(', ');
throw new ConflictException(
`Seat(s) ${labels} are already confirmed for another booking.`,
);
}
await this.prisma.ticket.deleteMany({ where: { bookingId } });
// Generate one ticket per unique passenger (grouped by passengerName)
const tickets = [];
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
// Group seats by passenger
const passengerSeatsMap = new Map<string, any[]>();

View File

@@ -22,6 +22,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
perm('7f3a1e9c-2b4d-4c8a-9e6f-1a2b3c4d5e6f', 'edr_passenger_app:tickets:generate', 'Generate tickets'),
perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'),
perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'),
perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
@@ -82,8 +83,9 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:passengers:manage',
},
tickets: {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate',
},
payments: {
view: 'edr_passenger_app:payments:view',
@@ -172,6 +174,7 @@ export const ROLE_PERMISSION_PRESETS = {
PASSENGER_PERMS.bookings.manage,
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
PASSENGER_PERMS.tickets.generate,
PASSENGER_PERMS.passengers.view,
PASSENGER_PERMS.agents.view,
PASSENGER_PERMS.audit.view,
@@ -182,6 +185,7 @@ export const ROLE_PERMISSION_PRESETS = {
ticketOfficer: [
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
PASSENGER_PERMS.tickets.generate,
PASSENGER_PERMS.bookings.view,
PASSENGER_PERMS.passengers.view,
PASSENGER_PERMS.dashboard.view,
@@ -194,6 +198,7 @@ export const ROLE_PERMISSION_PRESETS = {
PASSENGER_PERMS.passengers.view,
PASSENGER_PERMS.tickets.view,
PASSENGER_PERMS.tickets.manage,
PASSENGER_PERMS.tickets.generate,
PASSENGER_PERMS.payments.refund,
PASSENGER_PERMS.dashboard.view,
],

View File

@@ -66,6 +66,18 @@ function BookingsPageContent() {
}),
});
const smartAssignMutation = useMutation({
mutationFn: (bookingId: string) => bookingsApi.smartAssign(bookingId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setSuccessMessage('Seats assigned and ticket generated successfully');
setTimeout(() => setSuccessMessage(''), 4000);
setGenerateTicketBooking(null);
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
setGenerateTicketTouched({ paymentReference: false, paymentMethod: false });
},
});
const forceConfirmMutation = useMutation({
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
bookingsApi.forceConfirm(bookingId, data),
@@ -473,25 +485,6 @@ function BookingsPageContent() {
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
</div>
{b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
<div className="mt-3 p-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20">
<p className="text-xs text-amber-700 dark:text-amber-400 mb-2">
Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
</p>
<ActionButton
variant="secondary"
onClick={() => forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
disabled={forceConfirmMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
</ActionButton>
{forceConfirmMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</p>
)}
</div>
)}
</section>
{/* Seats / Passengers */}
@@ -517,7 +510,9 @@ function BookingsPageContent() {
</div>
{isSeats && (
<div className="text-right">
<p className="text-sm font-mono font-semibold">{p.seat?.seatNumber || p.seatId || '—'}</p>
<p className="text-sm font-mono font-semibold">
{[p.seat?.coach?.number || p.coach ? `Coach ${p.seat?.coach?.number || p.coach}` : null, p.seat?.seatNumber || p.seatNumber ? `Seat ${p.seat?.seatNumber || p.seatNumber}` : (p.seatId ? `Seat ${p.seatId.slice(0, 8)}` : '—')].filter(Boolean).join(' · ')}
</p>
<p className="text-xs text-muted-foreground">{formatCurrency(p.fareMinor ?? 0, b.currency || 'ETB')}</p>
</div>
)}
@@ -564,7 +559,7 @@ function BookingsPageContent() {
{/* Generate Ticket Modal */}
<Modal
isOpen={!!generateTicketBooking}
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
title="Generate Ticket"
size="md"
>
@@ -625,16 +620,31 @@ function BookingsPageContent() {
/>
</div>
{forceConfirmMutation.isError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
</div>
)}
{(forceConfirmMutation.isError || smartAssignMutation.isError) && (() => {
const e = (forceConfirmMutation.error ?? smartAssignMutation.error) as any;
const m = e?.response?.data?.message;
const msg = Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment';
const isConflict = e?.response?.status === 409 || msg?.toLowerCase().includes('seat');
const isFullyBooked = msg?.toLowerCase().includes('no available seats');
return (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-400">
<p className="font-semibold mb-1">
{isFullyBooked ? '🚫 Schedule Fully Booked' : isConflict ? '⚠️ Seat Conflict Detected' : 'Error'}
</p>
<p>{msg}</p>
</div>
);
})()}
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 text-sm">
<p className="font-semibold text-amber-800 dark:text-amber-300 mb-0.5">Seat auto-assignment</p>
<p className="text-amber-700 dark:text-amber-400">The system will automatically assign the best available seat and generate the ticket upon confirmation.</p>
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton
variant="secondary"
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); }}
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); forceConfirmMutation.reset(); smartAssignMutation.reset(); }}
>
Cancel
</ActionButton>
@@ -642,18 +652,12 @@ function BookingsPageContent() {
onClick={() => {
setGenerateTicketTouched({ paymentReference: true, paymentMethod: true });
if (!generateTicketForm.paymentReference || !generateTicketForm.paymentMethod) return;
forceConfirmMutation.mutate({
bookingId: generateTicketBooking.id,
data: {
paymentReference: generateTicketForm.paymentReference,
paymentMethod: generateTicketForm.paymentMethod,
notes: generateTicketForm.notes || undefined,
},
});
forceConfirmMutation.reset();
smartAssignMutation.mutate(generateTicketBooking.id);
}}
disabled={forceConfirmMutation.isPending}
disabled={forceConfirmMutation.isPending || smartAssignMutation.isPending}
>
{forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
{(forceConfirmMutation.isPending || smartAssignMutation.isPending) ? 'Generating…' : 'Confirm & Generate Ticket'}
</ActionButton>
</div>
</div>

View File

@@ -3,62 +3,103 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, Users, DollarSign, AlertCircle, Calendar } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import Link from 'next/link';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
// Mock data for fallback when API fails
const MOCK_STATS = {
totalBookings: 1247,
totalRevenue: 892450,
totalPassengers: 2156,
};
function StatCard({
icon, iconBg, label, total, loading, rows, href,
}: {
icon: React.ReactNode;
iconBg: string;
label: string;
total: number;
loading: boolean;
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
href: string;
}) {
return (
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
</div>
<p className="text-3xl font-bold text-foreground tabular-nums">
{loading ? '—' : total.toLocaleString()}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between">
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
{loading ? '—' : r.value.toLocaleString()}
</Link>
</div>
))}
</div>
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
);
}
const MOCK_RECENT_BOOKINGS = [
{
id: '1',
bookingRef: 'BK-2024-001',
passenger: { fullName: 'John Doe' },
totalMinor: 125000,
currency: 'ETB',
status: 'CONFIRMED',
createdAt: new Date().toISOString()
},
{
id: '2',
bookingRef: 'BK-2024-002',
passenger: { fullName: 'Jane Smith' },
totalMinor: 85000,
currency: 'ETB',
status: 'PENDING',
createdAt: new Date().toISOString()
}
];
function RevenueSection({
label, bookingCount, rows, subtotal, loading, renderRow,
}: {
label: React.ReactNode;
bookingCount: number;
rows: { currency: string; totalMinor: number }[];
subtotal: number;
loading: boolean;
renderRow: (r: { currency: string; totalMinor: number }) => React.ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? '—' : bookingCount.toLocaleString()} bookings
</span>
</div>
{rows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: rows.map(renderRow)}
{rows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
</div>
)}
</div>
);
}
function DashboardPageContent() {
const { data: exchangeRates = [] } = useQuery<any[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
});
const toEtbRate = (currency: string): number | null => {
if (currency === 'ETB') return 1;
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
return r ? 1 / r.rate : null;
};
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats,
retry: 1,
staleTime: 60000, // 1 minute
});
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
retry: 1,
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
queryKey: ['backoffice-stats'],
queryFn: dashboardApi.getBackofficeStats,
retry: 1,
staleTime: 60000,
});
const { data: paymentMethods } = useQuery({
@@ -67,57 +108,38 @@ function DashboardPageContent() {
retry: 1,
});
// Use actual data or fallback to mock/empty states
const displayStats = stats || (statsError ? MOCK_STATS : null);
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData :
(bookingsError ? MOCK_RECENT_BOOKINGS : []);
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
const bookingColumns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
{
key: 'passenger',
label: 'Passenger',
render: (item: any) => {
if (item.passenger?.fullName) {
return item.passenger.fullName;
}
if (item.contactEmail) {
return item.contactEmail;
}
if (item.contactPhone) {
return item.contactPhone;
}
return 'N/A';
}
},
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
];
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
const packageGrand = calcGrand(packageRows);
const overallGrand = normalGrand + packageGrand;
const tripColumns = [
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name}${item.destinationStation?.name || item.destination?.name}` },
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
];
const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
return (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{currency}</span>
</div>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
</span>
)}
</span>
</div>
);
};
return (
<div className="space-y-6 p-6">
@@ -126,43 +148,105 @@ function DashboardPageContent() {
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
{/* Error Alert */}
{(statsError || bookingsError) && (
{statsError && (
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
Some data may be outdated
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
Unable to fetch live data. Showing cached or sample information.
</p>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
</div>
</div>
</div>
)}
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
icon={Ticket}
color="blue"
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
iconBg="bg-blue-100 dark:bg-blue-900/30"
label="Bookings"
total={stats?.totalBookings ?? 0}
loading={statsLoading}
href="/bookings"
rows={[
{ label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' },
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
]}
/>
<StatCard
title="Total Revenue"
value={statsLoading ? '...' : formatCurrency(displayStats?.totalRevenue || 0, 'ETB')}
icon={DollarSign}
color="green"
/>
<StatCard
title="Total Passengers"
value={statsLoading ? '...' : (displayStats?.totalPassengers || 0).toLocaleString()}
icon={Users}
color="purple"
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
label="Tickets"
total={stats?.totalTickets ?? 0}
loading={statsLoading}
href="/tickets"
rows={[
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
]}
/>
{/* Revenue card */}
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</span>
</div>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : (
<>
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatCurrency(overallGrand, 'ETB')}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Regular</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(normalGrand, 'ETB')}</p>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Package</p>
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(packageGrand, 'ETB')}</p>
</div>
</div>
<Link href="/payments" className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
View payments <ArrowRight className="h-3 w-3" />
</Link>
</>
)}
</div>
</div>
{/* Revenue breakdown */}
<div className="card">
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">Revenue Breakdown</h2>
{statsLoading ? (
<p className="text-muted-foreground text-sm">Loading</p>
) : !normalRows.length && !packageRows.length ? (
<p className="text-muted-foreground text-sm">No revenue data yet.</p>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<RevenueSection
label="Regular"
bookingCount={stats?.totalNormalBookings ?? 0}
rows={normalRows}
subtotal={normalGrand}
loading={statsLoading}
renderRow={renderRevenueRow}
/>
<RevenueSection
label="Package"
bookingCount={stats?.totalPackageBookings ?? 0}
rows={packageRows}
subtotal={packageGrand}
loading={statsLoading}
renderRow={renderRevenueRow}
/>
</div>
)}
</div>
{/* Payment Methods Distribution */}
@@ -171,16 +255,8 @@ function DashboardPageContent() {
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={paymentMethods}
dataKey="count"
nameKey="method"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{paymentMethods.map((entry, index) => (
<Pie data={paymentMethods} dataKey="count" nameKey="method" cx="50%" cy="50%" outerRadius={80} label>
{paymentMethods.map((_: any, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
@@ -189,35 +265,6 @@ function DashboardPageContent() {
</ResponsiveContainer>
</div>
)}
{/* Recent Bookings */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Ticket className="h-5 w-5" />
Recent Bookings
</h2>
<DataTable
data={recentBookings}
columns={bookingColumns}
loading={bookingsLoading}
emptyMessage="No recent bookings found"
/>
</div>
{/* Upcoming Trips */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Calendar className="h-5 w-5" />
Upcoming Trips
</h2>
<DataTable
data={upcomingTrips || []}
columns={tripColumns}
loading={tripsLoading}
emptyMessage="No upcoming trips scheduled"
/>
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi } from '@/lib/api';
import { excessBaggageApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -34,6 +34,15 @@ export default function ExcessBaggagePage() {
const [resendSuccess, setResendSuccess] = useState(false);
const [resendError, setResendError] = useState<string | null>(null);
const { data: allowancesData } = useQuery({
queryKey: ['baggage-allowances'],
queryFn: () => apiClient.get<any>('/agents/excess-baggage/allowances'),
});
const allowances: any[] = Array.isArray(allowancesData)
? allowancesData
: (allowancesData as any)?.items ?? (allowancesData as any)?.data ?? [];
const excessRate = allowances[0] ?? null;
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
queryFn: () => excessBaggageApi.getAll({
@@ -229,58 +238,76 @@ export default function ExcessBaggagePage() {
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
</div>
)}
<div>
<label className="label">Booking ID</label>
<input
className="input"
placeholder="Booking UUID"
value={logForm.bookingId}
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 5"
value={logForm.excessWeightKg}
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={logForm.collectCash}
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
/>
Collect cash now (no payment link sent)
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
</p>
{!excessRate ? (
<div className="rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 text-sm text-amber-800 dark:text-amber-200">
No excess luggage rate configured. Please set a rate in Tariff Rates before logging.
</div>
) : (
<>
<div className="rounded-lg bg-muted/50 px-3 py-2 text-sm">
Rate: <span className="font-semibold">{(excessRate.excessFeePerKg / 100).toFixed(2)} ETB/kg</span>
</div>
<div>
<label className="label">Booking ID</label>
<input
className="input"
placeholder="Booking UUID"
value={logForm.bookingId}
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 5"
value={logForm.excessWeightKg}
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
/>
</div>
{logForm.excessWeightKg && (
<p className="text-xs text-muted-foreground">
Estimated charge: <span className="font-semibold">{((excessRate.excessFeePerKg / 100) * parseInt(logForm.excessWeightKg || '0')).toFixed(2)} ETB</span>
</p>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={logForm.collectCash}
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
/>
Collect cash now (no payment link sent)
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
</p>
)}
</>
)}
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
<ActionButton
loading={logMutation.isPending}
onClick={() => {
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
setLogError('Booking ID and excess weight are required');
return;
}
logMutation.mutate({
bookingId: logForm.bookingId.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
});
}}
>
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
</ActionButton>
{excessRate && (
<ActionButton
loading={logMutation.isPending}
onClick={() => {
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
setLogError('Booking ID and excess weight are required');
return;
}
logMutation.mutate({
bookingId: logForm.bookingId.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
});
}}
>
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
</ActionButton>
)}
</div>
</div>
</Modal>

View File

@@ -0,0 +1,290 @@
'use client';
import { useState } from 'react';
import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import {
useSupplementaryCharges,
useCreateSupplementaryCharge,
useMarkSupplementaryPaid,
useWaiveSupplementaryCharge,
useResendSupplementaryLink,
} from './useSupplementaryCharges';
type Tab = 'create' | 'list';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
const STATUS_COLORS: Record<string, string> = {
PENDING: 'warning',
PAID: 'success',
WAIVED: 'info',
EXPIRED: 'error',
};
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
const [tab, setTab] = useState<Tab>('create');
const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' });
// Create form state
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
const [formError, setFormError] = useState<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters);
const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []);
const createMutation = useCreateSupplementaryCharge(() => {
setCreateSuccess(`Charge created and payment link sent.`);
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
setFormError(null);
setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000);
});
const markPaidMutation = useMarkSupplementaryPaid();
const waiveMutation = useWaiveSupplementaryCharge();
const resendMutation = useResendSupplementaryLink();
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const flash = (msg: string) => {
setActionSuccess(msg);
setTimeout(() => setActionSuccess(null), 3000);
};
const handleCreate = async () => {
setFormError(null);
const amountMinor = Math.round(parseFloat(form.amountEtb) * 100);
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
try {
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
} catch (e: any) {
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
}
};
const handleMarkPaid = async (id: string) => {
setActionError(null);
try {
await markPaidMutation.mutateAsync({ id });
flash('Marked as paid');
} catch (e: any) {
setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed');
}
};
const handleWaive = async (id: string) => {
setActionError(null);
try {
await waiveMutation.mutateAsync({ id });
flash('Charge waived');
} catch (e: any) {
setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed');
}
};
const handleResend = async (id: string) => {
setActionError(null);
try {
await resendMutation.mutateAsync(id);
flash('Payment link resent');
} catch (e: any) {
setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed');
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Supplementary Charges" size="xl">
{/* Tabs */}
<div className="flex gap-1 mb-5 border-b border-muted">
{(['create', 'list'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
tab === t
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t === 'create' ? '+ Raise Charge' : 'All Charges'}
</button>
))}
</div>
{/* ── CREATE TAB ── */}
{tab === 'create' && (
<div className="space-y-4">
{createSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200"> {createSuccess}</div>
)}
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{formError}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input
className="input"
placeholder="e.g. EDR-20240001"
value={form.bookingRef}
onChange={(e) => setForm({ ...form, bookingRef: e.target.value })}
/>
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input
className="input"
type="number"
min="0.01"
step="0.01"
placeholder="e.g. 50.00"
value={form.amountEtb}
onChange={(e) => setForm({ ...form, amountEtb: e.target.value })}
/>
</div>
<div>
<label className="label">Reason <span className="text-red-500">*</span></label>
<select className="input" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
{REASONS.map((r) => <option key={r} value={r}>{r.replace('_', ' ')}</option>)}
</select>
</div>
<div className="md:col-span-2">
<label className="label">Notes (optional)</label>
<textarea
className="input resize-none"
rows={2}
placeholder="e.g. Passenger paid 350 ETB, correct fare is 400 ETB"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setTab('list')}>Cancel</ActionButton>
<ActionButton icon={PlusCircle} onClick={handleCreate} loading={createMutation.isPending}>
Raise Charge
</ActionButton>
</div>
</div>
)}
{/* ── LIST TAB ── */}
{tab === 'list' && (
<div className="space-y-4">
{actionSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {actionSuccess}</div>
)}
{actionError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{actionError}</div>
)}
{/* Filters */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Booking Ref</label>
<input
className="input"
placeholder="Search booking ref…"
value={listFilters.bookingRef}
onChange={(e) => setListFilters({ ...listFilters, bookingRef: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select className="input" value={listFilters.status} onChange={(e) => setListFilters({ ...listFilters, status: e.target.value })}>
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="WAIVED">Waived</option>
<option value="EXPIRED">Expired</option>
</select>
</div>
</div>
{/* Table */}
{isLoading ? (
<p className="text-sm text-muted-foreground py-6 text-center">Loading…</p>
) : charges.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">No supplementary charges found.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-muted">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Amount</th>
<th className="px-3 py-2">Reason</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Created</th>
<th className="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-muted">
{charges.map((c: any) => (
<tr key={c.id} className="hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 font-mono text-xs">{c.booking?.bookingRef ?? c.bookingId.substring(0, 8)}</td>
<td className="px-3 py-2 font-semibold">{formatCurrency(c.amountMinor, c.currency ?? 'ETB')}</td>
<td className="px-3 py-2 text-xs">{c.reason}</td>
<td className="px-3 py-2">
<Badge variant="status" status={STATUS_COLORS[c.status] ?? c.status}>{c.status}</Badge>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{formatDateTime(c.createdAt)}</td>
<td className="px-3 py-2">
{c.status === 'PENDING' && (
<div className="flex gap-1">
<button
title="Mark paid"
onClick={() => handleMarkPaid(c.id)}
className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600"
>
<CheckCircle size={15} />
</button>
<button
title="Waive"
onClick={() => handleWaive(c.id)}
className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500"
>
<XCircle size={15} />
</button>
<button
title="Resend link"
onClick={() => handleResend(c.id)}
className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900/30 text-blue-500"
>
<RotateCcw size={15} />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex justify-end pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={onClose}>Close</ActionButton>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, AlertCircle } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { paymentsApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import SupplementaryChargesModal from './SupplementaryChargesModal';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -37,6 +38,7 @@ export default function PaymentsPage() {
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const [supplementaryOpen, setSupplementaryOpen] = useState(false);
const queryClient = useQueryClient();
@@ -134,7 +136,10 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<div className="flex items-center gap-2">
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Supplementary Charges</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
</div>
<div className="card">
@@ -280,6 +285,8 @@ export default function PaymentsPage() {
error={deleteError ?? undefined}
/>
<SupplementaryChargesModal isOpen={supplementaryOpen} onClose={() => setSupplementaryOpen(false)} />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentsApi } from '@/lib/api';
export function useSupplementaryCharges(filters: { bookingRef?: string; status?: string }) {
return useQuery({
queryKey: ['supplementary-charges', filters],
queryFn: () => paymentsApi.supplementary.getAll(filters),
});
}
export function useCreateSupplementaryCharge(onSuccess: () => void) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
paymentsApi.supplementary.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
onSuccess();
},
});
}
export function useMarkSupplementaryPaid() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, providerTxnId }: { id: string; providerTxnId?: string }) =>
paymentsApi.supplementary.markPaid(id, providerTxnId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['supplementary-charges'] }),
});
}
export function useWaiveSupplementaryCharge() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
paymentsApi.supplementary.waive(id, notes),
onSuccess: () => qc.invalidateQueries({ queryKey: ['supplementary-charges'] }),
});
}
export function useResendSupplementaryLink() {
return useMutation({
mutationFn: (id: string) => paymentsApi.supplementary.resend(id),
});
}

View File

@@ -2,13 +2,23 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { Download, TrendingUp, BookOpen, Banknote, Ticket } from 'lucide-react';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell,
} from 'recharts';
import { bookingsApi } from '@/lib/api';
import { dashboardApi } from '@/lib/api/dashboard';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
const COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
function esc(s: string) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export default function ReportsPage() {
const [dateRange, setDateRange] = useState('30');
@@ -21,23 +31,13 @@ export default function ReportsPage() {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date();
switch (dateRange) {
case '7':
start.setDate(end.getDate() - 7);
break;
case '30':
start.setDate(end.getDate() - 30);
break;
case '90':
start.setDate(end.getDate() - 90);
break;
case '7': start.setDate(end.getDate() - 7); break;
case '30': start.setDate(end.getDate() - 30); break;
case '90': start.setDate(end.getDate() - 90); break;
default:
if (startDate && endDate) {
return { startDate, endDate };
}
if (startDate && endDate) return { startDate, endDate };
}
return {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
@@ -46,37 +46,61 @@ export default function ReportsPage() {
const dates = getDateRange();
// Fetch all bookings
const { data: bookingsData, isLoading } = useQuery({
// Confirmed-ticket revenue — same source as dashboard
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['backoffice-stats'],
queryFn: dashboardApi.getBackofficeStats,
staleTime: 60000,
});
const { data: exchangeRates = [] } = useQuery<any[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
});
const toEtbRate = (currency: string): number | null => {
if (currency === 'ETB') return 1;
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
return r ? 1 / r.rate : null;
};
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
const packageGrand = calcGrand(packageRows);
const overallGrand = normalGrand + packageGrand;
// Bookings for charts / status distribution
const { data: bookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: ['all-bookings'],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
// Filter bookings by date range — exclude CANCELLED from revenue calculations
const bookings = Array.isArray(bookingsData?.items)
? bookingsData.items.filter((b: any) => {
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
return bookingDate >= dates.startDate && bookingDate <= dates.endDate;
})
: [];
const isLoading = statsLoading || bookingsLoading;
const revenueBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : [];
// Calculate metrics — revenue excludes cancelled/refunded bookings
const totalRevenue = revenueBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
const totalBookings = bookings.length;
const avgTicketPrice = revenueBookings.length > 0 ? Math.round(totalRevenue / revenueBookings.length) : 0;
const bookings = allBookings.filter((b: any) => {
const d = new Date(b.createdAt).toISOString().split('T')[0];
return d >= dates.startDate && d <= dates.endDate;
});
// Group by date for revenue chart — exclude cancelled/refunded
const byDate = revenueBookings.reduce((acc: Record<string, any>, b: any) => {
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
const date = new Date(b.createdAt).toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
acc[date].totalMinor += b.totalMinor || 0;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
}, {});
const chartData = Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
@@ -86,33 +110,37 @@ export default function ReportsPage() {
bookings: d.count || 0,
}));
const REPORT_COLS = [
{ key: 'date', label: 'Date' },
{ key: 'revenue', label: 'Revenue (ETB)' },
{ key: 'bookings', label: 'Bookings' },
];
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
const doExport = () => {
if (!chartData.length) { alert('No data to export'); return; }
const headers = REPORT_COLS.map(c => c.label);
const rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Revenue Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Revenue Report — ${dates.startDate} to ${dates.endDate}</h2>`);
w.document.write(`<p>Total Revenue: ETB ${Math.round(totalRevenue / 100).toLocaleString()} | Total Bookings: ${totalBookings} | Cancelled: ${bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>`);
w.document.write(`<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
const thead = REPORT_COLS.map(h => `<th>${esc(h)}</th>`).join('');
const tbody = rows.map(r => `<tr>${r.map(v => `<td>${esc(v)}</td>`).join('')}</tr>`).join('');
w.document.write(
`<!DOCTYPE html><html><head><title>Revenue Report</title>` +
`<style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}` +
`th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>` +
`<h2>Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}</h2>` +
`<p>Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
`Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
`Tickets: ${esc(String(stats?.totalTickets ?? 0))}</p>` +
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></body></html>`
);
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const tsv = [REPORT_COLS.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const csv = [REPORT_COLS.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${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 = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
@@ -120,11 +148,32 @@ export default function ReportsPage() {
setExportModalOpen(false);
};
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
return (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium">{currency}</span>
</div>
<span className="text-sm font-semibold tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
</span>
)}
</span>
</div>
);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
<p className="text-muted-foreground mt-1">View detailed reports and performance metrics</p>
<p className="text-muted-foreground mt-1">Revenue figures reflect confirmed tickets only</p>
</div>
{/* Date Range Selector */}
@@ -132,239 +181,327 @@ export default function ReportsPage() {
<div className="flex items-end gap-4 flex-wrap">
<div>
<label className="label">Date Range</label>
<select
className="input"
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
disabled={isLoading}
>
<select className="input" value={dateRange} onChange={(e) => setDateRange(e.target.value)} disabled={isLoading}>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
</div>
{dateRange === 'custom' && (
<>
<div>
<label className="label">Start Date</label>
<input
type="date"
className="input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={isLoading}
/>
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} disabled={isLoading} />
</div>
<div>
<label className="label">End Date</label>
<input
type="date"
className="input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={isLoading}
/>
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} disabled={isLoading} />
</div>
</>
)}
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
Export
</ActionButton>
</div>
{isLoading && (
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
)}
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Excl. cancelled &amp; refunded</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Revenue */}
<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">Total Revenue</p>
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
</div>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
</div>
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
{/* Total Bookings */}
<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">Total Bookings</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
</div>
<Users className="h-8 w-8 text-green-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Non-cancelled bookings</p>
{/* Total Tickets */}
<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">Total Tickets</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
</div>
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
{/* Avg Daily Revenue */}
<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">Avg. Daily Revenue</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
<TrendingUp className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
</p>
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
</p>
</div>
</div>
{/* Revenue Breakdown by Currency */}
<div className="card">
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
Revenue Breakdown Confirmed Tickets
</h2>
{statsLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : !normalRows.length && !packageRows.length ? (
<p className="text-sm text-muted-foreground">No revenue data yet.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Regular */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Regular</span>
<span className="text-xs text-muted-foreground tabular-nums">
{(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets
</span>
</div>
{normalRows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: normalRows.map(renderCurrencyRow)}
{normalRows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold tabular-nums">{formatCurrency(normalGrand, 'ETB')}</span>
</div>
)}
</div>
{/* Package */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Package</span>
<span className="text-xs text-muted-foreground tabular-nums">
{(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets
</span>
</div>
{packageRows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: packageRows.map(renderCurrencyRow)}
{packageRows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold tabular-nums">{formatCurrency(packageGrand, 'ETB')}</span>
</div>
)}
</div>
</div>
)}
{!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && (
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 mt-4">
<span className="text-sm font-semibold text-muted-foreground">Grand Total (ETB equivalent)</span>
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatCurrency(overallGrand, 'ETB')}
</span>
</div>
)}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Trend */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Revenue Trend</h3>
<h3 className="text-base font-semibold mb-4">
Revenue Trend{' '}
<span className="text-xs font-normal text-muted-foreground">(confirmed, ETB)</span>
</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(value: number) => [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} />
<Legend />
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
<Line type="monotone" dataKey="revenue" stroke="#10b981" dot={{ r: 4 }} activeDot={{ r: 6 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Daily Bookings */}
{/* Daily Confirmed Bookings */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
<h3 className="text-base font-semibold mb-4">Daily Confirmed Bookings</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="bookings" fill="#10b981" />
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Booking Status Distribution */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Booking Status</h3>
<h3 className="text-base font-semibold mb-4">Booking Status Distribution</h3>
{bookings.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
{ name: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
].filter(d => d.value > 0)}
cx="50%"
cy="50%"
cx="50%" cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={100}
dataKey="value"
>
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
{STATUS_COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Top Payment Methods */}
{/* Payment Methods */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
<h3 className="text-base font-semibold mb-4">Payment Methods</h3>
{bookings.length > 0 ? (
<div className="space-y-3">
<div className="space-y-3 pt-1">
{(Object.entries(
bookings.reduce((acc: Record<string, number>, b: any) => {
const method = b.paymentIntent?.method || 'Unknown';
acc[method] = (acc[method] || 0) + 1;
return acc;
}, {} as Record<string, number>)
) as [string, number][]
)
) as [string, number][])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([method, count]) => (
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<span className="font-semibold">{count}</span>
</div>
))}
.slice(0, 6)
.map(([method, count]) => {
const pct = bookings.length > 0 ? Math.round((count / bookings.length) * 100) : 0;
return (
<div key={method} className="flex items-center gap-3">
<span className="text-sm w-32 shrink-0 capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div className="bg-primary h-2 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="text-sm font-semibold tabular-nums w-8 text-right">{count}</span>
</div>
);
})}
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
</div>
{/* Summary Stats */}
{/* Summary */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
<p className="text-xl font-bold mt-2">{chartData.length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Completed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
</div>
<h3 className="text-base font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ label: 'Active Days', value: chartData.length, fromStats: false },
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
{ label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true },
{ label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true },
].map(({ label, value, fromStats }) => (
<div key={label} className="border border-border rounded-lg p-3 text-center">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xl font-bold mt-1 tabular-nums">
{fromStats && statsLoading ? '—' : value.toLocaleString()}
</p>
</div>
))}
</div>
</div>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Exports daily revenue and booking counts for the selected date range. Cancelled and refunded bookings are excluded from revenue figures.</p>
<p className="text-sm text-muted-foreground">
Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded.
</p>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<p className="text-sm font-medium mb-2">Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reportExportFormat" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,324 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react';
import { bookingsApi } from '@/lib/api';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { formatDateTime, formatCurrency } from '@/lib/utils';
interface SeatRow {
bookingRef: string;
passengerName: string;
seatNumber: string;
coachNumber: string;
fareMinor: number;
currency: string;
paymentStatus: string;
bookingStatus: string;
bookedAt: string;
releaseAt: string | null;
scheduleOrigin: string;
scheduleDestination: string;
scheduleDeparture: string;
}
const HOLD_DURATION_MS = 5 * 60 * 1000;
function getReleaseAt(booking: any, seat: any): string | null {
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
if (paymentStatus === 'SUCCEEDED' || paymentStatus === 'COMPLETED') return null;
if (booking.status === 'CONFIRMED') return null;
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
if (booking.createdAt) {
return new Date(new Date(booking.createdAt).getTime() + HOLD_DURATION_MS).toISOString();
}
return null;
}
function isExpired(releaseAt: string | null): boolean {
if (!releaseAt) return false;
return new Date(releaseAt) < new Date();
}
export default function SeatStatusReportPage() {
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
const [search, setSearch] = useState('');
const { data: bookingsData, isLoading } = useQuery({
queryKey: ['seat-report-bookings'],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
const rows: SeatRow[] = useMemo(() => {
const bookings: any[] = bookingsData?.items || [];
const result: SeatRow[] = [];
for (const booking of bookings) {
if (booking.status === 'CANCELLED') continue;
const seats: any[] = booking.seats || [];
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
for (const seat of seats) {
result.push({
bookingRef: booking.bookingRef || '—',
passengerName: seat.passengerName || seat.name || booking.passengerNames?.[0] || '—',
seatNumber: seat.seat?.seatNumber || seat.seatNumber || '—',
coachNumber: seat.seat?.coach?.number || seat.coach || '—',
fareMinor: seat.fareMinor ?? 0,
currency: booking.currency || 'ETB',
paymentStatus,
bookingStatus: booking.status,
bookedAt: booking.createdAt,
releaseAt: getReleaseAt(booking, seat),
scheduleOrigin: booking.schedule?.originStation?.name || '—',
scheduleDestination: booking.schedule?.destinationStation?.name || '—',
scheduleDeparture: booking.schedule?.departureAt || '',
});
}
}
return result;
}, [bookingsData]);
const filtered = useMemo(() => {
return rows.filter((r) => {
const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED';
if (statusFilter === 'PAID' && !isPaid) return false;
if (statusFilter === 'UNPAID' && isPaid) return false;
if (search) {
const q = search.toLowerCase();
return (
r.bookingRef.toLowerCase().includes(q) ||
r.passengerName.toLowerCase().includes(q) ||
r.seatNumber.toLowerCase().includes(q) ||
r.coachNumber.toLowerCase().includes(q)
);
}
return true;
});
}, [rows, statusFilter, search]);
const paidCount = rows.filter(
(r) => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'
).length;
const unpaidCount = rows.length - paidCount;
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
const doExport = () => {
if (!filtered.length) { alert('No data to export'); return; }
const headers = [
'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare',
'Payment Status', 'Booking Status', 'Booked At', 'Release At',
'Origin', 'Destination', 'Departure',
];
const csvRows = filtered.map((r) => [
r.bookingRef,
r.passengerName,
r.seatNumber,
r.coachNumber,
formatCurrency(r.fareMinor, r.currency),
r.paymentStatus,
r.bookingStatus,
r.bookedAt ? formatDateTime(r.bookedAt) : '—',
r.releaseAt ? formatDateTime(r.releaseAt) : '—',
r.scheduleOrigin,
r.scheduleDestination,
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : '—',
]);
const csv = [
headers.map((h) => `"${h}"`).join(','),
...csvRows.map((row) => row.map((v) => `"${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 = `seat-status-report-${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">Seat Status Report</h1>
<p className="text-muted-foreground mt-1">
Track booked seats paid vs unpaid, booking times, and hold release times
</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
{paidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
</div>
<CheckCircle className="h-8 w-8 text-green-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">Unpaid Seats</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{unpaidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
</div>
<Clock className="h-8 w-8 text-amber-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">Expired Holds</p>
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
{expiredCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
</div>
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-48">
<label className="label">Search</label>
<input
type="text"
className="input"
placeholder="Booking ref, passenger, seat, coach..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div>
<label className="label">Payment Status</label>
<select
className="input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
>
<option value="ALL">All Seats</option>
<option value="PAID">Paid Only</option>
<option value="UNPAID">Unpaid Only</option>
</select>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
Export CSV
</ActionButton>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading...</p>}
</div>
{/* Table */}
<div className="card p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
'Booking Ref',
'Passenger',
'Seat / Coach',
'Fare',
'Payment',
'Booked At',
'Release At',
'Route',
].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{filtered.map((row, i) => {
const isPaid =
row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED';
const expired = isExpired(row.releaseAt);
return (
<tr
key={i}
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
{row.bookingRef}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{row.passengerName}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<span className="font-semibold">{row.seatNumber}</span>
{row.coachNumber !== '—' && (
<span className="text-muted-foreground"> · Coach {row.coachNumber}</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{formatCurrency(row.fareMinor, row.currency)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="status" status={isPaid ? 'PAID' : row.paymentStatus}>
{isPaid ? 'PAID' : row.paymentStatus}
</Badge>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.bookedAt ? formatDateTime(row.bookedAt) : '—'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{isPaid ? (
<span className="text-green-600 dark:text-green-400 text-xs font-medium">
Paid
</span>
) : row.releaseAt ? (
<span
className={
expired
? 'text-red-600 dark:text-red-400 text-xs font-semibold'
: 'text-amber-600 dark:text-amber-400 text-xs font-medium'
}
>
{expired ? '⚠ ' : '⏱ '}
{formatDateTime(row.releaseAt)}
{expired && ' (expired)'}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.scheduleOrigin} {row.scheduleDestination}
{row.scheduleDeparture && (
<div className="text-xs">{formatDateTime(row.scheduleDeparture)}</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && filtered.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No seats found</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -663,10 +663,6 @@ export default function SeatsPage() {
<div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-orange-500"></div>
<span className="text-sm text-muted-foreground">Under Maintenance</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span>

View File

@@ -26,20 +26,19 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
const handleSave = async () => {
setError(null);
if (!form.seatClassId || !form.maxWeightKg || !form.maxPiecesCount || !form.excessFeePerKg) {
setError('All fields are required'); return;
if (!form.excessFeePerKg) {
setError('Excess fee per kg is required'); return;
}
const payload = {
seatClassId: form.seatClassId,
maxWeightKg: parseInt(form.maxWeightKg),
maxPiecesCount: parseInt(form.maxPiecesCount),
excessFeePerKg: Math.round(parseFloat(form.excessFeePerKg) * 100),
};
const feeMinor = Math.round(parseFloat(form.excessFeePerKg) * 100);
try {
if (editing) {
await update.mutateAsync({ id: editing.id, ...payload });
await update.mutateAsync({ id: editing.id, excessFeePerKg: feeMinor });
} else {
await create.mutateAsync(payload);
// Create a rule for every seat class that doesn't already have one
const existingClassIds = new Set(allowances.map((a: BaggageAllowance) => a.seatClassId));
const missing = allClasses.filter(sc => !existingClassIds.has(sc.id));
if (!missing.length) { setError('All seat classes already have a rule. Use Edit to update.'); return; }
await Promise.all(missing.map(sc => create.mutateAsync({ seatClassId: sc.id, excessFeePerKg: feeMinor })));
}
resetForm();
onClose();
@@ -58,9 +57,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
<DataTable
data={allowances}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: BaggageAllowance) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
{ key: 'excessFeePerKg', label: 'Fare per kg (ETB)', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
actions={[
{
@@ -74,36 +71,18 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) },
]}
loading={false}
emptyMessage='No baggage allowance rules defined. Click "Add Allowance Rule" to create one.'
emptyMessage='No excess luggage tariff rates defined. Click "Add Luggage Rate" to create one.'
/>
)}
<Modal
isOpen={isOpen || !!editing}
onClose={() => { resetForm(); onClose(); }}
title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
title={editing ? 'Edit Excess Luggage Rate' : 'Add Excess Luggage Rate'}
size="md"
>
<div className="space-y-4">
{error && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>}
<div>
<label className="label">Seat Class *</label>
<select value={form.seatClassId} onChange={e => setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
<option value="">Select seat class...</option>
{allClasses.map(sc => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editing && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={form.maxWeightKg} onChange={e => setForm({ ...form, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={form.maxPiecesCount} onChange={e => setForm({ ...form, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={form.excessFeePerKg} onChange={e => setForm({ ...form, excessFeePerKg: e.target.value })} />
@@ -121,7 +100,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
onConfirm={() => remove.mutate(deleteConfirm.id!)}
onConfirm={() => remove.mutate(deleteConfirm.id!, { onSuccess: () => setDeleteConfirm({ isOpen: false, id: null }) })}
title="Delete Allowance Rule"
message="Are you sure you want to delete this baggage allowance rule?"
confirmText="Delete"

View File

@@ -119,7 +119,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Analytics & Reports',
items: [
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},

View File

@@ -31,4 +31,7 @@ export const bookingsApi = {
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
smartAssign: (bookingId: string) =>
apiClient.post(`/tickets/smart-assign/${bookingId}`, {}),
};

View File

@@ -2,6 +2,21 @@ import { apiClient } from '@/lib/api-client';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
getBackofficeStats: async () => {
const response = await apiClient.get<{
totalBookings: number;
totalNormalBookings: number;
totalPackageBookings: number;
totalTickets: number;
totalNormalTickets: number;
totalPackageTickets: number;
totalPassengers: number;
revenueByCurrency: { currency: string; totalMinor: number }[];
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
}>('/dashboard/backoffice-stats');
return response;
},
getStats: async () => {
try {
// Fetch bookings and passengers data in parallel

View File

@@ -46,6 +46,8 @@ export const bookingsApi = {
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
smartAssign: (bookingId: string) =>
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
};
// Passengers API
@@ -182,6 +184,25 @@ export const paymentsApi = {
addMethod: (data: any) => apiClient.post('/payments/methods', data),
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/payments/supplementary${query ? `?${query}` : ''}`);
if ((response as any)?.data) return (response as any).data;
return response;
},
markPaid: (id: string, providerTxnId?: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/mark-paid`, { providerTxnId }),
waive: (id: string, notes?: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/waive`, { notes }),
resend: (id: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/resend`, {}),
},
};
// Tickets API

View File

@@ -12,6 +12,7 @@ export const PERMS = {
tickets: {
view: 'edr_passenger_app:tickets:view',
manage: 'edr_passenger_app:tickets:manage',
generate: 'edr_passenger_app:tickets:generate',
},
// ── Master Data ────────────────────────────────────────────────

View File

@@ -4,8 +4,9 @@ export const formatCurrency = (amount: number, currency: string = 'ETB'): string
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
currencyDisplay: 'code',
minimumFractionDigits: 2,
}).format(amount / 100);
}).format(amount / 100).replace(/^([A-Z]{3})/, '$1 ').trim();
};
export const formatDate = (date?: string | Date | null, formatStr: string = 'MMM dd, yyyy'): string => {

View File

@@ -129,13 +129,17 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit {
requestBody,
);
this.logger.log(
`Waafi HPP_GETTRANINFO ref=${merchantOrderId} response: ${JSON.stringify(response)}`,
);
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
// wrongly resolved off a "no info" response.
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
this.logger.debug(
this.logger.warn(
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
);
return {

View File

@@ -100,6 +100,7 @@ export enum PaymentService {
export enum PaymentReferenceType {
BOOKING = "BOOKING",
SHIPMENT = "SHIPMENT",
SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE",
}