mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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, """);
|
||||
return `<!DOCTYPE html>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 & 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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
324
apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx
Normal file
324
apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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}`, {}),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -100,6 +100,7 @@ export enum PaymentService {
|
||||
export enum PaymentReferenceType {
|
||||
BOOKING = "BOOKING",
|
||||
SHIPMENT = "SHIPMENT",
|
||||
SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user