style: finalize the style of the dashboard

This commit is contained in:
Nathnael
2026-06-22 08:52:45 +00:00
parent feddce8a0c
commit 8fb432e30f
11 changed files with 699 additions and 642 deletions

View File

@@ -1,3 +1,4 @@
import { Badge, ScrollArea, Tabs } from "@mantine/core";
import { import {
CheckCircle, CheckCircle,
ClipboardCheck, ClipboardCheck,
@@ -8,13 +9,12 @@ import {
Wallet, Wallet,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { Badge, Tabs } from "@mantine/core";
import "@/components/overview/overview.css";
import { import {
BOOKING_LIST_TABS, BOOKING_LIST_TABS,
type BookingStatusTabKey, type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config"; } from "@/features/bookings/booking-status.config";
import "@/components/overview/overview.css";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = { const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid size={17} strokeWidth={1.85} />, all: <LayoutGrid size={17} strokeWidth={1.85} />,
@@ -47,8 +47,9 @@ export function BookingStatusTabs({
keepMounted={false} keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }} classNames={{ list: "ov-tablist", tab: "ov-tab" }}
> >
<Tabs.List> <ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
{BOOKING_LIST_TABS.map((tab) => { <Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{BOOKING_LIST_TABS.map((tab) => {
const isActive = active === tab.key; const isActive = active === tab.key;
const count = counts?.[tab.key]; const count = counts?.[tab.key];
return ( return (
@@ -56,6 +57,7 @@ export function BookingStatusTabs({
key={tab.key} key={tab.key}
value={tab.key} value={tab.key}
leftSection={TAB_ICONS[tab.key]} leftSection={TAB_ICONS[tab.key]}
size={"sm"}
rightSection={ rightSection={
count !== undefined ? ( count !== undefined ? (
<Badge <Badge
@@ -77,8 +79,9 @@ export function BookingStatusTabs({
{tab.label} {tab.label}
</Tabs.Tab> </Tabs.Tab>
); );
})} })}
</Tabs.List> </Tabs.List>
</ScrollArea>
</Tabs> </Tabs>
); );
} }

View File

@@ -1,5 +1,5 @@
import type { OnChangeFn, PaginationState } from "@tanstack/react-table"; import type { OnChangeFn, PaginationState } from "@tanstack/react-table";
import { Stack, Group, Text, Card, SimpleGrid } from "@mantine/core"; import { Stack, Group, Text, Card, SimpleGrid, Skeleton } from "@mantine/core";
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources"; import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
import type { RuleEngineRecord } from "@/types/rule-engine"; import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -99,13 +99,16 @@ const RuleEngineCardGrid = ({
<Stack gap="md" p="md"> <Stack gap="md" p="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md"> <SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => ( {Array.from({ length: 6 }).map((_, index) => (
<Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}> <Card key={index} p="lg">
<div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}> <Group gap="sm" mb="md">
<div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} /> <Skeleton height={44} width={44} radius="md" />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} /> <Stack gap={6} style={{ flex: 1 }}>
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} /> <Skeleton height={14} width="70%" radius="sm" />
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} /> <Skeleton height={10} width="40%" radius="sm" />
</div> </Stack>
</Group>
<Skeleton height={12} radius="sm" mb={8} />
<Skeleton height={12} width="80%" radius="sm" />
</Card> </Card>
))} ))}
</SimpleGrid> </SimpleGrid>
@@ -124,8 +127,8 @@ const RuleEngineCardGrid = ({
); );
} }
const avatarBg = "#f1f5f9"; const avatarBg = "var(--mantine-color-gray-1)";
const avatarText = "#475569"; const avatarText = "var(--mantine-color-gray-7)";
return ( return (
<Stack gap="md" p="md"> <Stack gap="md" p="md">
@@ -152,24 +155,7 @@ const RuleEngineCardGrid = ({
<Card <Card
key={record.id} key={record.id}
p="lg" p="lg"
radius="lg" style={{ display: "flex", flexDirection: "column" }}
withBorder
style={{
background: "white",
border: "1px solid var(--mantine-color-gray-2)",
display: "flex",
flexDirection: "column",
transition: "all 0.2s ease",
cursor: "pointer",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
> >
<Group justify="space-between" align="flex-start" mb="md"> <Group justify="space-between" align="flex-start" mb="md">
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}> <Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
@@ -207,7 +193,7 @@ const RuleEngineCardGrid = ({
</Group> </Group>
{(subtitle || presentation.detailColumns.length > 0) && ( {(subtitle || presentation.detailColumns.length > 0) && (
<Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}> <Stack gap="xs" mb="md" style={{ flex: 1 }}>
{subtitle && ( {subtitle && (
<Group gap="xs"> <Group gap="xs">
<Text size="xs" c="dimmed" fw={500}> <Text size="xs" c="dimmed" fw={500}>
@@ -234,7 +220,7 @@ const RuleEngineCardGrid = ({
</Stack> </Stack>
)} )}
<Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}> <Group justify="flex-end" gap="xs" pt="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
<RuleEngineRecordActions <RuleEngineRecordActions
record={record} record={record}
config={config} config={config}

View File

@@ -119,15 +119,6 @@ const resolveSelectValue = (
const inputStyles = { const inputStyles = {
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" }, label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
input: {
borderColor: "#e2e8f0",
background: "white",
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
"&:focus": {
borderColor: "var(--freight-brand)",
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
},
},
} as const; } as const;
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => ( const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
@@ -228,8 +219,8 @@ const RuleEngineFormDialog = ({
px="md" px="md"
style={{ style={{
minHeight: 42, minHeight: 42,
background: "#f8fafc", background: "var(--mantine-color-gray-0)",
border: "1px solid #e2e8f0", border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)", borderRadius: "var(--mantine-radius-md)",
}} }}
> >

View File

@@ -40,7 +40,7 @@ export const ruleEngineCard = {
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md", "group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5", header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
avatar: avatar:
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm", "flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground sm:h-10 sm:w-10 sm:text-sm",
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]", title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
meta: "text-xs text-muted-foreground", meta: "text-xs text-muted-foreground",
detailLabel: detailLabel:

View File

@@ -1,5 +1,7 @@
import { import {
ActionIcon, ActionIcon,
Box,
Button,
Card, Card,
Group, Group,
Stack, Stack,
@@ -7,14 +9,26 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { ArrowRight, Calendar, Package, Search, User, X } from "lucide-react"; import {
AlertTriangle,
ArrowRight,
Calendar,
CheckCircle2,
Clock,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell"; import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingRequestsHeader } from "@/components/bookings/BookingRequestsHeader";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { import {
BookingStatusTabs, BookingStatusTabs,
@@ -24,7 +38,7 @@ import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue"; import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings"; import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { PageContainer } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard"; import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
@@ -33,7 +47,6 @@ import {
useBookingList, useBookingList,
useBookingListSummary, useBookingListSummary,
} from "@/hooks/bookings/useBookings"; } from "@/hooks/bookings/useBookings";
import { cn } from "@/lib/utils";
import type { BookingListFilter } from "@/services/bookings.service"; import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
import { import {
@@ -50,13 +63,25 @@ function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
return match.statuses.join(","); return match.statuses.join(",");
} }
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
type OperationsSubTab = "ready" | "scheduled"; type OperationsSubTab = "ready" | "scheduled";
export default function BookingRequestsPage() { export default function BookingRequestsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 }); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("in_approval"); const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("all");
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready"); const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
const [allocateOpen, setAllocateOpen] = useState(false); const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]); const [allocateIds, setAllocateIds] = useState<string[]>([]);
@@ -251,7 +276,7 @@ export default function BookingRequestsPage() {
cell: ({ row }) => ( cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" /> <Calendar className="size-3.5" />
{row.original.scheduledDate} {formatDate(row.original.scheduledDate)}
</span> </span>
), ),
}, },
@@ -262,29 +287,9 @@ export default function BookingRequestsPage() {
<BookingPriorityBadge score={row.original.priorityScore} /> <BookingPriorityBadge score={row.original.priorityScore} />
), ),
}, },
{
id: "amount",
header: () => (
<span className={bookingTable.headerCell}>Amount</span>
),
cell: ({ row }) => {
const b = row.original;
return (
<span className="font-mono text-sm font-semibold tabular-nums text-foreground">
{b.paymentCurrency}{" "}
{b.totalAmount.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</span>
);
},
},
{ {
id: "actions", id: "actions",
size: 140, size: 140,
header: () => (
<span className={bookingTable.headerCell}>Actions</span>
),
cell: ({ row }) => ( cell: ({ row }) => (
<BookingActionsMenu <BookingActionsMenu
row={row.original} row={row.original}
@@ -298,13 +303,58 @@ export default function BookingRequestsPage() {
return ( return (
<PageContainer> <PageContainer>
<Stack gap="lg"> <Stack gap="lg">
<BookingRequestsHeader <PageHeader
metrics={metrics} title="Booking requests"
tabs={tabCounts} subtitle="Review, approve, and schedule freight booking requests."
action={
<>
<Button
color="edr-green"
leftSection={<Plus size={18} />}
onClick={() => navigate("/dashboard/booking-requests/new")}
>
Create booking
</Button>
<Button
variant="default"
leftSection={<RefreshCw size={16} />}
loading={isFetching}
onClick={handleRefresh}
>
Refresh
</Button>
</>
}
/>
<KpiStrip
loading={summaryLoading} loading={summaryLoading}
isFetching={isFetching} items={[
onCreate={() => navigate("/dashboard/booking-requests/new")} {
onRefresh={handleRefresh} label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Completed",
value: tabCounts?.completed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/> />
<BookingStatusTabs <BookingStatusTabs
@@ -316,75 +366,81 @@ export default function BookingRequestsPage() {
counts={tabCounts} counts={tabCounts}
/> />
<Card p="md" radius="lg" withBorder> <Card p={0}>
<Stack gap="md"> <Stack gap={0}>
<Group justify="space-between" gap="md" wrap="wrap"> <Box px="md" pt="md" pb="sm" w="100%">
<TextInput <Group justify="space-between" gap="md" wrap="wrap">
placeholder="Search reference or customer…" <TextInput
leftSection={<Search size={18} />} placeholder="Search reference or customer…"
value={query} leftSection={<Search size={18} />}
onChange={(e) => setQuery(e.target.value)} value={query}
rightSection={ onChange={(e) => setQuery(e.target.value)}
query && ( rightSection={
<ActionIcon query && (
size="sm" <ActionIcon
color="gray" size="sm"
radius="md" color="gray"
variant="transparent" radius="md"
onClick={() => setQuery("")} variant="transparent"
> onClick={() => setQuery("")}
<X size={16} /> >
</ActionIcon> <X size={16} />
) </ActionIcon>
} )
style={{ flex: 1, minWidth: "200px" }} }
radius="lg" style={{ flex: 1, minWidth: "200px" }}
/> radius="lg"
<Text size="sm" c="dimmed"> />
{total} record{total !== 1 ? "s" : ""} <Text size="sm" c="dimmed">
</Text> {total} record{total !== 1 ? "s" : ""}
</Group> </Text>
</Group>
</Box>
{isOperationsTab ? ( {isOperationsTab ? (
<Stack gap="md"> <Box px="md" pb="md">
<Tabs <Stack gap="md">
value={operationsSubTab} <Tabs
onChange={(value) => value={operationsSubTab}
setOperationsSubTab((value as OperationsSubTab) ?? "ready") onChange={(value) =>
} setOperationsSubTab((value as OperationsSubTab) ?? "ready")
> }
<Tabs.List> >
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab> <Tabs.List>
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab> <Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
</Tabs.List> <Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
</Tabs> </Tabs.List>
{isError ? ( </Tabs>
<BookingTableEmpty {isError ? (
isError <BookingTableEmpty
hasSearch={false} isError
onRetry={handleRefresh} hasSearch={false}
/> onRetry={handleRefresh}
) : operationsSubTab === "ready" ? ( />
<OperationsBookingQueue ) : operationsSubTab === "ready" ? (
bookings={rows} <OperationsBookingQueue
isLoading={isLoading} bookings={rows}
onAllocate={handleAllocateFromQueue} isLoading={isLoading}
/> onAllocate={handleAllocateFromQueue}
) : ( />
<OperationsScheduledBookings ) : (
bookings={rows} <OperationsScheduledBookings
isLoading={isLoading} bookings={rows}
/> isLoading={isLoading}
)} />
</Stack> )}
</Stack>
</Box>
) : showEmpty ? ( ) : showEmpty ? (
<BookingTableEmpty <Box px="md" pb="md">
isError={isError} <BookingTableEmpty
hasSearch={hasSearch} isError={isError}
onRetry={handleRefresh} hasSearch={hasSearch}
/> onRetry={handleRefresh}
/>
</Box>
) : ( ) : (
<div style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }} w="100%">
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -402,16 +458,10 @@ export default function BookingRequestsPage() {
manualPagination: true, manualPagination: true,
pageCount, pageCount,
}} }}
containerClassName={cn( containerClassName="border-0 shadow-none bg-transparent"
"border-0 shadow-none",
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
)}
footer={DataTableFooter} footer={DataTableFooter}
/> />
</div> </Box>
)} )}
</Stack> </Stack>
</Card> </Card>

View File

@@ -1,33 +1,47 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
AlertCircle, ActionIcon,
Filter, Badge,
Box,
Button,
Card,
Code,
Group,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
FileUp, FileUp,
HardDrive, HardDrive,
Layers, Layers,
Loader2,
Paperclip, Paperclip,
Pencil, Pencil,
Plus, Plus,
Search, Search,
Settings, Settings,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
import { DataTable, type ColumnDef } from "@edr/ui-common";
// import Breadcrumbs from "@/components/Breadcrumbs";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog"; import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog"; import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog"; import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { getMinFiles } from "@/types/fileUploadSettings";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
export default function FileUploadSettingsPage() { export default function FileUploadSettingsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useQuery( const { data, isLoading, isError, error, refetch } = useQuery(
api.fileUploadSettings.list.queryOptions(), api.fileUploadSettings.list.queryOptions(),
); );
const deleteMutation = useDeleteFileUploadSetting(); const deleteMutation = useDeleteFileUploadSetting();
@@ -66,397 +80,298 @@ export default function FileUploadSettingsPage() {
0, 0,
); );
return ( const columns = useMemo<ColumnDef<FileUploadSetting>[]>(() => {
<div className="min-h-screen bg-slate-50 p-6"> const headerClassName = ruleEngineTable.headerCell;
<div className="mx-auto max-w-7xl space-y-6"> const cellClassName = ruleEngineTable.bodyCell;
<Breadcrumbs return [
items={[ {
{ label: "Admin", href: "/admin" }, id: "setting",
{ label: "File Upload Settings" }, header: "Setting",
]} meta: { headerClassName, cellClassName },
/> cell: ({ row }) => {
const s = row.original;
return (
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<FileUp size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0, maxWidth: 260 }}>
<Text size="sm" fw={600} lh={1.2} truncate>
{s.label}
</Text>
<Text size="xs" c="dimmed" truncate>
{s.description ?? "No description"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "code",
header: "Code",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <Code>{row.original.code}</Code>,
},
{
id: "entity",
header: "Entity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge variant="light" color="gray" tt="capitalize">
{row.original.entity ?? "—"}
</Badge>
),
},
{
id: "fields",
header: "Fields",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Paperclip size={15} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
{row.original.fields.length}
</Text>
</Group>
),
},
{
id: "requiredMulti",
header: "Required / Multi",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const required = row.original.fields.filter((f) => f.isRequired).length;
const multi = row.original.fields.filter((f) => f.isMultiple).length;
return (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="edr-green">
{required} required
</Badge>
<Badge variant="light" color="gray">
{multi} multi
</Badge>
</Group>
);
},
},
{
id: "maxSize",
header: "Max size",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const maxSize = Math.max(
0,
...row.original.fields.map((f) => f.maxSizeMb),
);
return (
<Group gap={6} wrap="nowrap">
<HardDrive size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm">{maxSize ? `${maxSize} MB` : "—"}</Text>
</Group>
);
},
},
{
id: "actions",
header: "Actions",
meta: {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
cell: ({ row }) => {
const setting = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ManageFileUploadFieldsDialog setting={setting}>
<Button
variant="default"
size="xs"
leftSection={<Paperclip size={14} />}
>
Fields
</Button>
</ManageFileUploadFieldsDialog>
{/* Header */} <EditFileUploadSettingDialog mode="edit" setting={setting}>
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between"> <ActionIcon variant="default" aria-label="Edit setting">
<div> <Pencil size={16} />
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> </ActionIcon>
File Upload Settings </EditFileUploadSettingDialog>
</h1>
<p className="mt-1 text-sm text-slate-500">
Define the file inputs every form in the platform should render
required/optional, single/multiple, allowed types and size.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <DeleteFileUploadSettingDialog
<div className="relative w-full sm:w-80"> settingLabel={setting.label}
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" /> settingCode={setting.code}
<input onConfirm={() => deleteMutation.mutate(setting.id)}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by code, label, or file key..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
/>
</div>
<EditFileUploadSettingDialog mode="create">
<button
type="button"
className="inline-flex w-35 items-center justify-center gap-2 rounded-md bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
> >
<Plus className="h-4 w-4" /> <ActionIcon
New Setting variant="default"
</button> color="red"
</EditFileUploadSettingDialog> disabled={deleteMutation.isPending}
</div> aria-label="Delete setting"
</div> >
<Trash2 size={16} />
</ActionIcon>
</DeleteFileUploadSettingDialog>
</Group>
);
},
},
];
}, [deleteMutation]);
{/* Stats */} const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Settings"
value={String(fileUploadSettings.length)}
icon={<Settings className="h-5 w-5" />}
/>
<StatCard
title="Total Fields"
value={String(totalFields)}
icon={<Paperclip className="h-5 w-5" />}
/>
<StatCard
title="Required"
value={String(requiredFields)}
icon={<FileUp className="h-5 w-5" />}
/>
<StatCard
title="Multi-file"
value={String(multiFields)}
icon={<Layers className="h-5 w-5" />}
/>
</div>
{/* Table */} return (
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <PageContainer>
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4"> <PageHeader
<div> title="File upload settings"
<h2 className="text-lg font-semibold text-slate-900"> subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
Registered File Upload Groups action={
</h2> <EditFileUploadSettingDialog mode="create">
<p className="text-sm text-slate-500"> <Button leftSection={<Plus size={18} />}>New setting</Button>
Every group a form can reference by code. </EditFileUploadSettingDialog>
</p> }
</div> />
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"> <KpiStrip
<Filter className="h-4 w-4" /> loading={isLoading}
Filter items={[
</button> { label: "Settings", value: fileUploadSettings.length, icon: Settings },
</div> { label: "Total fields", value: totalFields, icon: Paperclip },
{ label: "Required", value: requiredFields, icon: FileUp },
{ label: "Multi-file", value: multiFields, icon: Layers },
]}
/>
<div className="overflow-x-auto"> <Card p={0}>
<table className="w-full min-w-[1100px] whitespace-nowrap text-left"> <Stack gap={0}>
<thead className="bg-slate-50 text-sm text-slate-500"> <Box px="md" pt="md" pb="sm" w="100%">
<tr> <TextInput
<th className="px-6 py-4 font-medium">Setting</th> placeholder="Search by code, label, or file key…"
<th className="px-6 py-4 font-medium">Code</th> leftSection={<Search size={18} />}
<th className="px-6 py-4 font-medium">Entity</th> value={query}
<th className="px-6 py-4 font-medium">Fields</th> onChange={(e) => setQuery(e.currentTarget.value)}
<th className="px-6 py-4 font-medium">Required / Multi</th> rightSection={
<th className="px-6 py-4 font-medium">Max Size</th> query ? (
<th className="px-6 py-4 font-medium text-right">Actions</th> <ActionIcon
</tr> size="sm"
</thead> color="gray"
variant="transparent"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ maxWidth: 420 }}
/>
</Box>
<tbody> <Box style={{ overflowX: "auto" }} w="100%">
{isLoading ? ( <DataTable
<tr> columns={columns}
<td colSpan={7} className="px-6 py-12 text-center"> data={filtered}
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" /> status={tableStatus}
<p className="mt-2 text-sm text-slate-500"> error={
Loading file upload settings isError
</p> ? {
</td> message: "Failed to load settings.",
</tr> description:
) : isError ? ( error instanceof Error ? error.message : "Unknown error.",
<tr> onRetry: () => void refetch(),
<td colSpan={7} className="px-6 py-12 text-center"> }
<AlertCircle className="mx-auto h-6 w-6 text-red-500" /> : undefined
<p className="mt-2 text-sm text-red-600"> }
Failed to load settings.{" "} emptyMessage={
{error instanceof Error ? error.message : "Unknown error."} query.trim()
</p> ? "No file upload settings match your search."
</td> : 'No file upload settings yet. Click "New setting" to add one.'
</tr> }
) : filtered.length === 0 ? ( containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
<tr> />
<td </Box>
colSpan={7} </Stack>
className="px-6 py-12 text-center text-sm text-slate-500" </Card>
>
{fileUploadSettings.length === 0
? "No file upload settings yet. Click \"New Setting\" to add one."
: "No file upload settings match your search."}
</td>
</tr>
) : (
filtered.map((setting) => {
const required = setting.fields.filter(
(f: any) => f.isRequired,
).length;
const multi = setting.fields.filter(
(f: any) => f.isMultiple,
).length;
const maxSize = Math.max(
0,
...setting.fields.map((f) => f.maxSizeMb),
);
return ( <BehaviorReferenceCard />
<tr </PageContainer>
key={setting.id} );
className="border-t border-slate-100 transition hover:bg-[#10B981]/5" }
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<FileUp className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{setting.label}
</p>
<p className="text-xs text-slate-500">
{setting.description ?? "No description"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4"> /**
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700"> * Static reference: how `min_files` / `max_files` are derived from the
{setting.code} * Required × Multiple toggles. Documentation aid for whoever wires uploaders.
</span> */
</td> function BehaviorReferenceCard() {
const rows: { required: boolean; multiple: boolean; min: string; max: string }[] =
[
{ required: false, multiple: false, min: "0", max: "1" },
{ required: true, multiple: false, min: "1", max: "1" },
{ required: false, multiple: true, min: "0", max: "field.maxFiles" },
{ required: true, multiple: true, min: "1", max: "field.maxFiles" },
];
<td className="px-6 py-4"> return (
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600"> <Card>
{setting.entity ?? "—"} <Stack gap="xs">
</span> <Text fw={600}>Required × Multiple behavior</Text>
</td> <Text size="sm" c="dimmed">
Min and max file counts are derived from these two toggles. The
&quot;Max files&quot; you set on a field is only used when{" "}
<Text span fw={600}>
Multiple
</Text>{" "}
is on.
</Text>
<td className="px-6 py-4"> <Table mt="sm" striped withRowBorders={false} verticalSpacing="xs">
<div className="flex items-center gap-2 text-sm text-slate-700"> <Table.Thead>
<Paperclip className="h-4 w-4 text-[#10B981]" /> <Table.Tr>
<span className="font-medium"> <Table.Th>Required</Table.Th>
{setting.fields.length} <Table.Th>Multiple</Table.Th>
</span> <Table.Th>min_files</Table.Th>
</div> <Table.Th>max_files</Table.Th>
</td> </Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((r) => (
<Table.Tr key={`${r.required}-${r.multiple}`}>
<Table.Td>
<Badge variant="light" color={r.required ? "edr-green" : "gray"}>
{r.required ? "Required" : "Optional"}
</Badge>
</Table.Td>
<Table.Td>
<Badge variant="light" color={r.multiple ? "edr-green" : "gray"}>
{r.multiple ? "Multiple" : "Single"}
</Badge>
</Table.Td>
<Table.Td>
<Code>{r.min}</Code>
</Table.Td>
<Table.Td>
<Code>{r.max}</Code>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<td className="px-6 py-4 text-sm text-slate-700"> <Text size="xs" c="dimmed">
<div className="flex flex-wrap items-center gap-1"> Helpers <Code>getMinFiles</Code> and <Code>getEffectiveMaxFiles</Code>{" "}
<Chip>{required} required</Chip> live in <Code>@/types/fileUploadSettings</Code> use them when wiring
<Chip muted>{multi} multi</Chip> real uploaders. Example: a field with <Code>isRequired=false</Code>,{" "}
</div> <Code>isMultiple=true</Code>, <Code>maxFiles=5</Code> gives min{" "}
</td> <Code>
{getMinFiles({
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4 text-slate-400" />
{maxSize ? `${maxSize} MB` : "—"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageFileUploadFieldsDialog setting={setting}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Paperclip className="h-3.5 w-3.5" />
Fields
</button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() =>
deleteMutation.mutate(setting.id)
}
>
<button
type="button"
disabled={deleteMutation.isPending}
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteFileUploadSettingDialog>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Behavior reference card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Required × Multiple behavior
</h2>
<p className="mt-1 text-sm text-slate-500">
Min and max file counts are derived from these two toggles. The
"Max Files" you set on a field is only used when{" "}
<span className="font-medium">Multiple</span> is on.
</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Required</th>
<th className="py-2 font-medium">Multiple</th>
<th className="py-2 font-medium">min_files</th>
<th className="py-2 font-medium">max_files</th>
</tr>
</thead>
<tbody>
<BehaviorRow
required={false}
multiple={false}
min="0"
max="1"
/>
<BehaviorRow
required={true}
multiple={false}
min="1"
max="1"
/>
<BehaviorRow
required={false}
multiple={true}
min="0"
max="field.maxFiles"
/>
<BehaviorRow
required={true}
multiple={true}
min="1"
max="field.maxFiles"
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-500">
Helpers <span className="font-mono">getMinFiles</span> and{" "}
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
<span className="font-mono">@/types/fileUploadSettings</span> use
them when wiring real uploaders. Example: a field with{" "}
<span className="font-mono">isRequired=false</span>,{" "}
<span className="font-mono">isMultiple=true</span>,{" "}
<span className="font-mono">maxFiles=5</span> gives{" "}
<span className="font-mono">{getMinFiles({
id: "demo",
fileKey: "demo",
fileLabel: "demo",
isRequired: false, isRequired: false,
isMultiple: true, })}
maxFiles: 5, </Code>{" "}
allowedExtensions: [], 5.
maxSizeMb: 1, </Text>
})}</span> </Stack>
5. </Card>
</p>
</div>
</div>
</div>
);
}
function BehaviorRow({
required,
multiple,
min,
max,
}: {
required: boolean;
multiple: boolean;
min: string;
max: string;
}) {
return (
<tr className="border-t border-slate-100">
<td className="py-2.5">
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
</td>
<td className="py-2.5">
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
</td>
<td className="py-2.5 font-mono text-slate-700">{min}</td>
<td className="py-2.5 font-mono text-slate-700">{max}</td>
</tr>
);
}
function Chip({
children,
muted = false,
}: {
children: React.ReactNode;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
}
>
{children}
</span>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>
</div>
); );
} }

View File

@@ -1,25 +1,35 @@
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core"; import { Box, Button, Card, Group, Modal, Select, Stack, Text } from "@mantine/core";
import {
Archive,
Circle,
CircleCheck,
CircleSlash,
Layers,
Link2,
Plus,
Wrench,
type LucideIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { PageContainer } from "@/components/page";
import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useCargoTypes } from "@/hooks/use-cargo-types"; import { useCargoTypes } from "@/hooks/use-cargo-types";
import { useContainerTypes } from "@/hooks/use-container-types"; import { useContainerTypes } from "@/hooks/use-container-types";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useFleetList, useFleetMutations } from "@/hooks/fleet/useFleet";
import { useContainers } from "@/hooks/useContainers";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useWagonTypes } from "@/hooks/use-wagon-types";
import { useContainers } from "@/hooks/useContainers";
import { useRouteYards } from "@/hooks/useRoutes"; import { useRouteYards } from "@/hooks/useRoutes";
import { useWagons } from "@/hooks/useWagons"; import { useWagons } from "@/hooks/useWagons";
import type { FleetListFilters } from "@/services/fleet/fleet.service";
import { import {
FLEET_SELECT_NONE, FLEET_SELECT_NONE,
getFleetResource, getFleetResource,
@@ -27,11 +37,27 @@ import {
type FleetFormFieldDef, type FleetFormFieldDef,
type FleetResourceSlug, type FleetResourceSlug,
} from "@/pages/fleet/config/resources"; } from "@/pages/fleet/config/resources";
import type { FleetRecord } from "@/services/fleet/fleet.service"; import type { FleetListFilters, FleetRecord } from "@/services/fleet/fleet.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
const FLEET_STATUS_META: Record<
string,
{ label: string; icon: LucideIcon; color: string }
> = {
AVAILABLE: { label: "Available", icon: CircleCheck, color: "edr-green" },
ASSIGNED: { label: "Assigned", icon: Link2, color: "blue" },
MAINTENANCE: { label: "Maintenance", icon: Wrench, color: "yellow" },
OUT_OF_SERVICE: { label: "Out of service", icon: CircleSlash, color: "red" },
RETIRED: { label: "Retired", icon: Archive, color: "gray" },
};
const humanizeStatus = (status: string) => {
const text = status.replace(/_/g, " ").toLowerCase();
return text.charAt(0).toUpperCase() + text.slice(1);
};
const FleetResourcePage = () => { const FleetResourcePage = () => {
const location = useLocation(); const location = useLocation();
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
@@ -250,6 +276,39 @@ const FleetResourcePage = () => {
const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
const kpiItems = useMemo(() => {
const items = [
{
label: `Total ${config?.label.toLowerCase() ?? ""}`,
value: allRows.length,
icon: Layers,
color: "edr-green",
},
];
if (hasStatusColumn) {
const counts = new Map<string, number>();
for (const row of allRows) {
const status = String(
(row as unknown as Record<string, unknown>).status ?? "",
);
if (status) counts.set(status, (counts.get(status) ?? 0) + 1);
}
const top = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
for (const [status, count] of top) {
const meta = FLEET_STATUS_META[status];
items.push({
label: meta?.label ?? humanizeStatus(status),
value: count,
icon: meta?.icon ?? Circle,
color: meta?.color ?? "gray",
});
}
}
return items.slice(0, 5);
}, [allRows, hasStatusColumn, config?.label]);
if (!config) { if (!config) {
return <Navigate to="/dashboard/locomotives" replace />; return <Navigate to="/dashboard/locomotives" replace />;
} }
@@ -293,6 +352,24 @@ const FleetResourcePage = () => {
return ( return (
<PageContainer> <PageContainer>
<PageHeader
title={config.label}
subtitle={config.subtitle}
action={
<Button
leftSection={<Plus size={18} />}
onClick={() => {
setEditing(null);
setFormOpen(true);
}}
>
{config.addLabel}
</Button>
}
/>
<KpiStrip loading={isLoading} items={kpiItems} />
<Card radius="lg" padding={0} withBorder> <Card radius="lg" padding={0} withBorder>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
@@ -301,11 +378,6 @@ const FleetResourcePage = () => {
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder={config.searchPlaceholder} searchPlaceholder={config.searchPlaceholder}
showSearch={config.supportsSearch} showSearch={config.supportsSearch}
addLabel={config.addLabel}
onAdd={() => {
setEditing(null);
setFormOpen(true);
}}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
filters={ filters={
@@ -316,7 +388,6 @@ const FleetResourcePage = () => {
key={filter.key} key={filter.key}
size="sm" size="sm"
radius="lg" radius="lg"
label={filter.label}
value={filter.value} value={filter.value}
onChange={(v) => { onChange={(v) => {
if (!v) return; if (!v) return;

View File

@@ -1,5 +1,5 @@
import { FormEvent, useMemo, useState } from "react"; import { FormEvent, useMemo, useState } from "react";
import { Edit, Eye, Trash2 } from "lucide-react"; import { Ban, CircleCheck, Edit, Eye, Plus, Route as RouteIcon, Trash2 } from "lucide-react";
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { import {
ActionIcon, ActionIcon,
@@ -17,7 +17,7 @@ import {
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { PageContainer } from "@/components/page"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
@@ -102,6 +102,9 @@ export default function RoutesPage() {
return filteredRoutes.slice(start, start + pagination.pageSize); return filteredRoutes.slice(start, start + pagination.pageSize);
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]); }, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
const allRoutes = routesQuery.data ?? [];
const activeCount = allRoutes.filter((route) => route.isActive).length;
const yardOptions = useMemo( const yardOptions = useMemo(
() => () =>
(yardsQuery.data ?? []).map((yard) => ({ (yardsQuery.data ?? []).map((yard) => ({
@@ -281,6 +284,30 @@ export default function RoutesPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader
title="Routes"
subtitle="Define rail corridors and their ordered yard stops used by train scheduling."
action={
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
Add route
</Button>
}
/>
<KpiStrip
loading={routesQuery.isLoading}
items={[
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
{ label: "Active", value: activeCount, icon: CircleCheck, color: "edr-green" },
{
label: "Inactive",
value: allRoutes.length - activeCount,
icon: Ban,
color: "gray",
},
]}
/>
<Card radius="lg" padding={0} withBorder> <Card radius="lg" padding={0} withBorder>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
@@ -288,8 +315,6 @@ export default function RoutesPage() {
search={search} search={search}
onSearchChange={setSearch} onSearchChange={setSearch}
searchPlaceholder="Search routes…" searchPlaceholder="Search routes…"
addLabel="Add Route"
onAdd={openCreate}
viewMode={viewMode} viewMode={viewMode}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
/> />

View File

@@ -1,5 +1,6 @@
import { import {
ActionIcon, ActionIcon,
Box,
Card, Card,
Group, Group,
Badge as MantineBadge, Badge as MantineBadge,
@@ -268,49 +269,51 @@ export default function PaymentsPage() {
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
<Card padding="md"> <Card p={0}>
<Stack gap="md"> <Stack gap={0}>
<Group justify="space-between" gap="md" wrap="wrap"> <Box px="md" pt="md" pb="sm" w="100%">
<TextInput <Group justify="space-between" gap="md" wrap="wrap">
placeholder="Search order, booking, or transaction…" <TextInput
leftSection={<Search size={18} />} placeholder="Search order, booking, or transaction…"
value={query} leftSection={<Search size={18} />}
onChange={(e) => { value={query}
setQuery(e.target.value); onChange={(e) => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setQuery(e.target.value);
}} setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
rightSection={ }}
query && ( rightSection={
<ActionIcon query && (
size="sm" <ActionIcon
color="gray" size="sm"
radius="md" color="gray"
variant="transparent" radius="md"
onClick={() => setQuery("")} variant="transparent"
> onClick={() => setQuery("")}
<X size={16} /> >
</ActionIcon> <X size={16} />
) </ActionIcon>
} )
style={{ flex: 1, minWidth: "200px" }} }
/> style={{ flex: 1, minWidth: "200px" }}
<Select />
placeholder="All methods" <Select
clearable placeholder="All methods"
data={METHOD_OPTIONS} clearable
value={method} data={METHOD_OPTIONS}
onChange={(value) => { value={method}
setMethod(value); onChange={(value) => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); setMethod(value);
}} setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
style={{ minWidth: 180 }} }}
/> style={{ minWidth: 180 }}
<Text size="sm" c="dimmed"> />
{total} record{total !== 1 ? "s" : ""} <Text size="sm" c="dimmed">
</Text> {total} record{total !== 1 ? "s" : ""}
</Group> </Text>
</Group>
</Box>
<div style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }} w="100%">
<DataTable <DataTable
columns={columns} columns={columns}
data={rows} data={rows}
@@ -327,9 +330,10 @@ export default function PaymentsPage() {
manualPagination: true, manualPagination: true,
pageCount, pageCount,
}} }}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter} footer={DataTableFooter}
/> />
</div> </Box>
</Stack> </Stack>
</Card> </Card>
</PageContainer> </PageContainer>

View File

@@ -3,9 +3,10 @@ import { Navigate, useLocation, useParams } from "react-router-dom";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions"; import { canAccessRuleEngineResource } from "@/lib/permissions";
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react"; import { Box, Card, Button, Modal, Stack, Group, Text, List, Loader } from "@mantine/core";
import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core"; import { Plus } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid"; import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog"; import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog"; import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
@@ -325,31 +326,44 @@ const RuleEngineResourcePage = () => {
}; };
const itemLabel = config.label.toLowerCase(); const itemLabel = config.label.toLowerCase();
const addLabel = `Add ${config.label.replace(/s$/, "")}`;
return ( return (
<Stack gap="lg"> <PageContainer>
<Card p="lg" radius="lg" withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)" }}> <PageHeader
<Stack gap="md"> title={config.label}
<RuleEngineToolbar subtitle={config.subtitle}
search={search} action={
onSearchChange={ canManage ? (
config.supportsSearch <Button leftSection={<Plus size={18} />} onClick={openCreate}>
? (v) => { {addLabel}
setSearch(v); </Button>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); ) : undefined
} }
: undefined />
}
showSearch={Boolean(config.supportsSearch)} <Card p={0}>
searchPlaceholder={config.searchPlaceholder} <Stack gap={0}>
onAdd={canManage ? openCreate : undefined} <Box px="md" pt="md" pb="sm" w="100%">
addLabel={`Add ${config.label.replace(/s$/, "")}`} <RuleEngineToolbar
onManageOrder={ search={search}
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined onSearchChange={
} config.supportsSearch
viewMode={viewMode} ? (v) => {
onViewModeChange={setViewMode} setSearch(v);
/> setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}
: undefined
}
showSearch={Boolean(config.supportsSearch)}
searchPlaceholder={config.searchPlaceholder}
onManageOrder={
canManage && config.orderConfig ? () => setOrderDialogOpen(true) : undefined
}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
{viewMode === "table" ? ( {viewMode === "table" ? (
<DataTable <DataTable
@@ -378,8 +392,7 @@ const RuleEngineResourcePage = () => {
state: { pagination }, state: { pagination },
onPaginationChange: setPagination, onPaginationChange: setPagination,
}} }}
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border" containerClassName="border-0 shadow-none bg-transparent"
footerClassName="border-t border-border bg-card"
footer={({ table, pagination: footerPagination }) => ( footer={({ table, pagination: footerPagination }) => (
<DataTableFooter <DataTableFooter
table={table} table={table}
@@ -473,16 +486,15 @@ const RuleEngineResourcePage = () => {
</Button> </Button>
<Button <Button
color="red" color="red"
disabled={remove.isPending} loading={remove.isPending}
onClick={() => { onClick={() => {
if (!deleteTarget) return; if (!deleteTarget) return;
remove.mutate(deleteTarget.id, { remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null), onSuccess: () => setDeleteTarget(null),
}); });
}} }}
leftSection={remove.isPending && <Loader2 size={16} />}
> >
{remove.isPending ? "Deleting..." : "Delete"} Delete
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -498,7 +510,7 @@ const RuleEngineResourcePage = () => {
<Stack gap="md"> <Stack gap="md">
{chainLoading ? ( {chainLoading ? (
<Group justify="center" p="xl"> <Group justify="center" p="xl">
<Loader2 size={32} style={{ animation: "spin 1s linear infinite" }} /> <Loader />
</Group> </Group>
) : ( ) : (
<> <>
@@ -524,7 +536,7 @@ const RuleEngineResourcePage = () => {
)} )}
</Stack> </Stack>
</Modal> </Modal>
</Stack> </PageContainer>
); );
}; };

View File

@@ -1,6 +1,3 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { isAxiosError } from "axios";
import type { ColumnDef } from "@edr/ui-common"; import type { ColumnDef } from "@edr/ui-common";
import { import {
ActionIcon, ActionIcon,
@@ -17,6 +14,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { isAxiosError } from "axios";
import { import {
ArrowRight, ArrowRight,
Ban, Ban,
@@ -28,24 +26,26 @@ import {
Train, Train,
Weight, Weight,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import FleetToolbar from "@/components/fleet/FleetToolbar"; import FleetToolbar from "@/components/fleet/FleetToolbar";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { import {
RouteCorridor, RouteCorridor,
StatusPill, StatusPill,
} from "@/components/trainScheduling/scheduleVisuals"; } from "@/components/trainScheduling/scheduleVisuals";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRoutes } from "@/hooks/useRoutes";
import { import {
useAvailableLocomotives, useAvailableLocomotives,
useScheduleList, useScheduleList,
useScheduleMutations, useScheduleMutations,
} from "@/hooks/trainScheduling/useTrainScheduling"; } from "@/hooks/trainScheduling/useTrainScheduling";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useRoutes } from "@/hooks/useRoutes";
import type { TrainScheduleListItem } from "@/types/trainScheduling"; import type { TrainScheduleListItem } from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
@@ -262,7 +262,7 @@ export default function TrainScheduleV2ListPage() {
}, },
{ {
id: "actions", id: "actions",
header: "Actions", size:32,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => { cell: ({ row }) => {
const schedule = row.original; const schedule = row.original;