From 053b2ffaf65dff0aefbdbb17e56349356a7d01c7 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 21 Jul 2026 18:13:03 +0300 Subject: [PATCH] Seats report page updates --- .../backoffice/src/app/reports/seats/page.tsx | 614 +++++++++--------- 1 file changed, 311 insertions(+), 303 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index 92e18b919..d120eced9 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -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("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({ + queryKey: ["report-schedules-all"], + queryFn: () => apiClient.get("/reports/schedules?all=true"), + }); + const schedules = schedulesRaw ?? []; + + const { data, isLoading, isError } = useQuery({ + 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(() => { - 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() {

Seat Status Report

- Track booked seats — paid vs unpaid, booking times, and hold release times + Paid, unpaid, expired holds and blocked seats for a schedule

- {/* Summary Cards */} -
-
-
-
-

Paid Seats

-

{paidCount}

-

Payment confirmed

-
- -
-
- -
-
-
-

Unpaid Seats

-

{unpaidCount}

-

Awaiting payment

-
- -
-
- -
-
-
-

Expired Holds

-

{expiredCount}

-

Hold time passed, not paid

-
- -
-
- -
-
-
-

Blocked Seats

-

- {(blockedSeats as any[]).length} -

-

Manually blocked

-
-
- {(blockedSeats as any[]).length > 0 && ( -
- {(blockedSeats as any[]).map((b: any) => ( -
- - Seat {b.seatNumber} · Coach {b.coachNumber} - - - {b.reason} - -
- ))} -
- )} -
-
- - {/* Filters */} + {/* Schedule selector */}
-
-
- - setSearch(e.target.value)} - /> -
-
- +
+
+
- - Export CSV -
- {isLoading && ( -

Loading...

- )} + {isLoading &&

Loading…

} + {isError &&

Failed to load report.

}
- {/* Table */} -
-
- - - - {[ - "Booking Ref", "Passenger", "Seat / Coach", "Fare", - "Payment", "Booked At", "Release At", "Route", - ].map((h) => ( - - ))} - - - - {filtered.map((row, i) => { - const isPaid = - row.paymentStatus === "SUCCEEDED" || - row.paymentStatus === "COMPLETED"; - const expired = isExpired(row.releaseAt); - return ( - - - - - - - - - - - ); - })} - -
- {h} -
- {row.bookingRef} - - {row.passengerName} - - {row.seatNumber} - {row.coachNumber !== "—" && ( - - {" · Coach "} - {row.coachNumber} - - )} - - {formatCurrency(row.fareMinor, row.currency)} - - - {isPaid ? "PAID" : row.paymentStatus} - - - {row.bookedAt ? formatDateTime(row.bookedAt) : "—"} - - {isPaid ? ( - - — Paid - - ) : row.releaseAt ? ( - - {expired ? "⚠ " : "⏱ "} - {formatDateTime(row.releaseAt)} - {expired && " (expired)"} - - ) : ( - - )} - - {row.scheduleOrigin} → {row.scheduleDestination} - {row.scheduleDeparture && ( -
- {formatDateTime(row.scheduleDeparture)} -
- )} -
+ {!scheduleId && ( +
+ +

Select a schedule above to load the seat status report

-
+ )} + + {data && ( + <> + {/* Summary Cards */} +
+
+
+
+

Paid Seats

+

+ {data.summary.paidCount} +

+

Payment confirmed

+
+ +
+
+ +
+
+
+

Unpaid Seats

+

+ {data.summary.unpaidCount} +

+

Awaiting payment

+
+ +
+
+ +
+
+
+

Expired Holds

+

+ {data.summary.expiredHoldCount} +

+

Hold time passed

+
+ +
+
+ +
+
+
+

Blocked Seats

+

+ {data.summary.blockedCount} +

+

Manually blocked

+
+ +
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Seat Details Tab */} + {tab === "seats" &&
+
+

+ Seat Details +

+
+ setSearch(e.target.value)} + /> + + + Export CSV + +
+
+
+ + + + {["Booking Ref", "Passenger", "Category", "Seat Class · Coach · Seat", "Fare", "Payment", "Booked At"].map((h) => ( + + ))} + + + + {filtered.map((row, i) => { + const isPaid = row.bookingStatus === "CONFIRMED" || row.bookingStatus === "BOARDED"; + return ( + + + + + + + + + + ); + })} + {filtered.length === 0 && ( + + + + )} + +
+ {h} +
{row.bookingRef}{row.passengerName}{row.passengerCategory} + {row.seatClassName ?? "—"} + {row.coachNumber && · {row.coachNumber}} + {row.seatNumber && · #{row.seatNumber}} + + {formatCurrency(row.fareMinor, row.currency)} + + + {isPaid ? "PAID" : row.paymentStatus} + + + {row.bookedAt ? formatDateTime(row.bookedAt) : "—"} +
+ No seats found +
+
+
} + + {/* Blocked Seats Tab */} + {tab === "blocked" && ( +
+
+

+ Blocked Seats +

+
+
+ + + + {["Seat Class · Coach · Seat", "Reason", "Blocked By", "Blocked At", "Unblock At"].map((h) => ( + + ))} + + + + {data.blockedSeats.length === 0 && ( + + + + )} + {data.blockedSeats.map((b) => ( + + + + + + + + ))} + +
+ {h} +
No blocked seats
+ {b.seatClassName ?? "—"} + {b.coachNumber && · {b.coachNumber}} + {b.seatNumber && · #{b.seatNumber}} + + {b.reason} + {b.blockedBy} + {formatDateTime(b.blockedAt)} + + {b.unblockAt ? formatDateTime(b.unblockAt) : "—"} +
+
+
+ )} + + )} + + {!data && !isLoading && scheduleId && ( +
+ No data found for this schedule. +
+ )}
); }