mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
style: finalize the style of the dashboard
This commit is contained in:
@@ -1,33 +1,47 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Filter,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
FileUp,
|
||||
HardDrive,
|
||||
Layers,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
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 { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
|
||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
// import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery(
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.fileUploadSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useDeleteFileUploadSetting();
|
||||
@@ -66,397 +80,298 @@ export default function FileUploadSettingsPage() {
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "File Upload Settings" },
|
||||
]}
|
||||
/>
|
||||
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`,
|
||||
},
|
||||
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>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
File Upload Settings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Define the file inputs every form in the platform should render —
|
||||
required/optional, single/multiple, allowed types and size.
|
||||
</p>
|
||||
</div>
|
||||
<EditFileUploadSettingDialog mode="edit" setting={setting}>
|
||||
<ActionIcon variant="default" aria-label="Edit setting">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</EditFileUploadSettingDialog>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by code, label, or file key..."
|
||||
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditFileUploadSettingDialog mode="create">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-35 items-center justify-center gap-2 rounded-md bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
<DeleteFileUploadSettingDialog
|
||||
settingLabel={setting.label}
|
||||
settingCode={setting.code}
|
||||
onConfirm={() => deleteMutation.mutate(setting.id)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Setting
|
||||
</button>
|
||||
</EditFileUploadSettingDialog>
|
||||
</div>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
color="red"
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="Delete setting"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</DeleteFileUploadSettingDialog>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [deleteMutation]);
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Settings"
|
||||
value={String(fileUploadSettings.length)}
|
||||
icon={<Settings className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Fields"
|
||||
value={String(totalFields)}
|
||||
icon={<Paperclip className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Required"
|
||||
value={String(requiredFields)}
|
||||
icon={<FileUp className="h-5 w-5" />}
|
||||
/>
|
||||
<StatCard
|
||||
title="Multi-file"
|
||||
value={String(multiFields)}
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Registered File Upload Groups
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Every group a form can reference by code.
|
||||
</p>
|
||||
</div>
|
||||
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."
|
||||
action={
|
||||
<EditFileUploadSettingDialog mode="create">
|
||||
<Button leftSection={<Plus size={18} />}>New setting</Button>
|
||||
</EditFileUploadSettingDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
<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 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1100px] whitespace-nowrap text-left">
|
||||
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">Setting</th>
|
||||
<th className="px-6 py-4 font-medium">Code</th>
|
||||
<th className="px-6 py-4 font-medium">Entity</th>
|
||||
<th className="px-6 py-4 font-medium">Fields</th>
|
||||
<th className="px-6 py-4 font-medium">Required / Multi</th>
|
||||
<th className="px-6 py-4 font-medium">Max Size</th>
|
||||
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Loading file upload settings…
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : isError ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
Failed to load settings.{" "}
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="px-6 py-12 text-center text-sm text-slate-500"
|
||||
>
|
||||
{fileUploadSettings.length === 0
|
||||
? "No file upload settings yet. Click \"New Setting\" to add one."
|
||||
: "No file upload settings match your search."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((setting) => {
|
||||
const required = setting.fields.filter(
|
||||
(f: any) => f.isRequired,
|
||||
).length;
|
||||
const multi = setting.fields.filter(
|
||||
(f: any) => f.isMultiple,
|
||||
).length;
|
||||
const maxSize = Math.max(
|
||||
0,
|
||||
...setting.fields.map((f) => f.maxSizeMb),
|
||||
);
|
||||
<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 yet. Click "New setting" to add one.'
|
||||
}
|
||||
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={setting.id}
|
||||
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
|
||||
<FileUp className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">
|
||||
{setting.label}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{setting.description ?? "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<BehaviorReferenceCard />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{setting.code}
|
||||
</span>
|
||||
</td>
|
||||
/**
|
||||
* 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" },
|
||||
];
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
|
||||
{setting.entity ?? "—"}
|
||||
</span>
|
||||
</td>
|
||||
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>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<Paperclip className="h-4 w-4 text-[#10B981]" />
|
||||
<span className="font-medium">
|
||||
{setting.fields.length}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Chip>{required} required</Chip>
|
||||
<Chip muted>{multi} multi</Chip>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 text-sm text-slate-700">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-4 w-4 text-slate-400" />
|
||||
{maxSize ? `${maxSize} MB` : "—"}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<ManageFileUploadFieldsDialog setting={setting}>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
Fields
|
||||
</button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
|
||||
<EditFileUploadSettingDialog
|
||||
mode="edit"
|
||||
setting={setting}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
</EditFileUploadSettingDialog>
|
||||
|
||||
<DeleteFileUploadSettingDialog
|
||||
settingLabel={setting.label}
|
||||
settingCode={setting.code}
|
||||
onConfirm={() =>
|
||||
deleteMutation.mutate(setting.id)
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deleteMutation.isPending}
|
||||
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</DeleteFileUploadSettingDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behavior reference card */}
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Required × Multiple behavior
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Min and max file counts are derived from these two toggles. The
|
||||
"Max Files" you set on a field is only used when{" "}
|
||||
<span className="font-medium">Multiple</span> is on.
|
||||
</p>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full whitespace-nowrap text-left text-sm">
|
||||
<thead className="text-xs text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">Required</th>
|
||||
<th className="py-2 font-medium">Multiple</th>
|
||||
<th className="py-2 font-medium">min_files</th>
|
||||
<th className="py-2 font-medium">max_files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<BehaviorRow
|
||||
required={false}
|
||||
multiple={false}
|
||||
min="0"
|
||||
max="1"
|
||||
/>
|
||||
<BehaviorRow
|
||||
required={true}
|
||||
multiple={false}
|
||||
min="1"
|
||||
max="1"
|
||||
/>
|
||||
<BehaviorRow
|
||||
required={false}
|
||||
multiple={true}
|
||||
min="0"
|
||||
max="field.maxFiles"
|
||||
/>
|
||||
<BehaviorRow
|
||||
required={true}
|
||||
multiple={true}
|
||||
min="1"
|
||||
max="field.maxFiles"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Helpers <span className="font-mono">getMinFiles</span> and{" "}
|
||||
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
|
||||
<span className="font-mono">@/types/fileUploadSettings</span> — use
|
||||
them when wiring real uploaders. Example: a field with{" "}
|
||||
<span className="font-mono">isRequired=false</span>,{" "}
|
||||
<span className="font-mono">isMultiple=true</span>,{" "}
|
||||
<span className="font-mono">maxFiles=5</span> gives{" "}
|
||||
<span className="font-mono">{getMinFiles({
|
||||
id: "demo",
|
||||
fileKey: "demo",
|
||||
fileLabel: "demo",
|
||||
<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,
|
||||
isMultiple: true,
|
||||
maxFiles: 5,
|
||||
allowedExtensions: [],
|
||||
maxSizeMb: 1,
|
||||
})}</span>
|
||||
…5.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BehaviorRow({
|
||||
required,
|
||||
multiple,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
required: boolean;
|
||||
multiple: boolean;
|
||||
min: string;
|
||||
max: string;
|
||||
}) {
|
||||
return (
|
||||
<tr className="border-t border-slate-100">
|
||||
<td className="py-2.5">
|
||||
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
|
||||
</td>
|
||||
<td className="py-2.5 font-mono text-slate-700">{min}</td>
|
||||
<td className="py-2.5 font-mono text-slate-700">{max}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
children,
|
||||
muted = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
muted
|
||||
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
|
||||
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{title}</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</Code>{" "}
|
||||
…5.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user