mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -32,7 +32,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
function BookingsPageContent() {
|
||||
const canManage = usePermission(PERMS.bookings.manage);
|
||||
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
||||
const [generateTicketBooking, setGenerateTicketBooking] = useState<any>(null);
|
||||
@@ -50,20 +50,26 @@ function BookingsPageContent() {
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true,
|
||||
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
|
||||
contactEmail: true, passengerCount: false, paymentStatus: true, providerTxnId: false, totalMinor: true, status: true, createdAt: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Single source of truth for the query params — the export path must send the same
|
||||
// filters as the table, otherwise exporting while filtered dumps every booking.
|
||||
const buildQueryFilters = (overrides: Partial<BookingFilters> = {}): BookingFilters => ({
|
||||
...filters,
|
||||
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
|
||||
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
|
||||
...(extraFilters.providerTxnId && { providerTxnId: extraFilters.providerTxnId }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['bookings', filters, extraFilters],
|
||||
queryFn: () => bookingsApi.getAll({
|
||||
...filters,
|
||||
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
|
||||
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
}),
|
||||
queryFn: () => bookingsApi.getAll(buildQueryFilters()),
|
||||
});
|
||||
|
||||
const smartAssignMutation = useMutation({
|
||||
@@ -112,7 +118,8 @@ function BookingsPageContent() {
|
||||
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
|
||||
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
|
||||
{ key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'providerTxnId', label: 'Provider Txn ID' },
|
||||
{ key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
|
||||
];
|
||||
|
||||
@@ -120,7 +127,7 @@ function BookingsPageContent() {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (!cols.length) { alert('Please select at least one column'); return; }
|
||||
// Fetch all records (not just current page)
|
||||
const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 });
|
||||
const allData = await bookingsApi.getAll(buildQueryFilters({ page: 1, pageSize: 9999 }));
|
||||
const exportItems = (allData?.items || []).filter((b: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
|
||||
@@ -138,6 +145,7 @@ function BookingsPageContent() {
|
||||
case 'contactEmail': return booking.contactEmail || 'N/A';
|
||||
case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0));
|
||||
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
|
||||
case 'providerTxnId': return booking.paymentIntent?.providerTxnId || 'N/A';
|
||||
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
|
||||
case 'status': return booking.status;
|
||||
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
|
||||
@@ -274,6 +282,11 @@ function BookingsPageContent() {
|
||||
<div>
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}</div>
|
||||
{booking.paymentIntent?.providerTxnId && (
|
||||
<div className="text-xs font-mono text-muted-foreground truncate max-w-[10rem]" title={booking.paymentIntent.providerTxnId}>
|
||||
{booking.paymentIntent.providerTxnId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -358,6 +371,12 @@ function BookingsPageContent() {
|
||||
<input type="date" className="input" value={extraFilters.dateTo}
|
||||
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Provider Txn ID</label>
|
||||
<input type="text" className="input" placeholder="Transaction / order ref"
|
||||
value={extraFilters.providerTxnId}
|
||||
onChange={(e) => setExtraFilters({ ...extraFilters, providerTxnId: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -471,6 +490,9 @@ function BookingsPageContent() {
|
||||
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
||||
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
</div>
|
||||
<Field label="Payment Method" value={b.paymentIntent?.method} />
|
||||
<Field label="Provider Txn ID" value={b.paymentIntent?.providerTxnId} mono truncate />
|
||||
<Field label="Merchant Order ID" value={b.paymentIntent?.merchantOrderId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
Banknote,
|
||||
ArrowRight,
|
||||
ScanLine,
|
||||
Ban,
|
||||
} from "lucide-react";
|
||||
import { SEAT_BLOCK_REASON_CATEGORY_LABELS } from "@edr/types";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
@@ -161,6 +163,10 @@ function DashboardPageContent() {
|
||||
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
|
||||
}, 0);
|
||||
|
||||
const blockedLoss = stats?.blockedSeatRevenueLoss;
|
||||
// Never summed across currencies — each is shown on its own line, largest first.
|
||||
const blockedLossRows = blockedLoss?.lossByCurrency ?? [];
|
||||
|
||||
const normalRows = stats?.revenueByCurrency ?? [];
|
||||
const packageRows = stats?.packageRevenueByCurrency ?? [];
|
||||
const normalGrand = calcGrand(normalRows);
|
||||
@@ -320,6 +326,73 @@ function DashboardPageContent() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
|
||||
dashboard makes no extra request for it. */}
|
||||
<div className="card flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-1.5">
|
||||
<Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Blocked Seats
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] text-muted-foreground">
|
||||
Last {blockedLoss?.periodDays ?? 30}d
|
||||
</span>
|
||||
</div>
|
||||
{statsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{blockedLossRows.length === 0 ? (
|
||||
<p className="text-3xl font-bold text-foreground tabular-nums">
|
||||
{formatCurrency(0, "ETB")}
|
||||
</p>
|
||||
) : (
|
||||
blockedLossRows.map((row, i) => (
|
||||
<p
|
||||
key={row.currency}
|
||||
className={
|
||||
i === 0
|
||||
? "text-3xl font-bold text-foreground tabular-nums"
|
||||
: "text-lg font-semibold text-foreground tabular-nums"
|
||||
}
|
||||
>
|
||||
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Estimated potential revenue never earned
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Seats blocked</span>
|
||||
<span className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{(blockedLoss?.blockedSeatCount ?? 0).toLocaleString()} across{" "}
|
||||
{(blockedLoss?.schedulesAffected ?? 0).toLocaleString()} schedules
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Top reason</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{blockedLoss?.topReasonCategory
|
||||
? (SEAT_BLOCK_REASON_CATEGORY_LABELS[blockedLoss.topReasonCategory] ??
|
||||
blockedLoss.topReasonCategory)
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/reports/blocked-seats"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
|
||||
>
|
||||
View full report <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue breakdown */}
|
||||
|
||||
@@ -200,6 +200,7 @@ export default function PaymentMethodsPage() {
|
||||
const paymentTypes = [
|
||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||
{ value: 'CBE_BIRR', label: 'CBE Birr' },
|
||||
{ value: 'CBE_BILL', label: 'CBE Bill Payment' },
|
||||
{ value: 'EBIRR', label: 'eBirr' },
|
||||
{ value: 'WAAFI', label: 'Waafi' },
|
||||
{ value: 'DMONEY', label: 'dMoney' },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Ban,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Info,
|
||||
Layers,
|
||||
TrendingDown,
|
||||
Train,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type {
|
||||
BlockedSeatLossDetail,
|
||||
BlockedSeatLossSchedule,
|
||||
BlockedSeatRevenueLossReport,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
SEAT_BLOCK_REASON_CATEGORIES,
|
||||
SEAT_BLOCK_REASON_CATEGORY_LABELS,
|
||||
UNCATEGORIZED_REASON_CATEGORY,
|
||||
} from "@edr/types";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import {
|
||||
blockedSeatsLossApi,
|
||||
type BlockedSeatsLossFilters,
|
||||
type ScheduleOption,
|
||||
} from "@/lib/api/blocked-seats-loss";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
import { formatCurrency, formatDateTime } from "@/lib/utils";
|
||||
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
|
||||
import { useTheme } from "@/lib/theme-store";
|
||||
|
||||
interface RouteOption {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface TrainOption {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Fixed domain order for reason categories, so a filter never repaints the survivors. */
|
||||
const REASON_CATEGORY_ORDER: readonly string[] = [
|
||||
...SEAT_BLOCK_REASON_CATEGORIES,
|
||||
UNCATEGORIZED_REASON_CATEGORY,
|
||||
];
|
||||
|
||||
function reasonLabel(category: string | null): string {
|
||||
const key = category ?? UNCATEGORIZED_REASON_CATEGORY;
|
||||
return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key;
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
const TABLE_PAGE_SIZE = 25;
|
||||
|
||||
export default function BlockedSeatRevenueLossPage() {
|
||||
const isDark = useTheme((s) => s.isDark);
|
||||
const palette = getChartPalette(isDark);
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
const [dateFrom, setDateFrom] = useState(isoDaysAgo(30));
|
||||
const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]);
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
const [reasonCategory, setReasonCategory] = useState("");
|
||||
const [blockedBy, setBlockedBy] = useState("");
|
||||
const [blockedByInput, setBlockedByInput] = useState("");
|
||||
const [sortBy, setSortBy] = useState("lossMinor");
|
||||
const [page, setPage] = useState(1);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [showMethodology, setShowMethodology] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const filters: BlockedSeatsLossFilters = useMemo(
|
||||
() => ({
|
||||
dateFrom,
|
||||
dateTo,
|
||||
scheduleId,
|
||||
routeId,
|
||||
trainId,
|
||||
reasonCategory,
|
||||
blockedBy,
|
||||
sortBy,
|
||||
}),
|
||||
[dateFrom, dateTo, scheduleId, routeId, trainId, reasonCategory, blockedBy, sortBy],
|
||||
);
|
||||
|
||||
const { data: schedules = [], isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||
queryKey: ["report-schedules-all"],
|
||||
queryFn: blockedSeatsLossApi.getSchedules,
|
||||
});
|
||||
|
||||
const { data: routes = [] } = useQuery<RouteOption[]>({
|
||||
queryKey: ["routes"],
|
||||
queryFn: () => apiClient.get<RouteOption[]>("/routes"),
|
||||
});
|
||||
|
||||
const { data: trains = [] } = useQuery<TrainOption[]>({
|
||||
queryKey: ["fleet-trains"],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<TrainOption[] | { items: TrainOption[] }>(
|
||||
"/fleet/trains",
|
||||
);
|
||||
return Array.isArray(res) ? res : (res?.items ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useQuery<BlockedSeatRevenueLossReport>({
|
||||
queryKey: ["blocked-seats-revenue-loss", filters, page],
|
||||
// Hold the previous render while refetching rather than flashing a skeleton.
|
||||
placeholderData: (previous) => previous,
|
||||
queryFn: () =>
|
||||
blockedSeatsLossApi.getReport({ ...filters, page, pageSize: TABLE_PAGE_SIZE }),
|
||||
});
|
||||
|
||||
const summary = data?.summary;
|
||||
const scheduleRows = data?.schedules ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE));
|
||||
|
||||
const resetFilters = () => {
|
||||
setDateFrom(isoDaysAgo(30));
|
||||
setDateTo(new Date().toISOString().split("T")[0]);
|
||||
setScheduleId("");
|
||||
setRouteId("");
|
||||
setTrainId("");
|
||||
setReasonCategory("");
|
||||
setBlockedBy("");
|
||||
setBlockedByInput("");
|
||||
setSortBy("lossMinor");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const onFilterChange = (apply: () => void) => {
|
||||
apply();
|
||||
setPage(1);
|
||||
setExpanded(new Set());
|
||||
};
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const doExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const csv = await blockedSeatsLossApi.exportCsv(filters);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `blocked-seats-revenue-loss-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Chart data ────────────────────────────────────────────────────────────
|
||||
// Money is only comparable within one currency, so both charts are scoped to the
|
||||
// dominant currency on this page and say so.
|
||||
const chartCurrency = summary?.lossByCurrency[0]?.currency ?? "ETB";
|
||||
const otherCurrencies = (summary?.lossByCurrency ?? [])
|
||||
.slice(1)
|
||||
.map((c) => c.currency);
|
||||
|
||||
const topSchedules = useMemo(
|
||||
() =>
|
||||
scheduleRows
|
||||
.filter((s) => s.currency === chartCurrency)
|
||||
.slice()
|
||||
.sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor)
|
||||
.slice(0, 10)
|
||||
.map((s) => ({
|
||||
label: `${s.trainNumber} · ${new Date(s.departureAt).toLocaleDateString("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
})}`,
|
||||
estimatedLossMinor: s.estimatedLossMinor,
|
||||
adjustedLossMinor: s.adjustedLossMinor,
|
||||
blockedSeatCount: s.blockedSeatCount,
|
||||
scheduleId: s.scheduleId,
|
||||
})),
|
||||
[scheduleRows, chartCurrency],
|
||||
);
|
||||
|
||||
const reasonBreakdown = useMemo(() => {
|
||||
const rows = (summary?.topReasonCategories ?? []).filter(
|
||||
(r) => r.currency === chartCurrency,
|
||||
);
|
||||
const total = rows.reduce((sum, r) => sum + r.estimatedLossMinor, 0);
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
// Colour by fixed domain position, not by rank in this filtered view.
|
||||
color: categoricalColor(palette, REASON_CATEGORY_ORDER.indexOf(r.reasonCategory)),
|
||||
sharePercent: total > 0 ? (r.estimatedLossMinor / total) * 100 : 0,
|
||||
}));
|
||||
}, [summary, chartCurrency, palette]);
|
||||
|
||||
const hasData = (summary?.blockedSeatCount ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Blocked Seat Revenue Loss</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Potential fare revenue that could never be earned because seats were blocked
|
||||
out of sale — with per-seat detail on who blocked each one and why.
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={doExport}
|
||||
loading={exporting}
|
||||
disabled={!hasData}
|
||||
>
|
||||
Download CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* ── Filter bar: one row above everything it scopes ───────────────── */}
|
||||
<div className="card">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="label" htmlFor="bsl-from">
|
||||
Departing from
|
||||
</label>
|
||||
<input
|
||||
id="bsl-from"
|
||||
type="date"
|
||||
className="input"
|
||||
value={dateFrom}
|
||||
onChange={(e) => onFilterChange(() => setDateFrom(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="bsl-to">
|
||||
Departing to
|
||||
</label>
|
||||
<input
|
||||
id="bsl-to"
|
||||
type="date"
|
||||
className="input"
|
||||
value={dateTo}
|
||||
onChange={(e) => onFilterChange(() => setDateTo(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="label" htmlFor="bsl-schedule">
|
||||
Schedule
|
||||
</label>
|
||||
<select
|
||||
id="bsl-schedule"
|
||||
className="input"
|
||||
value={scheduleId}
|
||||
disabled={loadingSchedules}
|
||||
onChange={(e) => onFilterChange(() => setScheduleId(e.target.value))}
|
||||
>
|
||||
<option value="">
|
||||
{loadingSchedules ? "Loading schedules…" : "All schedules"}
|
||||
</option>
|
||||
{schedules.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="bsl-route">
|
||||
Route
|
||||
</label>
|
||||
<select
|
||||
id="bsl-route"
|
||||
className="input"
|
||||
value={routeId}
|
||||
onChange={(e) => onFilterChange(() => setRouteId(e.target.value))}
|
||||
>
|
||||
<option value="">All routes</option>
|
||||
{routes.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="bsl-train">
|
||||
Train
|
||||
</label>
|
||||
<select
|
||||
id="bsl-train"
|
||||
className="input"
|
||||
value={trainId}
|
||||
onChange={(e) => onFilterChange(() => setTrainId(e.target.value))}
|
||||
>
|
||||
<option value="">All trains</option>
|
||||
{trains.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.number} — {t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="bsl-reason">
|
||||
Reason category
|
||||
</label>
|
||||
<select
|
||||
id="bsl-reason"
|
||||
className="input"
|
||||
value={reasonCategory}
|
||||
onChange={(e) => onFilterChange(() => setReasonCategory(e.target.value))}
|
||||
>
|
||||
<option value="">All reasons</option>
|
||||
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{reasonLabel(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="bsl-blocker">
|
||||
Blocked by
|
||||
</label>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onFilterChange(() => setBlockedBy(blockedByInput.trim()));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="bsl-blocker"
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder="Name or user id — press Enter"
|
||||
value={blockedByInput}
|
||||
onChange={(e) => setBlockedByInput(e.target.value)}
|
||||
onBlur={() => onFilterChange(() => setBlockedBy(blockedByInput.trim()))}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="bsl-sort">
|
||||
Sort by
|
||||
</label>
|
||||
<select
|
||||
id="bsl-sort"
|
||||
className="input w-56"
|
||||
value={sortBy}
|
||||
onChange={(e) => onFilterChange(() => setSortBy(e.target.value))}
|
||||
>
|
||||
<option value="lossMinor">Largest estimated loss</option>
|
||||
<option value="lossMinorAsc">Smallest estimated loss</option>
|
||||
<option value="blockedSeatCount">Most blocked seats</option>
|
||||
<option value="departureAt">Earliest departure</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
</div>
|
||||
{isError && (
|
||||
<p className="text-xs text-red-500 mt-3">
|
||||
Failed to load the report. Check the filters and try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && !data ? (
|
||||
<div className="card py-16 text-center text-muted-foreground">Loading report…</div>
|
||||
) : !hasData ? (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Ban className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No blocked seats cost revenue in this window.</p>
|
||||
<p className="text-xs mt-1">
|
||||
Widen the date range, or clear the route/train/reason filters.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isFetching ? "opacity-60 transition-opacity space-y-6" : "space-y-6"}>
|
||||
{/* ── Summary tiles ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Estimated loss
|
||||
</p>
|
||||
{summary?.lossByCurrency.map((c, i) => (
|
||||
<p
|
||||
key={c.currency}
|
||||
className={
|
||||
i === 0
|
||||
? "text-2xl font-bold mt-2 text-foreground"
|
||||
: "text-base font-semibold text-foreground"
|
||||
}
|
||||
>
|
||||
{formatCurrency(c.estimatedLossMinor, c.currency)}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground mt-1">At full occupancy</p>
|
||||
</div>
|
||||
<TrendingDown className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-sm font-medium">Adjusted loss</p>
|
||||
{summary?.lossByCurrency.map((c, i) => (
|
||||
<p
|
||||
key={c.currency}
|
||||
className={
|
||||
i === 0
|
||||
? "text-2xl font-bold mt-2 text-foreground"
|
||||
: "text-base font-semibold text-foreground"
|
||||
}
|
||||
>
|
||||
{formatCurrency(c.adjustedLossMinor, c.currency)}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Scaled by each train's load factor
|
||||
</p>
|
||||
</div>
|
||||
<Layers className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
|
||||
</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-foreground tabular-nums">
|
||||
{summary?.blockedSeatCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Across {summary?.schedulesAffected.toLocaleString()} schedules
|
||||
</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Seat-days blocked
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-foreground tabular-nums">
|
||||
{scheduleRows
|
||||
.reduce(
|
||||
(sum, s) => sum + s.blocks.reduce((n, b) => n + b.daysBlocked, 0),
|
||||
0,
|
||||
)
|
||||
.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">On this page</p>
|
||||
</div>
|
||||
<Train className="h-8 w-8 text-slate-500 opacity-30 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Charts ────────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Top schedules by estimated loss
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Estimated loss in {chartCurrency}, largest first
|
||||
{otherCurrencies.length > 0 && (
|
||||
<> · {otherCurrencies.join(", ")} shown in the table below</>
|
||||
)}
|
||||
</p>
|
||||
{topSchedules.length === 0 ? (
|
||||
<div className="h-[320px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No schedules in {chartCurrency} on this page
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={40 * topSchedules.length + 48}>
|
||||
<BarChart
|
||||
data={topSchedules}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 72, bottom: 8, left: 8 }}
|
||||
barCategoryGap="28%"
|
||||
>
|
||||
<CartesianGrid
|
||||
horizontal={false}
|
||||
stroke={palette.grid}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11, fill: palette.textMuted }}
|
||||
tickFormatter={(v: number) => (v / 100).toLocaleString()}
|
||||
axisLine={{ stroke: palette.axis }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
width={128}
|
||||
tick={{ fontSize: 11, fill: palette.textMuted }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{ fill: palette.grid, fillOpacity: 0.35 }}
|
||||
contentStyle={{
|
||||
background: palette.tooltipBg,
|
||||
border: `1px solid ${palette.tooltipBorder}`,
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
formatCurrency(value, chartCurrency),
|
||||
name === "estimatedLossMinor" ? "Estimated" : "Adjusted",
|
||||
]}
|
||||
/>
|
||||
{/* One series, one hue — bar length already encodes magnitude. */}
|
||||
<Bar
|
||||
dataKey="estimatedLossMinor"
|
||||
fill={palette.sequential}
|
||||
radius={[0, 4, 4, 0]}
|
||||
maxBarSize={24}
|
||||
isAnimationActive={false}
|
||||
label={{
|
||||
position: "right",
|
||||
fontSize: 11,
|
||||
fill: palette.textMuted,
|
||||
formatter: (v: number) => formatCurrency(v, chartCurrency),
|
||||
}}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Where the loss comes from
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
||||
Share of estimated loss by reason category, in {chartCurrency}
|
||||
</p>
|
||||
|
||||
{/* Part-to-whole: one horizontal stacked bar, 2px surface gaps between
|
||||
segments (no borders), with a legend carrying identity in text. */}
|
||||
<div
|
||||
className="flex w-full h-7 rounded-md overflow-hidden"
|
||||
role="img"
|
||||
aria-label={`Estimated loss by reason category: ${reasonBreakdown
|
||||
.map((r) => `${reasonLabel(r.reasonCategory)} ${r.sharePercent.toFixed(0)}%`)
|
||||
.join(", ")}`}
|
||||
>
|
||||
{reasonBreakdown.map((r, i) => (
|
||||
<div
|
||||
key={r.reasonCategory}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${r.sharePercent}%`,
|
||||
background: r.color,
|
||||
marginRight: i < reasonBreakdown.length - 1 ? 2 : 0,
|
||||
}}
|
||||
title={`${reasonLabel(r.reasonCategory)} — ${formatCurrency(
|
||||
r.estimatedLossMinor,
|
||||
r.currency,
|
||||
)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend doubles as the table view — the numbers are never tooltip-gated. */}
|
||||
<table className="w-full text-sm mt-4">
|
||||
<thead>
|
||||
<tr className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium py-2">Reason</th>
|
||||
<th className="text-right font-medium py-2">Seats</th>
|
||||
<th className="text-right font-medium py-2">Share</th>
|
||||
<th className="text-right font-medium py-2">Estimated loss</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{reasonBreakdown.map((r) => (
|
||||
<tr key={r.reasonCategory}>
|
||||
<td className="py-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ background: r.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-foreground">
|
||||
{reasonLabel(r.reasonCategory)}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">
|
||||
{r.count.toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">
|
||||
{r.sharePercent.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums text-foreground font-medium">
|
||||
{formatCurrency(r.estimatedLossMinor, r.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Schedule table with per-seat drill-down ───────────────────── */}
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3 flex items-center justify-between gap-4 flex-wrap">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Affected schedules
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expand a row to see every blocked seat
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
"Schedule",
|
||||
"Route",
|
||||
"Departure",
|
||||
"Blocked",
|
||||
"Load factor",
|
||||
"Estimated loss",
|
||||
"Adjusted loss",
|
||||
].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">
|
||||
{scheduleRows.map((row) => (
|
||||
<ScheduleRow
|
||||
key={row.scheduleId}
|
||||
row={row}
|
||||
expanded={expanded.has(row.scheduleId)}
|
||||
onToggle={() => toggleExpanded(row.scheduleId)}
|
||||
/>
|
||||
))}
|
||||
{scheduleRows.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No schedules on this page
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => {
|
||||
setPage(p);
|
||||
setExpanded(new Set());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Methodology, verbatim from the API ──────────────────────────── */}
|
||||
{data && (
|
||||
<div className="card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMethodology((v) => !v)}
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
aria-expanded={showMethodology}
|
||||
>
|
||||
{showMethodology ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
How this is calculated
|
||||
</span>
|
||||
</button>
|
||||
{showMethodology && (
|
||||
<div className="mt-4 space-y-4 text-sm text-muted-foreground">
|
||||
<p className="leading-relaxed">{data.meta.methodology}</p>
|
||||
<div>
|
||||
<p className="font-medium text-foreground mb-2">What is excluded</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{data.meta.exclusions.map((e) => (
|
||||
<li key={e}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Fares priced at nationality</dt>
|
||||
<dd className="text-foreground">{data.meta.nationalityAssumption}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Departure window</dt>
|
||||
<dd className="text-foreground">
|
||||
{formatDateTime(data.meta.dateFrom)} — {formatDateTime(data.meta.dateTo)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Schedules affected</dt>
|
||||
<dd className="text-foreground tabular-nums">{data.meta.total}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>Schedules with no fare on file</dt>
|
||||
<dd className="text-foreground tabular-nums">
|
||||
{data.meta.schedulesWithoutFare}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{data.meta.schedulesWithoutFare > 0 && (
|
||||
<p className="text-xs">
|
||||
{data.meta.schedulesWithoutFare} schedule
|
||||
{data.meta.schedulesWithoutFare === 1 ? "" : "s"} could not be priced (no
|
||||
route and no fare rules). Their blocked seats are counted, but carry no
|
||||
monetary claim.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Schedule row + drill-down ───────────────────────────────────────────────
|
||||
|
||||
function ScheduleRow({
|
||||
row,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
row: BlockedSeatLossSchedule;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-foreground">{row.trainNumber}</span>
|
||||
<Badge variant="status" status={row.status}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.originStation} → {row.destinationStation}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDateTime(row.departureAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
|
||||
{row.loadFactorPercent}%{" "}
|
||||
<span className="opacity-70">
|
||||
({row.soldSeats}/{row.sellableSeats})
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums font-medium whitespace-nowrap">
|
||||
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
||||
</td>
|
||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{formatCurrency(row.adjustedLossMinor, row.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<td colSpan={7} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
|
||||
<BlockDetailTable blocks={row.blocks} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
||||
const { paged, page, totalPages, setPage } = usePagination(blocks, 50);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
{[
|
||||
"Coach · Seat",
|
||||
"Class",
|
||||
"Scope",
|
||||
"Reason",
|
||||
"Blocked by",
|
||||
"Approved by",
|
||||
"Blocked at",
|
||||
"Until",
|
||||
"Days",
|
||||
"Estimated loss",
|
||||
].map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{paged.map((b) => (
|
||||
<tr key={b.blockId}>
|
||||
<td className="px-3 py-2 whitespace-nowrap font-medium text-foreground">
|
||||
{b.coachNumber ?? "—"} · #{b.seatNumber ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{b.seatClassName ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<Badge>{b.blockType === "SCHEDULE" ? "Schedule" : "Global"}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-sm">
|
||||
<span className="flex flex-col gap-1">
|
||||
<Badge className="w-fit">{reasonLabel(b.reasonCategory)}</Badge>
|
||||
<span className="text-muted-foreground break-words">{b.reason}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-foreground">
|
||||
{b.blockedByName ?? (b.blockedBy === "SYSTEM" ? "System" : "Unknown")}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{b.approvedBy ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{formatDateTime(b.blockedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{b.stillBlocked ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">Still blocked</span>
|
||||
) : (
|
||||
formatDateTime(b.unblockAt)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{b.daysBlocked}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
|
||||
{formatCurrency(b.estimatedLossMinor, b.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock, RefreshCw } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
@@ -28,6 +28,7 @@ interface Schedule {
|
||||
destinationStation?: { id: string; name: string };
|
||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||
isPackageOnly?: boolean;
|
||||
liveStatus?: { delayMinutes: number } | null;
|
||||
}
|
||||
|
||||
interface Train {
|
||||
@@ -461,6 +462,16 @@ export default function SchedulesPage() {
|
||||
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'liveStatus.delayMinutes',
|
||||
label: 'Delay',
|
||||
sortable: true,
|
||||
render: (schedule: Schedule) => {
|
||||
const delay = schedule.liveStatus?.delayMinutes ?? 0;
|
||||
if (delay <= 0) return <span className="text-sm text-muted-foreground">On time</span>;
|
||||
return <span className="edr-badge edr-badge-warning">+{delay} min</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'coachAssignments',
|
||||
label: 'Coaches',
|
||||
@@ -493,6 +504,15 @@ export default function SchedulesPage() {
|
||||
|
||||
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||
|
||||
const applyDelayMutation = useMutation({
|
||||
mutationFn: ({ id, minutes }: { id: string; minutes: number }) =>
|
||||
apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
|
||||
});
|
||||
const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||
const [delayMinutesInput, setDelayMinutesInput] = useState('');
|
||||
const [delayError, setDelayError] = useState<string | null>(null);
|
||||
|
||||
const scheduleActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
@@ -500,6 +520,17 @@ export default function SchedulesPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Report Delay',
|
||||
onClick: (schedule: Schedule) => {
|
||||
setDelayMinutesInput('');
|
||||
setDelayError(null);
|
||||
setDelayPrompt({ isOpen: true, item: schedule });
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
icon: Clock,
|
||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||
@@ -660,6 +691,67 @@ export default function SchedulesPage() {
|
||||
isLoading={cancelScheduleMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={delayPrompt.isOpen}
|
||||
onClose={() => setDelayPrompt({ isOpen: false, item: null })}
|
||||
title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`}
|
||||
size="sm"
|
||||
>
|
||||
{delayPrompt.item && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const minutes = parseInt(delayMinutesInput, 10);
|
||||
if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; }
|
||||
try {
|
||||
await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes });
|
||||
setDelayPrompt({ isOpen: false, item: null });
|
||||
} catch (err: any) {
|
||||
setDelayError(err?.response?.data?.message || 'Failed to apply delay.');
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
{delayError && (
|
||||
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{delayError}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-lg border border-border bg-muted/30">
|
||||
<span className="text-sm text-muted-foreground">Current reported delay</span>
|
||||
{(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? (
|
||||
<span className="edr-badge edr-badge-warning">+{delayPrompt.item.liveStatus?.delayMinutes} min</span>
|
||||
) : (
|
||||
<span className="text-sm font-medium">On time</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Delay (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={delayMinutesInput}
|
||||
onChange={(e) => setDelayMinutesInput(e.target.value)}
|
||||
placeholder="e.g. 60"
|
||||
className="input"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Adds to the current reported delay above and pushes every downstream station's
|
||||
check-in cutoff back by this many minutes. Use a negative number to correct an
|
||||
over-reported delay.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => setDelayPrompt({ isOpen: false, item: null })}>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton type="submit" loading={applyDelayMutation.isPending}>
|
||||
Apply Delay
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
|
||||
@@ -2,11 +2,18 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { usePermissionStrict } from '@/lib/use-permission';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton'
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react';
|
||||
import {
|
||||
SEAT_BLOCK_REASON_CATEGORIES,
|
||||
SEAT_BLOCK_REASON_CATEGORY_LABELS,
|
||||
SeatBlockReasonCategory,
|
||||
} from '@edr/types';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
||||
@@ -17,15 +24,44 @@ export default function SeatsPage() {
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
||||
const [blockReason, setBlockReason] = useState('');
|
||||
// Reporting bucket for the block — drives the reason breakdown in the Blocked Seat
|
||||
// Revenue Loss report. Free-text `reason` stays the operator's detail.
|
||||
const [blockCategory, setBlockCategory] = useState<SeatBlockReasonCategory>(
|
||||
SeatBlockReasonCategory.Other,
|
||||
);
|
||||
const [showBlockCoachModal, setShowBlockCoachModal] = useState(false);
|
||||
const [selectedCoach, setSelectedCoach] = useState<any>(null);
|
||||
const [blockCoachReason, setBlockCoachReason] = useState('');
|
||||
const [blockCoachCategory, setBlockCoachCategory] = useState<SeatBlockReasonCategory>(
|
||||
SeatBlockReasonCategory.Other,
|
||||
);
|
||||
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
|
||||
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||
const [maintenanceReason, setMaintenanceReason] = useState('');
|
||||
const [showIssueBookingModal, setShowIssueBookingModal] = useState(false);
|
||||
const [issueBookingCoach, setIssueBookingCoach] = useState<any>(null);
|
||||
const [issueBookingForm, setIssueBookingForm] = useState({
|
||||
bookingKind: 'STAFF' as 'STAFF' | 'PASSENGER',
|
||||
// No seatClassId — the seat's class is already fixed by the reservation; the backend
|
||||
// resolves it from the seat's own coach type + nationality tier.
|
||||
passengerName: '',
|
||||
dateOfBirth: '',
|
||||
idDocumentType: 'PASSPORT' as 'NATIONAL_ID' | 'PASSPORT',
|
||||
idDocumentNumber: '',
|
||||
passportNumber: '',
|
||||
nationality: '' as '' | 'Ethiopian' | 'Djiboutian' | 'Other',
|
||||
phone: '',
|
||||
email: '',
|
||||
});
|
||||
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Issuing a booking off a reserved seat needs edr_passenger_app:tickets:generate
|
||||
// (POST /bookings/reservations/:seatId/issue is guarded by @PassengerStaffStrict).
|
||||
// Strict: being an admin is not enough, the permission has to be granted.
|
||||
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
queryKey: ['schedules'],
|
||||
queryFn: () => schedulesApi.getAll(),
|
||||
@@ -87,13 +123,14 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) =>
|
||||
seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }),
|
||||
mutationFn: ({ seatId, reason, reasonCategory }: any) =>
|
||||
seatsApi.block(seatId, { reason, reasonCategory, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }),
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
setShowBlockModal(false);
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
setBlockCategory(SeatBlockReasonCategory.Other);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +142,27 @@ export default function SeatsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const issueBookingMutation = useMutation({
|
||||
mutationFn: ({ seatId, data }: { seatId: string; data: any }) =>
|
||||
bookingsApi.issueFromReservation(seatId, data),
|
||||
onSuccess: (result: any) => {
|
||||
invalidateSeatData();
|
||||
// Always show the reference — the PASSENGER path also needs the PNR alongside the
|
||||
// pay link (staff need to know which booking a seat belongs to, whether it's
|
||||
// awaiting payment or already ticketed), so no longer auto-closing for STAFF.
|
||||
setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef });
|
||||
},
|
||||
});
|
||||
|
||||
// Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat —
|
||||
// distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved).
|
||||
const cancelReservationMutation = useMutation({
|
||||
mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule),
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
},
|
||||
});
|
||||
|
||||
const removeSeatMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
||||
onSuccess: () => {
|
||||
@@ -142,17 +200,18 @@ export default function SeatsPage() {
|
||||
const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []);
|
||||
|
||||
const blockCoachMutation = useMutation({
|
||||
mutationFn: async ({ coachId, reason }: any) => {
|
||||
mutationFn: async ({ coachId, reason, reasonCategory }: any) => {
|
||||
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
|
||||
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
|
||||
const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined;
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) })));
|
||||
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, reasonCategory, ...(scheduleId ? { scheduleId } : {}) })));
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateSeatData();
|
||||
setShowBlockCoachModal(false);
|
||||
setSelectedCoach(null);
|
||||
setBlockCoachReason('');
|
||||
setBlockCoachCategory(SeatBlockReasonCategory.Other);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -186,11 +245,76 @@ export default function SeatsPage() {
|
||||
};
|
||||
|
||||
const handleUnblock = async (seat: any) => {
|
||||
if (confirm('Are you sure you want to unblock this seat?')) {
|
||||
if (confirm('Release this reservation and make the seat available to the public?')) {
|
||||
await unblockMutation.mutateAsync(seat.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already
|
||||
// released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking
|
||||
// invalidates its payment link immediately, so warn staff explicitly about that.
|
||||
const handleCancelReservation = async (seat: any) => {
|
||||
if (!selectedSchedule) return;
|
||||
if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) {
|
||||
await cancelReservationMutation.mutateAsync(seat.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIssueBooking = (seat: any, coach: any) => {
|
||||
if (activeTab !== 'schedule' || !selectedSchedule) {
|
||||
alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.');
|
||||
return;
|
||||
}
|
||||
setSelectedSeat(seat);
|
||||
setIssueBookingCoach(coach);
|
||||
setIssueBookingResult(null);
|
||||
setIssueBookingForm({
|
||||
bookingKind: 'STAFF',
|
||||
passengerName: '',
|
||||
dateOfBirth: '',
|
||||
idDocumentType: 'PASSPORT',
|
||||
idDocumentNumber: '',
|
||||
passportNumber: '',
|
||||
nationality: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
});
|
||||
setShowIssueBookingModal(true);
|
||||
};
|
||||
|
||||
const submitIssueBooking = async () => {
|
||||
const schedule = schedules.find((s: any) => s.id === selectedSchedule);
|
||||
if (!schedule?.originStation?.id || !schedule?.destinationStation?.id) {
|
||||
alert('Could not resolve this schedule\'s origin/destination stations.');
|
||||
return;
|
||||
}
|
||||
if (!issueBookingForm.passengerName.trim() || !issueBookingForm.dateOfBirth) {
|
||||
alert('Traveler name and date of birth are required.');
|
||||
return;
|
||||
}
|
||||
if (!issueBookingForm.nationality) {
|
||||
alert('Select a nationality.');
|
||||
return;
|
||||
}
|
||||
if (issueBookingForm.idDocumentType === 'PASSPORT' && !issueBookingForm.passportNumber.trim()) {
|
||||
alert('Passport number is required.');
|
||||
return;
|
||||
}
|
||||
if (issueBookingForm.bookingKind === 'PASSENGER' && !issueBookingForm.phone.trim()) {
|
||||
alert('Phone number is required for a passenger booking (used to send the payment link).');
|
||||
return;
|
||||
}
|
||||
await issueBookingMutation.mutateAsync({
|
||||
seatId: selectedSeat.id,
|
||||
data: {
|
||||
scheduleId: selectedSchedule,
|
||||
originStationId: schedule.originStation.id,
|
||||
destinationStationId: schedule.destinationStation.id,
|
||||
...issueBookingForm,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveSeat = (seat: any) => {
|
||||
setSelectedSeat(seat);
|
||||
setShowRemoveModal(true);
|
||||
@@ -247,15 +371,15 @@ export default function SeatsPage() {
|
||||
alert('Please provide a reason for blocking');
|
||||
return;
|
||||
}
|
||||
await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason });
|
||||
await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason, reasonCategory: blockCoachCategory });
|
||||
};
|
||||
|
||||
const submitBlock = async () => {
|
||||
if (!blockReason.trim()) {
|
||||
alert('Please provide a reason for blocking');
|
||||
alert('Please provide a reason for the reservation');
|
||||
return;
|
||||
}
|
||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason, reasonCategory: blockCategory });
|
||||
};
|
||||
|
||||
const submitRemoveSeat = async () => {
|
||||
@@ -347,9 +471,12 @@ export default function SeatsPage() {
|
||||
handleBlock={handleBlock}
|
||||
handleRemoveSeat={handleRemoveSeat}
|
||||
handleUnblock={handleUnblock}
|
||||
handleCancelReservation={handleCancelReservation}
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
canIssueBooking={canIssueBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -442,9 +569,12 @@ export default function SeatsPage() {
|
||||
handleBlock={handleBlock}
|
||||
handleRemoveSeat={handleRemoveSeat}
|
||||
handleUnblock={handleUnblock}
|
||||
handleCancelReservation={handleCancelReservation}
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
canIssueBooking={canIssueBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -464,9 +594,12 @@ export default function SeatsPage() {
|
||||
handleBlock={handleBlock}
|
||||
handleRemoveSeat={handleRemoveSeat}
|
||||
handleUnblock={handleUnblock}
|
||||
handleCancelReservation={handleCancelReservation}
|
||||
handleUndoRemove={handleUndoRemove}
|
||||
handleSetMaintenance={handleSetMaintenance}
|
||||
handleClearMaintenance={handleClearMaintenance}
|
||||
handleIssueBooking={handleIssueBooking}
|
||||
canIssueBooking={canIssueBooking}
|
||||
hideNumber={true}
|
||||
/>
|
||||
))}
|
||||
@@ -749,15 +882,32 @@ export default function SeatsPage() {
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
}}
|
||||
title="Block Seat"
|
||||
title="Reserve Seat"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
||||
</p>
|
||||
<div>
|
||||
<label className="label">Reason for Blocking *</label>
|
||||
<label className="label">Reason Category</label>
|
||||
<select
|
||||
className="input"
|
||||
value={blockCategory}
|
||||
onChange={(e) => setBlockCategory(e.target.value as SeatBlockReasonCategory)}
|
||||
>
|
||||
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{SEAT_BLOCK_REASON_CATEGORY_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Groups this block in the Blocked Seat Revenue Loss report.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reason for Reservation *</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
@@ -782,12 +932,197 @@ export default function SeatsPage() {
|
||||
loading={blockMutation.isPending}
|
||||
disabled={!blockReason.trim()}
|
||||
>
|
||||
Block Seat
|
||||
Reserve Seat
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showIssueBookingModal}
|
||||
onClose={() => {
|
||||
setShowIssueBookingModal(false);
|
||||
setSelectedSeat(null);
|
||||
setIssueBookingCoach(null);
|
||||
setIssueBookingResult(null);
|
||||
}}
|
||||
title="Issue Booking"
|
||||
size="md"
|
||||
>
|
||||
{issueBookingResult ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{issueBookingResult.payUrl
|
||||
? 'Booking created. A payment link has been sent via SMS to the traveler.'
|
||||
: 'Booking confirmed and ticketed.'}
|
||||
</p>
|
||||
{issueBookingResult.bookingRef && (
|
||||
<div>
|
||||
<label className="label">Booking Reference (PNR)</label>
|
||||
<div className="input font-mono font-semibold text-sm">{issueBookingResult.bookingRef}</div>
|
||||
</div>
|
||||
)}
|
||||
{issueBookingResult.payUrl && (
|
||||
<div>
|
||||
<label className="label">Payment Link</label>
|
||||
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setShowIssueBookingModal(false);
|
||||
setSelectedSeat(null);
|
||||
setIssueBookingCoach(null);
|
||||
setIssueBookingResult(null);
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Issue a booking for seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
|
||||
<strong>{issueBookingCoach?.coachNumber}</strong>
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="label">Booking Type *</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'STAFF' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'STAFF' }))}
|
||||
>
|
||||
Staff (no fee)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'PASSENGER' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'PASSENGER' }))}
|
||||
>
|
||||
Passenger (pay via link)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Traveler Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={issueBookingForm.passengerName}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passengerName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Date of Birth *</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={issueBookingForm.dateOfBirth}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, dateOfBirth: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Nationality *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={issueBookingForm.nationality}
|
||||
onChange={(e) => {
|
||||
const nationality = e.target.value as 'Ethiopian' | 'Djiboutian' | 'Other';
|
||||
setIssueBookingForm((f) => ({
|
||||
...f,
|
||||
nationality,
|
||||
// National ID is Ethiopian-only — switch back to Passport for anyone else.
|
||||
idDocumentType: nationality === 'Ethiopian' ? f.idDocumentType : 'PASSPORT',
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">Select nationality...</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">ID Document Type *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={issueBookingForm.idDocumentType}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentType: e.target.value as 'NATIONAL_ID' | 'PASSPORT' }))}
|
||||
>
|
||||
<option value="PASSPORT">Passport</option>
|
||||
{issueBookingForm.nationality === 'Ethiopian' && (
|
||||
<option value="NATIONAL_ID">National ID</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{issueBookingForm.idDocumentType === 'NATIONAL_ID' ? (
|
||||
<div>
|
||||
<label className="label">National ID Number</label>
|
||||
<input
|
||||
className="input"
|
||||
value={issueBookingForm.idDocumentNumber}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentNumber: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="label">Passport Number *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={issueBookingForm.passportNumber}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passportNumber: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Phone{issueBookingForm.bookingKind === 'PASSENGER' ? ' * (payment link sent here)' : ''}</label>
|
||||
<input
|
||||
className="input"
|
||||
value={issueBookingForm.phone}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
placeholder="+251911234567"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Email</label>
|
||||
<input
|
||||
className="input"
|
||||
value={issueBookingForm.email}
|
||||
onChange={(e) => setIssueBookingForm((f) => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowIssueBookingModal(false);
|
||||
setSelectedSeat(null);
|
||||
setIssueBookingCoach(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton onClick={submitIssueBooking} loading={issueBookingMutation.isPending}>
|
||||
{issueBookingForm.bookingKind === 'STAFF' ? 'Issue Ticket' : 'Send Payment Link'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showRemoveModal}
|
||||
onClose={() => {
|
||||
@@ -847,6 +1182,20 @@ export default function SeatsPage() {
|
||||
This will block all {selectedCoach?.seats?.length || 0} seats in this coach.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reason Category</label>
|
||||
<select
|
||||
className="input"
|
||||
value={blockCoachCategory}
|
||||
onChange={(e) => setBlockCoachCategory(e.target.value as SeatBlockReasonCategory)}
|
||||
>
|
||||
{SEAT_BLOCK_REASON_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{SEAT_BLOCK_REASON_CATEGORY_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reason for Blocking *</label>
|
||||
<textarea
|
||||
@@ -970,9 +1319,12 @@ interface SeatIconProps {
|
||||
handleBlock: (seat: any) => void;
|
||||
handleRemoveSeat: (seat: any) => void;
|
||||
handleUnblock: (seat: any) => void;
|
||||
handleCancelReservation: (seat: any) => void;
|
||||
handleUndoRemove: (seat: any) => void;
|
||||
handleSetMaintenance: (seat: any) => void;
|
||||
handleClearMaintenance: (seat: any) => void;
|
||||
handleIssueBooking: (seat: any, coach: any) => void;
|
||||
canIssueBooking?: boolean;
|
||||
}
|
||||
|
||||
function SeatIcon({
|
||||
@@ -986,9 +1338,12 @@ function SeatIcon({
|
||||
handleBlock,
|
||||
handleRemoveSeat,
|
||||
handleUnblock,
|
||||
handleCancelReservation,
|
||||
handleUndoRemove,
|
||||
handleSetMaintenance,
|
||||
handleClearMaintenance,
|
||||
handleIssueBooking,
|
||||
canIssueBooking = false,
|
||||
}: SeatIconProps) {
|
||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||
@@ -1022,6 +1377,10 @@ function SeatIcon({
|
||||
const color = getSeatColor(status);
|
||||
const canBlock = status === 'AVAILABLE';
|
||||
const canUnblock = status === 'BLOCKED';
|
||||
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
|
||||
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
|
||||
// it's not reachable via canUnblock anymore; this is the seat's own release path.
|
||||
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
||||
const canMaintenance = false;
|
||||
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
||||
|
||||
@@ -1036,7 +1395,7 @@ function SeatIcon({
|
||||
{isBedCoach ? (
|
||||
<div
|
||||
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}${seat.bookingRef ? ` - PNR ${seat.bookingRef} (${seat.reservationStatus})` : ''}`}
|
||||
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Bed className="w-7 h-7 text-white" />
|
||||
@@ -1044,21 +1403,30 @@ function SeatIcon({
|
||||
) : (
|
||||
<div
|
||||
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||
title={`${seat.seatNumber} - ${status}`}
|
||||
title={`${seat.seatNumber} - ${status}${seat.bookingRef ? ` - PNR ${seat.bookingRef} (${seat.reservationStatus})` : ''}`}
|
||||
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Armchair className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
|
||||
{seat.bookingRef && (
|
||||
<span
|
||||
className="text-[9px] leading-3 font-semibold text-foreground/80 mt-0.5 max-w-[3.5rem] truncate"
|
||||
title={`PNR ${seat.bookingRef} — ${seat.reservationStatus}${seat.reservationPassengerName ? ` — ${seat.reservationPassengerName}` : ''}`}
|
||||
>
|
||||
{seat.bookingRef}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
|
||||
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||
{canBlock && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleBlock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Block seat"
|
||||
title="Reserve seat"
|
||||
>
|
||||
<Lock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
@@ -1071,15 +1439,35 @@ function SeatIcon({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canUnblock && (
|
||||
{canCancelReservation && (
|
||||
<button
|
||||
onClick={() => handleUnblock(seat)}
|
||||
onClick={() => handleCancelReservation(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Unblock seat"
|
||||
title={`Cancel reservation (PNR ${seat.bookingRef}) — invalidates the payment link`}
|
||||
>
|
||||
<Unlock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
{canUnblock && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleUnblock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Release reservation"
|
||||
>
|
||||
<Unlock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
{canIssueBooking && (
|
||||
<button
|
||||
onClick={() => handleIssueBooking(seat, coach)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||
title="Issue booking"
|
||||
>
|
||||
<TicketIcon className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canMaintenance && (
|
||||
<button
|
||||
onClick={() => handleSetMaintenance(seat)}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
Moon,
|
||||
Sun,
|
||||
Armchair,
|
||||
Ban,
|
||||
Grid3x3,
|
||||
Banknote,
|
||||
Activity,
|
||||
@@ -64,7 +65,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
|
||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
|
||||
]
|
||||
@@ -123,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
items: [
|
||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
|
||||
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { BlockedSeatRevenueLossReport } from '@edr/types';
|
||||
|
||||
/** One entry of `GET /reports/schedules`. */
|
||||
export interface ScheduleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
departureAt: string;
|
||||
isPackage: boolean;
|
||||
}
|
||||
|
||||
/** Every filter the report accepts. Empty strings are dropped before the request. */
|
||||
export interface BlockedSeatsLossFilters {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
scheduleId?: string;
|
||||
routeId?: string;
|
||||
trainId?: string;
|
||||
coachId?: string;
|
||||
reasonCategory?: string;
|
||||
blockedBy?: string;
|
||||
nationality?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
/** Serializes filters, omitting blanks so the API applies its own defaults. */
|
||||
export function toQueryString(filters: BlockedSeatsLossFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export const blockedSeatsLossApi = {
|
||||
getReport: (filters: BlockedSeatsLossFilters) =>
|
||||
apiClient.get<BlockedSeatRevenueLossReport>(
|
||||
`/reports/blocked-seats-revenue-loss?${toQueryString(filters)}`,
|
||||
),
|
||||
|
||||
getSchedules: () => apiClient.get<ScheduleOption[]>('/reports/schedules?all=true'),
|
||||
|
||||
/**
|
||||
* CSV export. `getRaw` because the endpoint streams a bare CSV body with no
|
||||
* `{ success, data }` envelope for `get` to unwrap.
|
||||
*/
|
||||
exportCsv: (filters: BlockedSeatsLossFilters) =>
|
||||
apiClient.getRaw<string>(
|
||||
`/reports/blocked-seats-revenue-loss/export?${toQueryString(filters)}`,
|
||||
),
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export const bookingsApi = {
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.bookingType) params.append('bookingType', filters.bookingType);
|
||||
if (filters?.paymentStatus) params.append('paymentStatus', filters.paymentStatus);
|
||||
if (filters?.providerTxnId) params.append('providerTxnId', filters.providerTxnId);
|
||||
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
|
||||
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { BlockedSeatRevenueLossStat } from '@edr/types';
|
||||
import { DashboardStats, RevenueData } from '@/types';
|
||||
|
||||
export const dashboardApi = {
|
||||
@@ -12,6 +13,7 @@ export const dashboardApi = {
|
||||
totalPackageTickets: number;
|
||||
totalPassengers: number;
|
||||
blockedSeatsCount: number;
|
||||
blockedSeatRevenueLoss: BlockedSeatRevenueLossStat;
|
||||
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
}>('/dashboard/backoffice-stats');
|
||||
|
||||
@@ -48,6 +48,14 @@ export const bookingsApi = {
|
||||
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
||||
smartAssign: (bookingId: string) =>
|
||||
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
|
||||
// Converts a reserved (blocked) seat into a real booking — STAFF (fee-waived, ticket
|
||||
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
|
||||
issueFromReservation: (seatId: string, data: any) =>
|
||||
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
|
||||
// Cancels a seat's still-PENDING_PAYMENT reservation (payment link sent, not yet paid) and
|
||||
// releases the seat — the old payment link stops working immediately.
|
||||
cancelReservation: (seatId: string, scheduleId: string) =>
|
||||
apiClient.delete<any>(`/bookings/reservations/${seatId}?scheduleId=${scheduleId}`),
|
||||
};
|
||||
|
||||
// Passengers API
|
||||
@@ -137,6 +145,8 @@ export const schedulesApi = {
|
||||
update: (id: string, data: any) => apiClient.patch<any>(`/schedules/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
|
||||
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/schedules/${id}/status`, { status }),
|
||||
applyDelay: (id: string, delayMinutes: number, fromSequence?: number) =>
|
||||
apiClient.post<any>(`/schedules/${id}/delay`, { delayMinutes, fromSequence }),
|
||||
assignCoaches: (scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
|
||||
apiClient.post<any>(`/schedules/${scheduleId}/coaches`, { coaches }),
|
||||
getAssignedCoaches: (scheduleId: string) => apiClient.get<any>(`/schedules/${scheduleId}/coaches`),
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthState {
|
||||
setUser: (user: AdminUser, token: string) => void;
|
||||
initialize: () => void;
|
||||
hasPermission: (key: string) => boolean;
|
||||
hasPermissionStrict: (key: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@@ -123,4 +124,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (user.isSuperAdmin || user.isOrgAdmin) return true;
|
||||
return user.permissions.includes(key);
|
||||
},
|
||||
|
||||
// No super-admin / org-admin bypass — mirrors PassengerStaffStrict on the API,
|
||||
// so we don't render actions that would 403.
|
||||
hasPermissionStrict: (key: string) => {
|
||||
const { user } = get();
|
||||
if (!user) return false;
|
||||
return user.permissions.includes(key);
|
||||
},
|
||||
}));
|
||||
|
||||
66
apps/edr-passenger-web/backoffice/src/lib/chart-palette.ts
Normal file
66
apps/edr-passenger-web/backoffice/src/lib/chart-palette.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Chart palette.
|
||||
*
|
||||
* Categorical slots are assigned in fixed order and never cycled — a series keeps its
|
||||
* hue when a filter removes its neighbours. Both modes are separately stepped for their
|
||||
* own surface, not an automatic flip of the light values.
|
||||
*
|
||||
* Validated against this app's card surfaces (light `#ffffff`, dark `#0f1729`) with the
|
||||
* dataviz six-check validator, six slots, adjacent pairlist:
|
||||
* light — CVD ΔE 9.1, normal-vision ΔE 19.6, contrast WARN on aqua/yellow/magenta
|
||||
* dark — CVD ΔE 8.4, normal-vision ΔE 19.3, contrast all ≥ 3:1
|
||||
* The light-mode contrast WARN obliges *relief*: every chart using these slots ships a
|
||||
* legend with visible text labels and a table view of the same numbers.
|
||||
*/
|
||||
|
||||
export interface ChartPalette {
|
||||
/** Categorical slots, in fixed assignment order. */
|
||||
categorical: readonly string[];
|
||||
/** Single hue for magnitude — one colour for every bar in a one-series chart. */
|
||||
sequential: string;
|
||||
/** Recessive chrome. */
|
||||
grid: string;
|
||||
axis: string;
|
||||
/** Text tokens — labels never wear the data colour. */
|
||||
textMuted: string;
|
||||
/** Surface, for the 2px gaps and rings that separate marks. */
|
||||
surface: string;
|
||||
tooltipBg: string;
|
||||
tooltipBorder: string;
|
||||
}
|
||||
|
||||
const LIGHT: ChartPalette = {
|
||||
categorical: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300'],
|
||||
sequential: '#2a78d6',
|
||||
grid: '#e1e0d9',
|
||||
axis: '#c3c2b7',
|
||||
textMuted: '#898781',
|
||||
surface: '#ffffff',
|
||||
tooltipBg: '#ffffff',
|
||||
tooltipBorder: 'rgba(11,11,11,0.10)',
|
||||
};
|
||||
|
||||
const DARK: ChartPalette = {
|
||||
categorical: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300'],
|
||||
sequential: '#3987e5',
|
||||
grid: '#2c2c2a',
|
||||
axis: '#383835',
|
||||
textMuted: '#898781',
|
||||
surface: '#0f1729',
|
||||
tooltipBg: '#0f1729',
|
||||
tooltipBorder: 'rgba(255,255,255,0.10)',
|
||||
};
|
||||
|
||||
export function getChartPalette(isDark: boolean): ChartPalette {
|
||||
return isDark ? DARK : LIGHT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour for a categorical member, keyed by its position in a **stable** ordering of the
|
||||
* whole domain — never by its rank in the current filtered view, so filtering does not
|
||||
* repaint the survivors. Past the last slot everything folds into one neutral bucket
|
||||
* rather than inventing a hue no CVD check would pass.
|
||||
*/
|
||||
export function categoricalColor(palette: ChartPalette, index: number): string {
|
||||
return palette.categorical[index] ?? palette.textMuted;
|
||||
}
|
||||
@@ -13,3 +13,15 @@ import { useAuthStore } from './auth-store';
|
||||
export function usePermission(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermission(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
|
||||
* permission must be explicitly granted. Use it wherever the API endpoint is
|
||||
* guarded with PassengerStaffStrict, so the UI matches what the API allows.
|
||||
*
|
||||
* Usage:
|
||||
* const canIssue = usePermissionStrict(PERMS.tickets.generate);
|
||||
*/
|
||||
export function usePermissionStrict(key: string): boolean {
|
||||
return useAuthStore((s) => s.hasPermissionStrict(key));
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface BookingFilters {
|
||||
status?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
/** Payment provider transaction / order / merchant reference — partial, case-insensitive. */
|
||||
providerTxnId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
|
||||
Reference in New Issue
Block a user