Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/documents/FileUploadSettingsPage.tsx
2026-07-07 18:40:32 +00:00

347 lines
11 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,
Stack,
Table,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
FileUp,
HardDrive,
Layers,
Paperclip,
Search,
Settings,
X,
} 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 { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
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 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 columns = useMemo<ColumnDef<FileUploadSetting>[]>(() => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "setting",
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`,
},
// Settings are seeded/fixed — staff may only update a setting's fields,
// not create, edit, or delete the settings themselves.
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>
</Group>
);
},
},
];
}, []);
const tableStatus = 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}>
<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 style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={filtered}
status={tableStatus}
error={
isError
? {
message: "Failed to load settings.",
description:
error instanceof Error ? error.message : "Unknown error.",
onRetry: () => void refetch(),
}
: undefined
}
emptyMessage={
query.trim()
? "No file upload settings match your search."
: "No file upload settings configured."
}
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
/>
</Box>
</Stack>
</Card>
<BehaviorReferenceCard />
</PageContainer>
);
}
/**
* 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>
);
}