booking operations and trains scheduling also allocations

This commit is contained in:
marshal
2026-06-10 00:48:32 +03:00
parent 675975bc08
commit 5774d7db9d
180 changed files with 13423 additions and 3877 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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