mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
@@ -0,0 +1,44 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
|
||||
// One driver ⇒ one truck: create/update must refuse a driver already assigned
|
||||
// to another (non-deleted) vehicle until they are detached.
|
||||
describe('VehiclesService driver assignment guard', () => {
|
||||
const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' };
|
||||
|
||||
const makeService = (findOne: jest.Mock) =>
|
||||
new VehiclesService(
|
||||
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
|
||||
it('rejects create when the driver is on another truck', async () => {
|
||||
// First findOne = plate uniqueness (null), second = driver holder.
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||
const svc = makeService(findOne);
|
||||
await expect(
|
||||
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects update when reassigning a driver still attached elsewhere', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById
|
||||
.mockResolvedValueOnce(otherTruck); // driver holder
|
||||
const svc = makeService(findOne);
|
||||
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows update that keeps the same driver on the same truck', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' });
|
||||
const svc = makeService(findOne);
|
||||
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined();
|
||||
expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,25 @@ export class VehiclesService {
|
||||
private readonly history: FleetHistoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A driver holds one truck at a time — reassignment requires detaching them
|
||||
* from their current truck first.
|
||||
* ponytail: app-level guard only (race window); add a partial unique index on
|
||||
* assigned_driver_id if concurrent fleet edits ever become real.
|
||||
*/
|
||||
private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise<void> {
|
||||
const holder = await this.vehicleRepo.findOne({
|
||||
where: exceptVehicleId
|
||||
? { assignedDriverId: driverId, id: Not(exceptVehicleId) }
|
||||
: { assignedDriverId: driverId },
|
||||
});
|
||||
if (holder) {
|
||||
throw new ConflictException(
|
||||
`This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
@@ -31,6 +50,10 @@ export class VehiclesService {
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.assignedDriverId) {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
@@ -121,6 +144,10 @@ export class VehiclesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||
}
|
||||
|
||||
const prev = {
|
||||
assignedDriverId: vehicle.assignedDriverId,
|
||||
assignedDriverName: vehicle.assignedDriverName,
|
||||
|
||||
@@ -556,6 +556,12 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/location')
|
||||
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
||||
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingLocation(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
|
||||
@@ -1030,6 +1030,28 @@ export class WarehouseInventoryService {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse location rows for one booking, trimmed for the customer portal:
|
||||
* no staff guard on the route, so only location fields leave the API —
|
||||
* never notes, fees or inspection internals.
|
||||
*/
|
||||
async bookingLocation(bookingId: string) {
|
||||
const items = await this.inventoryRepository.findAll({
|
||||
where: { bookingId },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return items.map((i) => ({
|
||||
id: i.id,
|
||||
bookingId: i.bookingId,
|
||||
status: i.status,
|
||||
arrivedAt: i.arrivedAt ?? null,
|
||||
warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null,
|
||||
yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null,
|
||||
zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.inventoryRepository.findById(id, {
|
||||
relations: { warehouse: { facility: true }, yard: true, zone: true },
|
||||
|
||||
@@ -23,15 +23,15 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
|
||||
export function WarehouseLocationCard({ bookingId }: WarehouseLocationCardProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["warehouse-inventory", bookingId],
|
||||
queryFn: () => warehouseService.listInventory({ bookingId }),
|
||||
queryKey: ["warehouse-inventory", "booking-location", bookingId],
|
||||
queryFn: () => warehouseService.bookingLocation(bookingId),
|
||||
});
|
||||
|
||||
const items = data ?? [];
|
||||
const latest = items[0];
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" padding="lg">
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<WarehouseIcon size={18} />
|
||||
|
||||
@@ -49,6 +49,12 @@ export const warehouseService = {
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Customer-safe location endpoint — the plain list is staff-only (403 for portal users). */
|
||||
bookingLocation: async (bookingId: string): Promise<WarehouseInventoryItem[]> => {
|
||||
const { data } = await client.get(`/warehouse-inventory/bookings/${bookingId}/location`);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
bookingSchedule: async (bookingId: string): Promise<BookingScheduleView> => {
|
||||
const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`);
|
||||
return data?.data ?? data ?? { schedule: null, wagon: null };
|
||||
|
||||
@@ -1,161 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { bookingsApi, seatsApi } from "@/lib/api";
|
||||
import { CheckCircle, Clock, AlertCircle, Ban, Armchair, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||
|
||||
const HOLD_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
function isExpired(releaseAt: string | null): boolean {
|
||||
if (!releaseAt) return false;
|
||||
return new Date(releaseAt) < new Date();
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface BookedSeatRow {
|
||||
interface SeatRow {
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
coachNumber: string;
|
||||
seatNumber: string;
|
||||
passengerCategory: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
fareMinor: number;
|
||||
currency: string;
|
||||
bookingStatus: string;
|
||||
paymentStatus: string;
|
||||
bookedAt: string;
|
||||
releaseAt: string | null;
|
||||
scheduleOrigin: string;
|
||||
scheduleDestination: string;
|
||||
scheduleDeparture: string;
|
||||
}
|
||||
|
||||
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;
|
||||
interface BlockedRow {
|
||||
id: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
reason: string;
|
||||
blockedBy: string;
|
||||
blockedAt: string;
|
||||
unblockAt: string | null;
|
||||
}
|
||||
|
||||
interface SeatStatusReport {
|
||||
summary: {
|
||||
paidCount: number;
|
||||
unpaidCount: number;
|
||||
expiredHoldCount: number;
|
||||
blockedCount: number;
|
||||
};
|
||||
paidSeats: SeatRow[];
|
||||
unpaidSeats: SeatRow[];
|
||||
expiredHolds: { holdId: string; seatIds: string[]; expiresAt: string; createdAt: string }[];
|
||||
blockedSeats: BlockedRow[];
|
||||
}
|
||||
|
||||
type Tab = "seats" | "blocked";
|
||||
|
||||
export default function SeatStatusReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("seats");
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: blockedSeats = [] } = useQuery({
|
||||
queryKey: ["blocked-seats"],
|
||||
queryFn: () =>
|
||||
seatsApi
|
||||
.getBlocked()
|
||||
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||
queryKey: ["report-schedules-all"],
|
||||
queryFn: () => apiClient.get("/reports/schedules?all=true"),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data, isLoading, isError } = useQuery<SeatStatusReport>({
|
||||
queryKey: ["seat-status-report", scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/seat-status?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
queryKey: ["seat-report-bookings"],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
});
|
||||
const allSeats: SeatRow[] = [
|
||||
...(data?.paidSeats ?? []),
|
||||
...(data?.unpaidSeats ?? []),
|
||||
];
|
||||
|
||||
const rows = useMemo<BookedSeatRow[]>(() => {
|
||||
const bookings: any[] = (bookingsData as any)?.data ?? (Array.isArray(bookingsData) ? bookingsData : []);
|
||||
const result: BookedSeatRow[] = [];
|
||||
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 || "",
|
||||
});
|
||||
}
|
||||
const filtered = allSeats.filter((r) => {
|
||||
const isPaid = r.bookingStatus === "CONFIRMED" || r.bookingStatus === "BOARDED";
|
||||
if (statusFilter === "PAID" && !isPaid) return false;
|
||||
if (statusFilter === "UNPAID" && isPaid) return false;
|
||||
if (search.trim()) {
|
||||
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 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;
|
||||
return true;
|
||||
});
|
||||
|
||||
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) => [
|
||||
if (!filtered.length) return;
|
||||
const headers = ["Booking Ref", "Passenger", "Category", "Seat Class", "Coach", "Seat", "Fare", "Payment", "Booking Status", "Booked At"];
|
||||
const rows = filtered.map((r) => [
|
||||
r.bookingRef,
|
||||
r.passengerName,
|
||||
r.seatNumber,
|
||||
r.coachNumber,
|
||||
r.passengerCategory,
|
||||
r.seatClassName ?? "—",
|
||||
r.coachNumber ?? "—",
|
||||
r.seatNumber ?? "—",
|
||||
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(",")),
|
||||
...rows.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.download = `seat-status-${scheduleId}-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -165,208 +125,256 @@ export default function SeatStatusReportPage() {
|
||||
<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
|
||||
Paid, unpaid, expired holds and blocked seats for a schedule
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<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">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 className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{(blockedSeats as any[]).length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
{(blockedSeats as any[]).length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
|
||||
{(blockedSeats as any[]).map((b: any) => (
|
||||
<div key={b.id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
Seat {b.seatNumber} · Coach {b.coachNumber}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate max-w-24" title={b.reason}>
|
||||
{b.reason}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{/* Schedule selector */}
|
||||
<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>
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-72">
|
||||
<label className="label">Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={statusFilter}
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")
|
||||
}
|
||||
value={scheduleId}
|
||||
onChange={(e) => {
|
||||
setScheduleId(e.target.value);
|
||||
setTab("seats");
|
||||
setStatusFilter("ALL");
|
||||
setSearch("");
|
||||
}}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
<option value="ALL">All Seats</option>
|
||||
<option value="PAID">Paid Only</option>
|
||||
<option value="UNPAID">Unpaid Only</option>
|
||||
<option value="">
|
||||
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
||||
</option>
|
||||
{schedules.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</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>
|
||||
)}
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</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>
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule above to load the seat status report</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 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">Paid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
|
||||
{data.summary.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">
|
||||
{data.summary.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">
|
||||
{data.summary.expiredHoldCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold time passed</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{data.summary.blockedCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex">
|
||||
<button
|
||||
onClick={() => setTab("seats")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "seats" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Seat Details{allSeats.length > 0 ? ` (${allSeats.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("blocked")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "blocked" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Blocked Seats{data.blockedSeats.length > 0 ? ` (${data.blockedSeats.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Seat Details Tab */}
|
||||
{tab === "seats" && <div className="card p-0">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 gap-4 flex-wrap">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Seat Details
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-xs"
|
||||
placeholder="Booking ref, passenger, seat…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input w-40"
|
||||
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>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filtered.length}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Booking Ref", "Passenger", "Category", "Seat Class · Coach · Seat", "Fare", "Payment", "Booked At"].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 whitespace-nowrap">
|
||||
{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.bookingStatus === "CONFIRMED" || row.bookingStatus === "BOARDED";
|
||||
return (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 font-mono font-semibold whitespace-nowrap">{row.bookingRef}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">{row.passengerName}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.passengerCategory}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
<span className="font-medium">{row.seatClassName ?? "—"}</span>
|
||||
{row.coachNumber && <span className="text-muted-foreground"> · {row.coachNumber}</span>}
|
||||
{row.seatNumber && <span className="text-muted-foreground"> · #{row.seatNumber}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap tabular-nums">
|
||||
{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 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.bookedAt ? formatDateTime(row.bookedAt) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
|
||||
No seats found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Blocked Seats Tab */}
|
||||
{tab === "blocked" && (
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Blocked Seats
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{["Seat Class · Coach · Seat", "Reason", "Blocked By", "Blocked At", "Unblock At"].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 whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.blockedSeats.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-sm text-muted-foreground">No blocked seats</td>
|
||||
</tr>
|
||||
)}
|
||||
{data.blockedSeats.map((b) => (
|
||||
<tr key={b.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">
|
||||
<span className="font-medium">{b.seatClassName ?? "—"}</span>
|
||||
{b.coachNumber && <span className="text-muted-foreground"> · {b.coachNumber}</span>}
|
||||
{b.seatNumber && <span className="text-muted-foreground"> · #{b.seatNumber}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground max-w-xs truncate" title={b.reason}>
|
||||
{b.reason}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs">{b.blockedBy}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatDateTime(b.blockedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{b.unblockAt ? formatDateTime(b.unblockAt) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !isLoading && scheduleId && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
No data found for this schedule.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user