Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-08-05 10:39:35 +03:00
948 changed files with 99715 additions and 11357 deletions

View File

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

View File

@@ -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 */}

View File

@@ -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' },

View File

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

View File

@@ -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&apos;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>
);
}

View File

@@ -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&apos;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 })}

View File

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

View File

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

View File

@@ -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)}`,
),
};

View File

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

View File

@@ -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');

View File

@@ -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`),

View File

@@ -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);
},
}));

View 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;
}

View File

@@ -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));
}

View File

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