Seats report page updates

This commit is contained in:
Stephanos A
2026-07-21 18:13:03 +03:00
parent 8b713d56eb
commit 053b2ffaf6

View File

@@ -1,161 +1,121 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import { CheckCircle, Clock, AlertCircle, Ban, Armchair, Download } from "lucide-react";
Download, import { apiClient } from "@/lib/api-client";
CheckCircle,
Clock,
AlertCircle,
} from "lucide-react";
import { bookingsApi, seatsApi } from "@/lib/api";
import Badge from "@/components/ui/Badge"; import Badge from "@/components/ui/Badge";
import ActionButton from "@/components/ui/ActionButton"; import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime, formatCurrency } from "@/lib/utils"; import { formatDateTime, formatCurrency } from "@/lib/utils";
const HOLD_DURATION_MS = 15 * 60 * 1000; interface ScheduleOption {
id: string;
function isExpired(releaseAt: string | null): boolean { label: string;
if (!releaseAt) return false;
return new Date(releaseAt) < new Date();
} }
interface BookedSeatRow { interface SeatRow {
bookingRef: string; bookingRef: string;
passengerName: string; passengerName: string;
coachNumber: string; passengerCategory: string;
seatNumber: string; coachNumber: string | null;
seatNumber: string | null;
seatClassName: string | null;
fareMinor: number; fareMinor: number;
currency: string; currency: string;
bookingStatus: string; bookingStatus: string;
paymentStatus: string; paymentStatus: string;
bookedAt: string; bookedAt: string;
releaseAt: string | null;
scheduleOrigin: string;
scheduleDestination: string;
scheduleDeparture: string;
} }
function getReleaseAt(booking: any, seat: any): string | null { interface BlockedRow {
const paymentStatus = booking.paymentIntent?.status || "PENDING"; id: string;
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED") return null; coachNumber: string | null;
if (booking.status === "CONFIRMED") return null; seatNumber: string | null;
if (seat?.holdExpiresAt) return seat.holdExpiresAt; seatClassName: string | null;
if (booking.createdAt) { reason: string;
return new Date( blockedBy: string;
new Date(booking.createdAt).getTime() + HOLD_DURATION_MS, blockedAt: string;
).toISOString(); unblockAt: string | null;
}
return 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() { export default function SeatStatusReportPage() {
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("seats");
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL"); const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { data: blockedSeats = [] } = useQuery({ const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ["blocked-seats"], queryKey: ["report-schedules-all"],
queryFn: () => queryFn: () => apiClient.get("/reports/schedules?all=true"),
seatsApi });
.getBlocked() const schedules = schedulesRaw ?? [];
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
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({ const allSeats: SeatRow[] = [
queryKey: ["seat-report-bookings"], ...(data?.paidSeats ?? []),
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), ...(data?.unpaidSeats ?? []),
}); ];
const rows = useMemo<BookedSeatRow[]>(() => { const filtered = allSeats.filter((r) => {
const bookings: any[] = (bookingsData as any)?.data ?? (Array.isArray(bookingsData) ? bookingsData : []); const isPaid = r.bookingStatus === "CONFIRMED" || r.bookingStatus === "BOARDED";
const result: BookedSeatRow[] = []; if (statusFilter === "PAID" && !isPaid) return false;
for (const booking of bookings) { if (statusFilter === "UNPAID" && isPaid) return false;
if (booking.status === "CANCELLED") continue; if (search.trim()) {
const seats: any[] = booking.seats || []; const q = search.toLowerCase();
const paymentStatus = booking.paymentIntent?.status || "PENDING"; return (
for (const seat of seats) { r.bookingRef.toLowerCase().includes(q) ||
result.push({ r.passengerName.toLowerCase().includes(q) ||
bookingRef: booking.bookingRef || "—", (r.seatNumber ?? "").toLowerCase().includes(q) ||
passengerName: (r.coachNumber ?? "").toLowerCase().includes(q)
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; return true;
}, [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 = () => { const doExport = () => {
if (!filtered.length) { if (!filtered.length) return;
alert("No data to export"); const headers = ["Booking Ref", "Passenger", "Category", "Seat Class", "Coach", "Seat", "Fare", "Payment", "Booking Status", "Booked At"];
return; const rows = filtered.map((r) => [
}
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.bookingRef,
r.passengerName, r.passengerName,
r.seatNumber, r.passengerCategory,
r.coachNumber, r.seatClassName ?? "—",
r.coachNumber ?? "—",
r.seatNumber ?? "—",
formatCurrency(r.fareMinor, r.currency), formatCurrency(r.fareMinor, r.currency),
r.paymentStatus, r.paymentStatus,
r.bookingStatus, r.bookingStatus,
r.bookedAt ? formatDateTime(r.bookedAt) : "—", r.bookedAt ? formatDateTime(r.bookedAt) : "—",
r.releaseAt ? formatDateTime(r.releaseAt) : "—",
r.scheduleOrigin,
r.scheduleDestination,
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : "—",
]); ]);
const csv = [ const csv = [
headers.map((h) => `"${h}"`).join(","), headers.map((h) => `"${h}"`).join(","),
...csvRows.map((row) => row.map((v) => `"${v}"`).join(",")), ...rows.map((row) => row.map((v) => `"${v}"`).join(",")),
].join("\n"); ].join("\n");
const blob = new Blob([csv], { type: "text/csv" }); const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; 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(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
@@ -165,208 +125,256 @@ export default function SeatStatusReportPage() {
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1> <h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
<p className="text-muted-foreground mt-1"> <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> </p>
</div> </div>
{/* Summary Cards */} {/* Schedule selector */}
<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 */}
<div className="card"> <div className="card">
<div className="flex flex-wrap items-end gap-4"> <div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-48"> <div className="flex-1 min-w-72">
<label className="label">Search</label> <label className="label">Schedule</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 <select
className="input" className="input"
value={statusFilter} value={scheduleId}
onChange={(e) => onChange={(e) => {
setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID") setScheduleId(e.target.value);
} setTab("seats");
setStatusFilter("ALL");
setSearch("");
}}
disabled={loadingSchedules}
> >
<option value="ALL">All Seats</option> <option value="">
<option value="PAID">Paid Only</option> {loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
<option value="UNPAID">Unpaid Only</option> </option>
{schedules.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select> </select>
</div> </div>
<ActionButton
icon={Download}
variant="secondary"
onClick={doExport}
disabled={isLoading}
>
Export CSV
</ActionButton>
</div> </div>
{isLoading && ( {isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
<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> </div>
{/* Table */} {!scheduleId && (
<div className="card p-0"> <div className="card py-16 text-center text-muted-foreground">
<div className="overflow-x-auto"> <Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
<table className="w-full"> <p>Select a schedule above to load the seat status report</p>
<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> </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> </div>
); );
} }