mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
style: finalize the style of the dashboard
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
"Max files" 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user