Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx
Marshal a8788eb549 enhance contract and train scheduling features
- 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.
2026-08-01 05:37:46 +00:00

362 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
&quot;Max files&quot; 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>
);
}