mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
- Added pricing service integration to ContractsService for pre-persistence contract pricing. - Updated LegCapacityPanel to display cargo weight instead of gross weight for better clarity on booked cargo. - Enhanced train scheduling service to include cargo weight in booking details. - Modified ClearanceDocumentsPage to include FULLY_EXECUTED status in booking status options. - Refactored FileUploadSettingsPage to categorize file upload settings into tabs for better organization. - Removed legacy onboarding fields and settings from file upload settings seeder. - Improved type definitions for train scheduling to include cargo weight without wagon tare.
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import {
|
||
ActionIcon,
|
||
Badge,
|
||
Box,
|
||
Button,
|
||
Card,
|
||
Code,
|
||
Group,
|
||
SimpleGrid,
|
||
Stack,
|
||
Table,
|
||
Tabs,
|
||
Text,
|
||
TextInput,
|
||
ThemeIcon,
|
||
} from "@mantine/core";
|
||
import {
|
||
ArrowDownToLine,
|
||
ArrowUpFromLine,
|
||
FileUp,
|
||
FolderOpen,
|
||
HardDrive,
|
||
Inbox,
|
||
Layers,
|
||
Paperclip,
|
||
Search,
|
||
Settings,
|
||
TrainFront,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
|
||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||
import { api } from "@/services/api";
|
||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||
|
||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||
|
||
type DocCategory = "import" | "export" | "intercity" | "other";
|
||
|
||
function categorize(setting: FileUploadSetting): DocCategory {
|
||
const code = setting.code.toLowerCase();
|
||
if (code.includes("intercity")) return "intercity";
|
||
if (code.includes("export")) return "export";
|
||
if (code.includes("import")) return "import";
|
||
return "other";
|
||
}
|
||
|
||
export default function FileUploadSettingsPage() {
|
||
const [query, setQuery] = useState("");
|
||
|
||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||
api.fileUploadSettings.list.queryOptions(),
|
||
);
|
||
|
||
const fileUploadSettings = useMemo(
|
||
() => (Array.isArray(data) ? data : []),
|
||
[data],
|
||
);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = query.trim().toLowerCase();
|
||
if (!q) return fileUploadSettings;
|
||
return fileUploadSettings.filter(
|
||
(s) =>
|
||
s.code.toLowerCase().includes(q) ||
|
||
s.label.toLowerCase().includes(q) ||
|
||
(s.description ?? "").toLowerCase().includes(q) ||
|
||
s.fields.some(
|
||
(f) =>
|
||
f.fileKey.toLowerCase().includes(q) ||
|
||
f.fileLabel.toLowerCase().includes(q),
|
||
),
|
||
);
|
||
}, [fileUploadSettings, query]);
|
||
|
||
const byCategory = useMemo(() => {
|
||
const groups: Record<DocCategory, FileUploadSetting[]> = {
|
||
import: [],
|
||
export: [],
|
||
intercity: [],
|
||
other: [],
|
||
};
|
||
for (const s of filtered) groups[categorize(s)].push(s);
|
||
return groups;
|
||
}, [filtered]);
|
||
|
||
const totalFields = fileUploadSettings.reduce(
|
||
(sum, s) => sum + s.fields.length,
|
||
0,
|
||
);
|
||
const requiredFields = fileUploadSettings.reduce(
|
||
(sum, s) => sum + s.fields.filter((f) => f.isRequired).length,
|
||
0,
|
||
);
|
||
const multiFields = fileUploadSettings.reduce(
|
||
(sum, s) => sum + s.fields.filter((f) => f.isMultiple).length,
|
||
0,
|
||
);
|
||
|
||
const gridStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||
|
||
return (
|
||
<PageContainer>
|
||
<PageHeader
|
||
title="File upload settings"
|
||
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
|
||
/>
|
||
|
||
<KpiStrip
|
||
loading={isLoading}
|
||
items={[
|
||
{ label: "Settings", value: fileUploadSettings.length, icon: Settings },
|
||
{ label: "Total fields", value: totalFields, icon: Paperclip },
|
||
{ label: "Required", value: requiredFields, icon: FileUp },
|
||
{ label: "Multi-file", value: multiFields, icon: Layers },
|
||
]}
|
||
/>
|
||
|
||
<Card p={0}>
|
||
<Tabs defaultValue="import" variant="outline" radius="md">
|
||
<Box px="md" pt="md">
|
||
<Tabs.List>
|
||
<Tabs.Tab value="import" leftSection={<ArrowDownToLine size={16} />}>
|
||
Import
|
||
</Tabs.Tab>
|
||
<Tabs.Tab value="export" leftSection={<ArrowUpFromLine size={16} />}>
|
||
Export
|
||
</Tabs.Tab>
|
||
<Tabs.Tab value="intercity" leftSection={<TrainFront size={16} />}>
|
||
Intercity
|
||
</Tabs.Tab>
|
||
<Tabs.Tab value="other" leftSection={<FolderOpen size={16} />}>
|
||
Other
|
||
</Tabs.Tab>
|
||
</Tabs.List>
|
||
</Box>
|
||
|
||
{(["import", "export", "intercity", "other"] as const).map((tab) => (
|
||
<Tabs.Panel key={tab} value={tab}>
|
||
<Stack gap={0}>
|
||
<Box px="md" pt="md" pb="sm" w="100%">
|
||
<TextInput
|
||
placeholder="Search by code, label, or file key…"
|
||
leftSection={<Search size={18} />}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||
rightSection={
|
||
query ? (
|
||
<ActionIcon
|
||
size="sm"
|
||
color="gray"
|
||
variant="transparent"
|
||
onClick={() => setQuery("")}
|
||
aria-label="Clear search"
|
||
>
|
||
<X size={16} />
|
||
</ActionIcon>
|
||
) : null
|
||
}
|
||
style={{ maxWidth: 420 }}
|
||
/>
|
||
</Box>
|
||
|
||
<Box px="md" pb="md" w="100%">
|
||
{gridStatus === "error" ? (
|
||
<Card withBorder p="lg">
|
||
<Stack gap="xs" align="center" ta="center">
|
||
<Text fw={600} c="red">
|
||
Failed to load settings.
|
||
</Text>
|
||
<Text size="sm" c="dimmed">
|
||
{error instanceof Error ? error.message : "Unknown error."}
|
||
</Text>
|
||
<Button
|
||
variant="light"
|
||
size="xs"
|
||
onClick={() => void refetch()}
|
||
>
|
||
Retry
|
||
</Button>
|
||
</Stack>
|
||
</Card>
|
||
) : gridStatus === "loading" ? (
|
||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Card key={i} withBorder p="md" h={168} />
|
||
))}
|
||
</SimpleGrid>
|
||
) : byCategory[tab].length === 0 ? (
|
||
<Card withBorder p="xl">
|
||
<Stack gap={4} align="center" ta="center" c="dimmed">
|
||
<Inbox size={28} />
|
||
<Text size="sm">
|
||
{query.trim()
|
||
? "No file upload settings match your search."
|
||
: "No file upload settings in this category."}
|
||
</Text>
|
||
</Stack>
|
||
</Card>
|
||
) : (
|
||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||
{byCategory[tab].map((setting) => (
|
||
<SettingCard key={setting.id} setting={setting} />
|
||
))}
|
||
</SimpleGrid>
|
||
)}
|
||
</Box>
|
||
</Stack>
|
||
</Tabs.Panel>
|
||
))}
|
||
</Tabs>
|
||
</Card>
|
||
|
||
<BehaviorReferenceCard />
|
||
</PageContainer>
|
||
);
|
||
}
|
||
|
||
function SettingCard({ setting }: { setting: FileUploadSetting }) {
|
||
const required = setting.fields.filter((f) => f.isRequired).length;
|
||
const multi = setting.fields.filter((f) => f.isMultiple).length;
|
||
const maxSize = Math.max(0, ...setting.fields.map((f) => f.maxSizeMb));
|
||
|
||
return (
|
||
<Card withBorder radius="md" p="md" h="100%">
|
||
<Stack gap="sm" h="100%">
|
||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
|
||
<FileUp size={18} />
|
||
</ThemeIcon>
|
||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||
<Text size="sm" fw={600} lh={1.25}>
|
||
{setting.label}
|
||
</Text>
|
||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||
{setting.description ?? "No description"}
|
||
</Text>
|
||
</Stack>
|
||
</Group>
|
||
|
||
<Group gap={6} wrap="wrap">
|
||
<Badge variant="light" color="gray" tt="capitalize">
|
||
{setting.entity ?? "—"}
|
||
</Badge>
|
||
<Badge variant="light" color="edr-green">
|
||
{setting.fields.length} field{setting.fields.length === 1 ? "" : "s"}
|
||
</Badge>
|
||
{required > 0 && (
|
||
<Badge variant="light" color="orange">
|
||
{required} required
|
||
</Badge>
|
||
)}
|
||
{multi > 0 && (
|
||
<Badge variant="light" color="blue">
|
||
{multi} multi
|
||
</Badge>
|
||
)}
|
||
</Group>
|
||
|
||
<Group gap={6} wrap="nowrap">
|
||
<Code style={{ flex: 1, minWidth: 0 }}>
|
||
<Text truncate span size="xs">
|
||
{setting.code}
|
||
</Text>
|
||
</Code>
|
||
</Group>
|
||
|
||
<Group gap={6} c="dimmed">
|
||
<HardDrive size={14} />
|
||
<Text size="xs">{maxSize ? `Max ${maxSize} MB per file` : "No size limit set"}</Text>
|
||
</Group>
|
||
|
||
<Group justify="flex-end" mt="auto">
|
||
<ManageFileUploadFieldsDialog setting={setting}>
|
||
<Button variant="default" size="xs" leftSection={<Paperclip size={14} />}>
|
||
Fields
|
||
</Button>
|
||
</ManageFileUploadFieldsDialog>
|
||
</Group>
|
||
</Stack>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Static reference: how `min_files` / `max_files` are derived from the
|
||
* Required × Multiple toggles. Documentation aid for whoever wires uploaders.
|
||
*/
|
||
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" },
|
||
];
|
||
|
||
return (
|
||
<Card>
|
||
<Stack gap="xs">
|
||
<Text fw={600}>Required × Multiple behavior</Text>
|
||
<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>
|
||
|
||
<Table mt="sm" striped withRowBorders={false} verticalSpacing="xs">
|
||
<Table.Thead>
|
||
<Table.Tr>
|
||
<Table.Th>Required</Table.Th>
|
||
<Table.Th>Multiple</Table.Th>
|
||
<Table.Th>min_files</Table.Th>
|
||
<Table.Th>max_files</Table.Th>
|
||
</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>
|
||
|
||
<Text size="xs" c="dimmed">
|
||
Helpers <Code>getMinFiles</Code> and <Code>getEffectiveMaxFiles</Code>{" "}
|
||
live in <Code>@/types/fileUploadSettings</Code> — use them when wiring
|
||
real uploaders. Example: a field with <Code>isRequired=false</Code>,{" "}
|
||
<Code>isMultiple=true</Code>, <Code>maxFiles=5</Code> gives min{" "}
|
||
<Code>
|
||
{getMinFiles({
|
||
isRequired: false,
|
||
})}
|
||
</Code>{" "}
|
||
…5.
|
||
</Text>
|
||
</Stack>
|
||
</Card>
|
||
);
|
||
}
|