mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
727 lines
23 KiB
TypeScript
727 lines
23 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Group,
|
|
Menu,
|
|
Paper,
|
|
RingProgress,
|
|
Select,
|
|
SimpleGrid,
|
|
Skeleton,
|
|
Stack,
|
|
Text,
|
|
ThemeIcon,
|
|
} from "@mantine/core";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowRight,
|
|
CalendarClock,
|
|
CalendarDays,
|
|
Eye,
|
|
Inbox,
|
|
MoreHorizontal,
|
|
Package,
|
|
Ruler,
|
|
Train,
|
|
TrainFront,
|
|
Weight,
|
|
} from "lucide-react";
|
|
import type { ColumnDef } from "@edr/ui-common";
|
|
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
|
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
|
import {
|
|
BookingPipeline,
|
|
HeroChip,
|
|
totalBookingCount,
|
|
WindowStatusPill,
|
|
} from "@/components/trainScheduling/batchVisuals";
|
|
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
|
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
|
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
|
|
|
const fmtTons = (n: number) =>
|
|
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
|
|
|
const fmtMeters = (n: number) =>
|
|
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
|
|
|
const fmtScheduleDate = (iso: string | null) =>
|
|
iso
|
|
? new Intl.DateTimeFormat("en-GB", {
|
|
weekday: "short",
|
|
day: "2-digit",
|
|
month: "short",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
timeZone: "Africa/Addis_Ababa",
|
|
}).format(new Date(iso)) + " EAT"
|
|
: "No date";
|
|
|
|
const splitDate = (iso: string | null) => {
|
|
if (!iso) return { day: "—", time: "" };
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
|
|
return {
|
|
day: new Intl.DateTimeFormat("en-GB", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
timeZone: "Africa/Addis_Ababa",
|
|
}).format(date),
|
|
time:
|
|
new Intl.DateTimeFormat("en-GB", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
timeZone: "Africa/Addis_Ababa",
|
|
}).format(date) + " EAT",
|
|
};
|
|
};
|
|
|
|
/** Capacity ring color: gold normally, red once over capacity. */
|
|
function ringColor(pct: number) {
|
|
if (pct >= 100) return "#fa5252";
|
|
return "#F2A516";
|
|
}
|
|
|
|
/** Capacity ring that keeps the explicit allocated/max numbers underneath. */
|
|
function CapacityRing({
|
|
pct,
|
|
label,
|
|
current,
|
|
max,
|
|
}: {
|
|
pct: number;
|
|
label: string;
|
|
current: string;
|
|
max: string;
|
|
}) {
|
|
const clamped = Math.min(100, Math.max(0, pct));
|
|
const color = ringColor(pct);
|
|
return (
|
|
<Stack gap={4} align="center" style={{ flex: 1 }}>
|
|
<RingProgress
|
|
size={104}
|
|
thickness={9}
|
|
roundCaps
|
|
sections={[{ value: clamped, color }]}
|
|
rootColor="var(--mantine-color-gray-1)"
|
|
label={
|
|
<Stack gap={0} align="center">
|
|
<Text ta="center" size="lg" fw={800} lh={1} style={{ color }}>
|
|
{Math.round(pct)}%
|
|
</Text>
|
|
<Text ta="center" size="9px" c="dimmed" fw={600}>
|
|
{label}
|
|
</Text>
|
|
</Stack>
|
|
}
|
|
/>
|
|
<Stack gap={0} align="center">
|
|
<Text size="xs" fw={700} c="dark.4">
|
|
{current}
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
of {max}
|
|
</Text>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
/** Small percent chip used in the table's capacity column. */
|
|
function CapacityChip({
|
|
icon: Icon,
|
|
pct,
|
|
text,
|
|
}: {
|
|
icon: typeof Weight;
|
|
pct: number | null;
|
|
text: string;
|
|
}) {
|
|
const over = pct != null && pct >= 100;
|
|
return (
|
|
<Group
|
|
gap={4}
|
|
wrap="nowrap"
|
|
style={{
|
|
padding: "2px 8px",
|
|
borderRadius: 8,
|
|
background: over ? "var(--mantine-color-red-0)" : "var(--mantine-color-gray-1)",
|
|
border: `1px solid ${over ? "var(--mantine-color-red-2)" : "var(--mantine-color-gray-2)"}`,
|
|
}}
|
|
>
|
|
<Icon size={12} color={over ? "var(--mantine-color-red-6)" : "var(--mantine-color-gray-6)"} />
|
|
<Text size="xs" fw={700} c={over ? "red.7" : "gray.7"} lh={1.2}>
|
|
{pct != null ? `${Math.round(pct)}%` : "—"}
|
|
</Text>
|
|
<Text size="10px" c="dimmed" lh={1.2}>
|
|
{text}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
function weightPctOf(s: BatchBoardSchedule) {
|
|
return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0
|
|
? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100
|
|
: null;
|
|
}
|
|
function lengthPctOf(s: BatchBoardSchedule) {
|
|
return s.capacity.maxLengthMeters && s.capacity.maxLengthMeters > 0
|
|
? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100
|
|
: null;
|
|
}
|
|
|
|
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
|
const navigate = useNavigate();
|
|
const { capacity, counts, locomotive } = schedule;
|
|
|
|
const lengthPct = lengthPctOf(schedule);
|
|
const weightPct = weightPctOf(schedule);
|
|
|
|
const totalBookings = totalBookingCount(counts);
|
|
|
|
return (
|
|
<Paper
|
|
className="bb-card"
|
|
radius="lg"
|
|
withBorder
|
|
style={{
|
|
borderColor: "var(--mantine-color-gray-2)",
|
|
background: "white",
|
|
overflow: "hidden",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
onClick={() =>
|
|
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
|
}
|
|
>
|
|
<Stack gap="md" p="lg" style={{ flex: 1 }}>
|
|
{/* header */}
|
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
<ThemeIcon size={44} radius="md" variant="light" color="#F2A516">
|
|
<Train size={22} />
|
|
</ThemeIcon>
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text fw={800} size="md" truncate style={{ letterSpacing: 0.2 }}>
|
|
{schedule.trainNumber ?? schedule.routeName ?? "Schedule"}
|
|
</Text>
|
|
<Text
|
|
size="10px"
|
|
fw={700}
|
|
tt="uppercase"
|
|
c="dimmed"
|
|
style={{ letterSpacing: 0.8 }}
|
|
>
|
|
Freight schedule · {schedule.status}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
<WindowStatusPill status={schedule.bookingWindowStatus} />
|
|
</Group>
|
|
|
|
<RouteCorridor
|
|
origin={schedule.origin}
|
|
destination={schedule.destination}
|
|
variant="compact"
|
|
/>
|
|
|
|
<Group gap={6} wrap="wrap">
|
|
<HeroChip icon={<CalendarDays size={12} />}>
|
|
{fmtScheduleDate(schedule.scheduleDate)}
|
|
</HeroChip>
|
|
{locomotive ? (
|
|
<HeroChip icon={<TrainFront size={12} />}>
|
|
{locomotive.code} · {fmtTons(locomotive.maxPullWeightTons)}
|
|
</HeroChip>
|
|
) : null}
|
|
</Group>
|
|
|
|
{!locomotive ? (
|
|
<Alert
|
|
color="red"
|
|
radius="md"
|
|
icon={<AlertTriangle size={15} />}
|
|
py={6}
|
|
styles={{ message: { fontSize: 12 } }}
|
|
>
|
|
No locomotive assigned — wagon allocation cannot run.
|
|
</Alert>
|
|
) : null}
|
|
|
|
{/* capacity: weight + length rings + wagons (numbers preserved) */}
|
|
<Box
|
|
py="sm"
|
|
px="xs"
|
|
style={{
|
|
borderRadius: 14,
|
|
background: "var(--mantine-color-gray-0)",
|
|
border: "1px solid var(--mantine-color-gray-1)",
|
|
}}
|
|
>
|
|
<Group justify="space-around" align="center" wrap="nowrap" gap="xs">
|
|
{weightPct != null ? (
|
|
<CapacityRing
|
|
pct={weightPct}
|
|
label="WEIGHT"
|
|
current={fmtTons(capacity.usedWeightTons)}
|
|
max={fmtTons(capacity.maxWeightTons ?? 0)}
|
|
/>
|
|
) : null}
|
|
|
|
<Stack gap={0} align="center" style={{ flex: 1 }}>
|
|
<ThemeIcon size={34} radius="md" variant="light" color="gray">
|
|
<Package size={17} />
|
|
</ThemeIcon>
|
|
<Text fw={800} size="26px" c="dark.5" lh={1.1} mt={6}>
|
|
{capacity.allocatedWagons}
|
|
</Text>
|
|
<Text size="9px" fw={700} c="gray.6" tt="uppercase" style={{ letterSpacing: 0.5 }}>
|
|
Wagons
|
|
</Text>
|
|
<Text size="xs" c="dimmed">
|
|
allocated
|
|
</Text>
|
|
</Stack>
|
|
|
|
{lengthPct != null ? (
|
|
<CapacityRing
|
|
pct={lengthPct}
|
|
label="LENGTH"
|
|
current={fmtMeters(capacity.allocatedLengthMeters)}
|
|
max={fmtMeters(capacity.maxLengthMeters ?? 0)}
|
|
/>
|
|
) : null}
|
|
</Group>
|
|
</Box>
|
|
|
|
{/* booking pipeline */}
|
|
<Box>
|
|
<Group justify="space-between" mb={6}>
|
|
<Group gap={5} wrap="nowrap">
|
|
<Package size={13} color="var(--mantine-color-gray-6)" />
|
|
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
|
Booking pipeline
|
|
</Text>
|
|
</Group>
|
|
<Text size="xs" fw={700} c="dark.4">
|
|
{totalBookings} booking{totalBookings === 1 ? "" : "s"}
|
|
</Text>
|
|
</Group>
|
|
<BookingPipeline counts={counts} />
|
|
</Box>
|
|
</Stack>
|
|
|
|
{/* CTA */}
|
|
<Box px="lg" pb="lg">
|
|
<Button
|
|
fullWidth
|
|
radius="md"
|
|
variant="gradient"
|
|
gradient={{ from: FREIGHT_BRAND, to: FREIGHT_BRAND_DARK, deg: 135 }}
|
|
rightSection={<ArrowRight size={16} className="bb-arrow" />}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`);
|
|
}}
|
|
>
|
|
View batch windows
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function CardSkeleton() {
|
|
return (
|
|
<Paper radius="lg" withBorder style={{ overflow: "hidden" }}>
|
|
<Stack gap="md" p="lg">
|
|
<Group justify="space-between">
|
|
<Group gap="sm">
|
|
<Skeleton height={44} width={44} radius="md" />
|
|
<Skeleton height={32} width={120} />
|
|
</Group>
|
|
<Skeleton height={22} width={90} radius="xl" />
|
|
</Group>
|
|
<Skeleton height={120} radius="md" />
|
|
<Skeleton height={10} radius="xl" />
|
|
<Skeleton height={36} radius="md" />
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
export default function BatchBoardPage() {
|
|
const navigate = useNavigate();
|
|
const { data, isLoading, isError, isFetching, refetch } = useBatchBoard();
|
|
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const [search, setSearch] = useState("");
|
|
const [windowFilter, setWindowFilter] = useState("ALL");
|
|
|
|
const schedules = data ?? [];
|
|
|
|
const summary = useMemo(() => {
|
|
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
|
|
const totalBookings = schedules.reduce((sum, s) => sum + totalBookingCount(s.counts), 0);
|
|
const totalWagons = schedules.reduce((sum, s) => sum + s.capacity.allocatedWagons, 0);
|
|
return { openWindows, totalBookings, totalWagons };
|
|
}, [schedules]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const query = search.trim().toLowerCase();
|
|
return schedules.filter((s) => {
|
|
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
|
|
if (!query) return true;
|
|
const haystack = [
|
|
s.trainNumber,
|
|
s.routeName,
|
|
s.origin,
|
|
s.destination,
|
|
s.locomotive?.code,
|
|
s.status,
|
|
s.bookingWindowStatus,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return haystack.includes(query);
|
|
});
|
|
}, [schedules, search, windowFilter]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
|
const paged = useMemo(() => {
|
|
const start = pagination.pageIndex * pagination.pageSize;
|
|
return filtered.slice(start, start + pagination.pageSize);
|
|
}, [filtered, pagination]);
|
|
|
|
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
|
|
const headerClassName = ruleEngineTable.headerCell;
|
|
const cellClassName = ruleEngineTable.bodyCell;
|
|
return [
|
|
{
|
|
id: "train",
|
|
header: "Train / Route",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => (
|
|
<Group gap="sm" wrap="nowrap">
|
|
<ThemeIcon size={34} radius="md" variant="light" color="#F2A516">
|
|
<Train size={17} />
|
|
</ThemeIcon>
|
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
|
<Text size="sm" fw={700} lh={1.2} truncate>
|
|
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
|
|
</Text>
|
|
<Box maw={220}>
|
|
<RouteCorridor
|
|
origin={row.original.origin}
|
|
destination={row.original.destination}
|
|
variant="compact"
|
|
/>
|
|
</Box>
|
|
</Stack>
|
|
</Group>
|
|
),
|
|
},
|
|
{
|
|
id: "date",
|
|
header: "Departure",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => {
|
|
const { day, time } = splitDate(row.original.scheduleDate);
|
|
return (
|
|
<Group gap="sm" wrap="nowrap">
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 34,
|
|
height: 34,
|
|
borderRadius: 9,
|
|
background: "var(--mantine-color-orange-0)",
|
|
color: "#B26C09",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<CalendarClock size={16} />
|
|
</Box>
|
|
<Stack gap={0}>
|
|
<Text size="sm" fw={600} lh={1.2}>
|
|
{day}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" lh={1.2}>
|
|
{time || "—"}
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "window",
|
|
header: "Window",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => <WindowStatusPill status={row.original.bookingWindowStatus} />,
|
|
},
|
|
{
|
|
id: "loco",
|
|
header: "Locomotive",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) =>
|
|
row.original.locomotive ? (
|
|
<Group gap={6} wrap="nowrap">
|
|
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
|
|
<Stack gap={0}>
|
|
<Text size="sm" fw={600} lh={1.2}>
|
|
{row.original.locomotive.code}
|
|
</Text>
|
|
<Text size="10px" c="dimmed" lh={1.2}>
|
|
{fmtTons(row.original.locomotive.maxPullWeightTons)} pull
|
|
</Text>
|
|
</Stack>
|
|
</Group>
|
|
) : (
|
|
<Text size="xs" c="red.6" fw={600}>
|
|
No loco
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "capacity",
|
|
header: "Capacity",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => (
|
|
<Group gap={6} wrap="nowrap">
|
|
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
|
|
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
|
<Group
|
|
gap={4}
|
|
wrap="nowrap"
|
|
style={{
|
|
padding: "2px 8px",
|
|
borderRadius: 8,
|
|
background: "var(--mantine-color-edr-green-0)",
|
|
border: "1px solid var(--mantine-color-edr-green-1)",
|
|
}}
|
|
>
|
|
<Package size={12} color="var(--mantine-color-edr-green-7)" />
|
|
<Text size="xs" fw={700} c="edr-green.8" lh={1.2}>
|
|
{row.original.capacity.allocatedWagons}
|
|
</Text>
|
|
<Text size="10px" c="dimmed" lh={1.2}>
|
|
wgn
|
|
</Text>
|
|
</Group>
|
|
</Group>
|
|
),
|
|
},
|
|
{
|
|
id: "bookings",
|
|
header: "Bookings",
|
|
meta: { headerClassName, cellClassName },
|
|
cell: ({ row }) => {
|
|
const total = totalBookingCount(row.original.counts);
|
|
return (
|
|
<Stack gap={4} style={{ minWidth: 130 }}>
|
|
<Text size="sm" fw={700} c="dark.4" lh={1.2}>
|
|
{total} booking{total === 1 ? "" : "s"}
|
|
</Text>
|
|
<BookingPipeline counts={row.original.counts} size={10} />
|
|
</Stack>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
|
cell: ({ row }) => (
|
|
<Group justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
|
<Menu position="bottom-end" withinPortal shadow="md" width={180}>
|
|
<Menu.Target>
|
|
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
|
|
<MoreHorizontal size={16} />
|
|
</ActionIcon>
|
|
</Menu.Target>
|
|
<Menu.Dropdown>
|
|
<Menu.Item
|
|
leftSection={<Eye size={15} />}
|
|
onClick={() =>
|
|
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
|
|
}
|
|
>
|
|
View windows
|
|
</Menu.Item>
|
|
</Menu.Dropdown>
|
|
</Menu>
|
|
</Group>
|
|
),
|
|
},
|
|
];
|
|
}, [navigate]);
|
|
|
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Batch Board"
|
|
subtitle="Active schedules and their booking windows."
|
|
action={
|
|
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
|
|
Refresh
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<KpiStrip
|
|
loading={isLoading}
|
|
items={[
|
|
{
|
|
label: "Active schedules",
|
|
value: schedules.length,
|
|
hint: "on the board right now",
|
|
icon: Train,
|
|
},
|
|
{
|
|
label: "Open windows",
|
|
value: summary.openWindows,
|
|
hint: "accepting bookings",
|
|
icon: CalendarDays,
|
|
},
|
|
{
|
|
label: "Bookings in play",
|
|
value: summary.totalBookings,
|
|
hint: `${summary.totalWagons} wagons allocated`,
|
|
icon: Package,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Card p={0}>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="md" pb="sm" w="100%">
|
|
<FleetToolbar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
searchPlaceholder="Search schedules…"
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
filters={
|
|
<Select
|
|
size="sm"
|
|
radius="lg"
|
|
value={windowFilter}
|
|
onChange={(v) => v && setWindowFilter(v)}
|
|
data={[
|
|
{ value: "ALL", label: "All windows" },
|
|
{ value: "OPEN", label: "Open" },
|
|
{ value: "FULL", label: "Full" },
|
|
{ value: "CLOSED", label: "Closed" },
|
|
]}
|
|
w={150}
|
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
|
/>
|
|
}
|
|
/>
|
|
</Box>
|
|
|
|
{viewMode === "table" ? (
|
|
<DataTable
|
|
columns={columns}
|
|
data={paged}
|
|
status={tableStatus}
|
|
onRowClick={(schedule) =>
|
|
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
|
}
|
|
error={
|
|
isError
|
|
? {
|
|
message: "Failed to load the batch board.",
|
|
onRetry: () => void refetch(),
|
|
}
|
|
: undefined
|
|
}
|
|
emptyMessage="No active schedules"
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount: filtered.length,
|
|
}}
|
|
tableOptions={{
|
|
manualPagination: true,
|
|
pageCount,
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={({ table, pagination: footerPagination }) => (
|
|
<DataTableFooter
|
|
table={table}
|
|
pagination={footerPagination}
|
|
options={{ labels: { items: "schedules" } }}
|
|
/>
|
|
)}
|
|
/>
|
|
) : isLoading ? (
|
|
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
</SimpleGrid>
|
|
) : filtered.length === 0 ? (
|
|
<Paper radius="lg" p={48} m="md" bg="gray.0">
|
|
<Stack align="center" gap="sm">
|
|
<Box
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 20,
|
|
background: "white",
|
|
border: "1px solid var(--mantine-color-gray-2)",
|
|
}}
|
|
>
|
|
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
|
</Box>
|
|
<Text fw={700} c="gray.7">
|
|
No active schedules
|
|
</Text>
|
|
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
|
Schedules with an open booking window appear here. Create or activate a
|
|
schedule to get started.
|
|
</Text>
|
|
</Stack>
|
|
</Paper>
|
|
) : (
|
|
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
|
{filtered.map((s) => (
|
|
<ScheduleCard key={s.scheduleId} schedule={s} />
|
|
))}
|
|
</SimpleGrid>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
</PageContainer>
|
|
);
|
|
}
|