Seats report, supplementary change for bookings added

This commit is contained in:
Stephanos A
2026-07-16 11:43:04 +03:00
parent 3bc492ea83
commit a852b4e619
15 changed files with 1392 additions and 176 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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