mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 10:40:58 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import type { OnChangeFn, PaginationState } from "@edr/ui-common";
|
||||
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
import FleetRecordActions from "./FleetRecordActions";
|
||||
import { cardInitials, resolveFleetCardPresentation } from "./fleetCardMeta";
|
||||
import { formatFleetCell } from "./fleetFormat";
|
||||
import RuleEngineListFooter from "../ruleEngine/RuleEngineListFooter";
|
||||
|
||||
export interface FleetCardGridProps {
|
||||
config: FleetResourceConfig;
|
||||
rows: FleetRecord[];
|
||||
status: "loading" | "error" | "success";
|
||||
emptyMessage: string;
|
||||
pagination: PaginationState;
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
}
|
||||
|
||||
const FleetCardGrid = ({
|
||||
config,
|
||||
rows,
|
||||
status,
|
||||
emptyMessage,
|
||||
pagination,
|
||||
pageCount,
|
||||
totalCount,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: FleetCardGridProps) => {
|
||||
const presentation = resolveFleetCardPresentation(config);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
Loading…
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<Text size="sm" c="red" py="xl" ta="center">
|
||||
Failed to load data
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="xl" ta="center">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
|
||||
{rows.map((record) => {
|
||||
const title = String(
|
||||
(record as unknown as Record<string, unknown>)[presentation.titleKey] ?? config.entityLabel,
|
||||
);
|
||||
const code = presentation.codeKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.codeKey]
|
||||
: null;
|
||||
const subtitle = presentation.subtitleKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.subtitleKey]
|
||||
: null;
|
||||
const statusValue = presentation.statusKey
|
||||
? (record as unknown as Record<string, unknown>)[presentation.statusKey]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={String((record as { id: string }).id)}
|
||||
radius="lg"
|
||||
padding="lg"
|
||||
withBorder
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-green-0)",
|
||||
color: "var(--mantine-color-green-7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{cardInitials(title)}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{title || "—"}
|
||||
</Text>
|
||||
{subtitle != null && subtitle !== "" ? (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{String(subtitle)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{code != null && code !== "" ? (
|
||||
<Badge variant="light" color="blue" size="sm" radius="md">
|
||||
{String(code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Stack gap={6}>
|
||||
{config.columns
|
||||
.filter(
|
||||
(col) =>
|
||||
col.accessorKey !== presentation.titleKey &&
|
||||
col.accessorKey !== presentation.codeKey &&
|
||||
col.accessorKey !== presentation.statusKey,
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((col) => (
|
||||
<Group key={col.id} justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{col.header}
|
||||
</Text>
|
||||
<Text size="xs" fw={500}>
|
||||
{formatFleetCell(
|
||||
(record as unknown as Record<string, unknown>)[col.accessorKey],
|
||||
col.format,
|
||||
col.accessorKey,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{statusValue != null ? (
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
Status
|
||||
</Text>
|
||||
{formatFleetCell(statusValue, "statusBadge")}
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<FleetRecordActions
|
||||
record={record}
|
||||
config={config}
|
||||
layout="compact"
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={totalCount}
|
||||
itemLabel={config.entityLabel.toLowerCase() + "s"}
|
||||
onPaginationChange={onPaginationChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetCardGrid;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { FLEET_SELECT_NONE, type FleetFormFieldDef } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
fields: FleetFormFieldDef[];
|
||||
initialRecord?: FleetRecord | null;
|
||||
emptyValues: Record<string, unknown>;
|
||||
isSubmitting: boolean;
|
||||
selectOptionsLoading?: boolean;
|
||||
onSubmit: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const buildInitialValues = (
|
||||
fields: FleetFormFieldDef[],
|
||||
emptyValues: Record<string, unknown>,
|
||||
record?: FleetRecord | null,
|
||||
): Record<string, unknown> => {
|
||||
const values: Record<string, unknown> = { ...emptyValues };
|
||||
if (!record) return values;
|
||||
|
||||
fields.forEach((field) => {
|
||||
const raw = (record as unknown as Record<string, unknown>)[field.name];
|
||||
if (raw === null || raw === undefined) {
|
||||
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
|
||||
return;
|
||||
}
|
||||
values[field.name] = raw;
|
||||
});
|
||||
return values;
|
||||
};
|
||||
|
||||
const FleetFormDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
fields,
|
||||
initialRecord,
|
||||
emptyValues,
|
||||
isSubmitting,
|
||||
selectOptionsLoading,
|
||||
onSubmit,
|
||||
}: FleetFormDialogProps) => {
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, fields, emptyValues, initialRecord]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
[fields],
|
||||
);
|
||||
const longFields = useMemo(
|
||||
() => fields.filter((f) => f.type === "textarea"),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const validate = () => {
|
||||
const next: Record<string, string> = {};
|
||||
fields.forEach((field) => {
|
||||
const value = values[field.name];
|
||||
const stringValue =
|
||||
typeof value === "string" ? value.trim() : String(value ?? "");
|
||||
if (field.required && (stringValue === "" || stringValue === FLEET_SELECT_NONE)) {
|
||||
next[field.name] = `${field.label} is required`;
|
||||
}
|
||||
});
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(values)
|
||||
.map(([key, value]) => {
|
||||
if (value === FLEET_SELECT_NONE || value === "") return [key, undefined];
|
||||
return [key, value];
|
||||
})
|
||||
.filter(([, value]) => value !== undefined),
|
||||
);
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const renderField = (field: FleetFormFieldDef) => {
|
||||
const value = values[field.name];
|
||||
const error = errors[field.name];
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
data={field.options ?? []}
|
||||
value={value == null || value === "" ? (field.noneOption ? FLEET_SELECT_NONE : null) : String(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({ ...current, [field.name]: next ?? "" }))
|
||||
}
|
||||
error={error}
|
||||
searchable
|
||||
disabled={selectOptionsLoading}
|
||||
rightSection={selectOptionsLoading ? <Loader2 size={14} className="animate-spin" /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<NumberInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={value === "" || value == null ? "" : Number(value)}
|
||||
onChange={(next) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
[field.name]: next === "" ? "" : next,
|
||||
}))
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "textarea") {
|
||||
return (
|
||||
<Textarea
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
|
||||
}
|
||||
error={error}
|
||||
minRows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
setValues((current) => ({ ...current, [field.name]: e.currentTarget.value }))
|
||||
}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={<Text fw={600}>{title}</Text>}
|
||||
size="lg"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{shortFields.map(renderField)}
|
||||
</SimpleGrid>
|
||||
{longFields.map(renderField)}
|
||||
<Group justify="flex-end" gap="sm" mt="sm">
|
||||
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" loading={isSubmitting} onClick={handleSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetFormDialog;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MoreHorizontal, Pencil, Trash2, Truck } from "lucide-react";
|
||||
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
|
||||
export interface FleetRecordActionsProps {
|
||||
record: FleetRecord;
|
||||
config: FleetResourceConfig;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
layout?: "row" | "compact";
|
||||
}
|
||||
|
||||
const FleetRecordActions = ({
|
||||
record,
|
||||
config,
|
||||
onEdit,
|
||||
onRemove,
|
||||
layout = "row",
|
||||
}: FleetRecordActionsProps) => {
|
||||
const navigate = useNavigate();
|
||||
const removeLabel = config.removeActionLabel ?? "Delete";
|
||||
const showDetail = Boolean(config.detailPath && "id" in record);
|
||||
|
||||
const handleDetail = () => {
|
||||
if (!config.detailPath || !("id" in record)) return;
|
||||
navigate(config.detailPath.replace(":id", String(record.id)));
|
||||
};
|
||||
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{showDetail ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={handleDetail}
|
||||
leftSection={<Truck size={14} />}
|
||||
>
|
||||
Manage wagons
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Pencil size={14} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap" justify="flex-end">
|
||||
{showDetail ? (
|
||||
<Tooltip label="Manage wagons">
|
||||
<ActionIcon variant="subtle" color="green" size="md" radius="md" onClick={handleDetail}>
|
||||
<Truck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" size="md" radius="md" onClick={() => onEdit(record)}>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" size="md" radius="md">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item color="red" leftSection={<Trash2 size={14} />} onClick={() => onRemove(record)}>
|
||||
{removeLabel}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default FleetRecordActions;
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||
import { Box, Button, Group, SegmentedControl, TextInput } from "@mantine/core";
|
||||
|
||||
import type { FleetViewMode } from "./useFleetViewMode";
|
||||
|
||||
export interface FleetToolbarProps {
|
||||
search?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
showSearch?: boolean;
|
||||
onAdd?: () => void;
|
||||
addLabel?: string;
|
||||
viewMode: FleetViewMode;
|
||||
onViewModeChange: (mode: FleetViewMode) => void;
|
||||
/** Optional filters rendered beside search (status, freight type, etc.) */
|
||||
filters?: ReactNode;
|
||||
}
|
||||
|
||||
const FleetToolbar = ({
|
||||
search = "",
|
||||
onSearchChange,
|
||||
searchPlaceholder = "Search…",
|
||||
showSearch = true,
|
||||
onAdd,
|
||||
addLabel = "Add",
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
filters,
|
||||
}: FleetToolbarProps) => (
|
||||
<Box w="100%">
|
||||
<Group
|
||||
gap="md"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Group
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="wrap"
|
||||
style={{ flex: "1 1 280px", minWidth: 0 }}
|
||||
>
|
||||
{showSearch && onSearchChange ? (
|
||||
<TextInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
leftSection={<Search size={16} />}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
style={{ flex: "1 1 200px", minWidth: 180, maxWidth: 360 }}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
) : null}
|
||||
{filters ? (
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{filters}
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" align="center" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<SegmentedControl
|
||||
value={viewMode}
|
||||
onChange={(value) => onViewModeChange(value as FleetViewMode)}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="green"
|
||||
data={[
|
||||
{
|
||||
value: "table",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<Table2 size={14} />
|
||||
<span>Table</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "cards",
|
||||
label: (
|
||||
<Group gap={6} justify="center" wrap="nowrap">
|
||||
<LayoutGrid size={14} />
|
||||
<span>Cards</span>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
styles={{
|
||||
root: { background: "var(--mantine-color-gray-1)" },
|
||||
}}
|
||||
/>
|
||||
{onAdd ? (
|
||||
<Button
|
||||
color="green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
fw={600}
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={onAdd}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default FleetToolbar;
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FleetResourceConfig } from "@/pages/fleet/config/resources";
|
||||
|
||||
export interface FleetCardPresentation {
|
||||
titleKey: string;
|
||||
subtitleKey?: string;
|
||||
codeKey?: string;
|
||||
statusKey?: string;
|
||||
}
|
||||
|
||||
export const resolveFleetCardPresentation = (config: FleetResourceConfig): FleetCardPresentation => {
|
||||
const titleKey =
|
||||
config.cardTitleKey ??
|
||||
config.columns.find((col) => col.format !== "code" && col.accessorKey !== "status")
|
||||
?.accessorKey ??
|
||||
"id";
|
||||
|
||||
const codeKey =
|
||||
config.cardCodeKey ?? config.columns.find((col) => col.format === "code")?.accessorKey;
|
||||
|
||||
const statusKey = config.columns.find((col) => col.format === "statusBadge")?.accessorKey;
|
||||
|
||||
const subtitleKey =
|
||||
config.cardSubtitleKey ??
|
||||
config.columns.find(
|
||||
(col) =>
|
||||
col.accessorKey !== titleKey &&
|
||||
col.accessorKey !== codeKey &&
|
||||
col.accessorKey !== statusKey,
|
||||
)?.accessorKey;
|
||||
|
||||
return { titleKey, subtitleKey, codeKey, statusKey };
|
||||
};
|
||||
|
||||
export const cardInitials = (title: string) => {
|
||||
const parts = title.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase();
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
|
||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||
import { formatCell as formatRuleEngineCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||
|
||||
export type FleetColumnFormat = ColumnFormat | "statusBadge";
|
||||
|
||||
const optionLabelMap = new Map<string, Map<string, string>>();
|
||||
|
||||
export const registerFleetOptionLabels = (
|
||||
fieldKey: string,
|
||||
options: { value: string; label: string }[],
|
||||
) => {
|
||||
optionLabelMap.set(fieldKey, new Map(options.map((o) => [o.value, o.label])));
|
||||
};
|
||||
|
||||
export const formatFleetCell = (
|
||||
value: unknown,
|
||||
format?: FleetColumnFormat,
|
||||
accessorKey?: string,
|
||||
): ReactNode => {
|
||||
if (format === "statusBadge") {
|
||||
const status = value == null || value === "" ? "—" : String(value);
|
||||
return (
|
||||
<Badge variant="light" color="gray" size="sm" radius="md">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (accessorKey && optionLabelMap.has(accessorKey)) {
|
||||
const label = optionLabelMap.get(accessorKey)?.get(String(value ?? ""));
|
||||
if (label) {
|
||||
return <Text size="sm">{label}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
|
||||
export type FleetViewMode = "table" | "cards";
|
||||
|
||||
const STORAGE_PREFIX = "edr-freight-fleet-view:";
|
||||
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2";
|
||||
|
||||
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${slug}`);
|
||||
return raw === "cards" ? "cards" : "table";
|
||||
} catch {
|
||||
return "table";
|
||||
}
|
||||
};
|
||||
|
||||
export const useFleetViewMode = (slug: ViewModeSlug) => {
|
||||
const [viewMode, setViewModeState] = useState<FleetViewMode>(() => readStored(slug));
|
||||
|
||||
useEffect(() => {
|
||||
setViewModeState(readStored(slug));
|
||||
}, [slug]);
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: FleetViewMode) => {
|
||||
setViewModeState(mode);
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${slug}`, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
return { viewMode, setViewMode };
|
||||
};
|
||||
Reference in New Issue
Block a user