mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
rule engine UI, services and API integration
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface DeleteFileUploadSettingDialogProps {
|
||||
settingLabel: string;
|
||||
settingCode: string;
|
||||
onConfirm?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function DeleteFileUploadSettingDialog({
|
||||
settingLabel,
|
||||
settingCode,
|
||||
onConfirm,
|
||||
children,
|
||||
}: DeleteFileUploadSettingDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
Delete file upload setting?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
This will remove{" "}
|
||||
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
|
||||
(<span className="font-mono text-xs">{settingCode}</span>) and all
|
||||
of its fields. Forms referencing this code will fall back to no
|
||||
uploads.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Hash, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FileUploadEntity } from "@edr/types/freight";
|
||||
import { useCreateFileUploadSetting, useUpdateFileUploadSetting } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
// import type {
|
||||
// FileUploadEntity,
|
||||
// FileUploadSetting,
|
||||
// } from "@/types/fileUploadSettings";
|
||||
// import {
|
||||
// useCreateFileUploadSetting,
|
||||
// useUpdateFileUploadSetting,
|
||||
// } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
export interface EditFileUploadSettingDialogProps {
|
||||
mode?: "create" | "edit";
|
||||
setting?: FileUploadSetting;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
||||
|
||||
// const ENTITIES: FileUploadEntity[] = [
|
||||
// "customer",
|
||||
// "booking",
|
||||
// "consignment",
|
||||
// "shipment",
|
||||
// "invoice",
|
||||
// "train",
|
||||
// "other",
|
||||
// ];
|
||||
|
||||
export default function EditFileUploadSettingDialog({
|
||||
mode = "create",
|
||||
setting,
|
||||
children,
|
||||
}: EditFileUploadSettingDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [code, setCode] = useState(setting?.code ?? "");
|
||||
const [label, setLabel] = useState(setting?.label ?? "");
|
||||
const [entity, setEntity] = useState<FileUploadEntity>(
|
||||
setting?.entity ?? "other",
|
||||
);
|
||||
const [description, setDescription] = useState(setting?.description ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useCreateFileUploadSetting();
|
||||
const updateMutation = useUpdateFileUploadSetting();
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setCode(setting?.code ?? "");
|
||||
setLabel(setting?.label ?? "");
|
||||
setEntity(setting?.entity ?? "other");
|
||||
setDescription(setting?.description ?? "");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
if (!code.trim() || !label.trim()) {
|
||||
setError("Code and label are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
code: code.trim(),
|
||||
label: label.trim(),
|
||||
entity,
|
||||
description: description.trim() || undefined,
|
||||
};
|
||||
|
||||
const onDone = () => {
|
||||
setOpen(false);
|
||||
if (!isEdit) reset();
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Something went wrong. Try again.",
|
||||
);
|
||||
};
|
||||
|
||||
if (isEdit && setting) {
|
||||
updateMutation.mutate(
|
||||
{ id: setting.id, dto: payload },
|
||||
{ onSuccess: onDone, onError },
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{isEdit ? "Edit File Upload Setting" : "New File Upload Setting"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update the metadata for this file upload group."
|
||||
: "Define a new file upload group that a form can reference by code."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Code *</Label>
|
||||
<div className="relative">
|
||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="e.g. customer_registration"
|
||||
className="pl-10 font-mono"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{isEdit
|
||||
? "Code is immutable after creation."
|
||||
: "Stable identifier used in code. Use snake_case."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Label *</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Customer Registration"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label>Entity</Label>
|
||||
<select
|
||||
value={entity}
|
||||
onChange={(e) => setEntity(e.target.value as FileUploadEntity)}
|
||||
className={selectClass}
|
||||
>
|
||||
{ENTITIES.map((e) => (
|
||||
<option key={e} value={e} className="capitalize">
|
||||
{e[0]!.toUpperCase() + e.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-slate-500">
|
||||
Domain the upload group applies to.
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Field Count</Label>
|
||||
<Input
|
||||
disabled
|
||||
value={String(setting?.fields.length ?? 0)}
|
||||
className="bg-slate-50 text-slate-600"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Manage fields from the "Fields" action on the list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this upload group represents and where it's used..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isEdit ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create Setting"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Filter,
|
||||
FileUp,
|
||||
HardDrive,
|
||||
Layers,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
// 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 { useDeleteFileUploadSetting, useFileUploadSettings } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useFileUploadSettings();
|
||||
const deleteMutation = useDeleteFileUploadSetting();
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Setting
|
||||
</button>
|
||||
</EditFileUploadSettingDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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),
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import type {
|
||||
CreateFileUploadFieldDto,
|
||||
FileUploadSetting,
|
||||
} from "@/types/fileUploadSettings";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { useReplaceFileUploadFields } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
export interface ManageFileUploadFieldsDialogProps {
|
||||
setting: FileUploadSetting;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local draft used by the editor — does NOT need to satisfy IFileUploadField
|
||||
* (which carries server-only props like createdAt). On save, we strip the
|
||||
* client-only `key` and post the rest as CreateFileUploadFieldDto[].
|
||||
*/
|
||||
interface DraftField extends CreateFileUploadFieldDto {
|
||||
key: string;
|
||||
}
|
||||
|
||||
let draftCounter = 0;
|
||||
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
|
||||
|
||||
function makeEmptyDraft(idx: number): DraftField {
|
||||
return {
|
||||
key: nextKey(),
|
||||
fileKey: "",
|
||||
fileLabel: "",
|
||||
isRequired: false,
|
||||
isMultiple: false,
|
||||
maxFiles: 1,
|
||||
allowedExtensions: ["pdf"],
|
||||
maxSizeMb: 10,
|
||||
order: idx + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ManageFileUploadFieldsDialog({
|
||||
setting,
|
||||
children,
|
||||
}: ManageFileUploadFieldsDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const seed = (): DraftField[] =>
|
||||
[...setting.fields]
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((f, idx) => ({
|
||||
key: f.id,
|
||||
fileKey: f.fileKey,
|
||||
fileLabel: f.fileLabel,
|
||||
helpText: f.helpText ?? undefined,
|
||||
isRequired: f.isRequired,
|
||||
isMultiple: f.isMultiple,
|
||||
maxFiles: f.maxFiles,
|
||||
allowedExtensions: f.allowedExtensions,
|
||||
maxSizeMb: f.maxSizeMb,
|
||||
order: f.order ?? idx + 1,
|
||||
}));
|
||||
|
||||
const [fields, setFields] = useState<DraftField[]>(seed);
|
||||
|
||||
const replaceMutation = useReplaceFileUploadFields();
|
||||
|
||||
const update = (i: number, patch: Partial<DraftField>) =>
|
||||
setFields((prev) =>
|
||||
prev.map((f, idx) => {
|
||||
if (idx !== i) return f;
|
||||
const next = { ...f, ...patch };
|
||||
if (patch.isMultiple === false) next.maxFiles = 1;
|
||||
if (patch.isMultiple === true && next.maxFiles <= 1) next.maxFiles = 5;
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
|
||||
const updateExtensions = (i: number, raw: string) => {
|
||||
const list = raw
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
|
||||
.filter(Boolean);
|
||||
update(i, { allowedExtensions: list });
|
||||
};
|
||||
|
||||
const remove = (i: number) =>
|
||||
setFields((prev) => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
const add = () =>
|
||||
setFields((prev) => [...prev, makeEmptyDraft(prev.length)]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) =>
|
||||
setFields((prev) => {
|
||||
const next = [...prev];
|
||||
const target = i + dir;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
const a = next[i] as DraftField;
|
||||
const b = next[target] as DraftField;
|
||||
next[i] = { ...b, order: i + 1 };
|
||||
next[target] = { ...a, order: target + 1 };
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
|
||||
const invalid = fields.findIndex(
|
||||
(f) =>
|
||||
!f.fileKey.trim() ||
|
||||
!f.fileLabel.trim() ||
|
||||
f.allowedExtensions.length === 0,
|
||||
);
|
||||
if (invalid >= 0) {
|
||||
setError(
|
||||
`Field ${invalid + 1} is missing file key, label, or extensions.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateFileUploadFieldDto[] = fields.map((f, idx) => ({
|
||||
fileKey: f.fileKey.trim(),
|
||||
fileLabel: f.fileLabel.trim(),
|
||||
helpText: f.helpText?.trim() || undefined,
|
||||
isRequired: f.isRequired,
|
||||
isMultiple: f.isMultiple,
|
||||
maxFiles: f.isMultiple ? Math.max(1, f.maxFiles) : 1,
|
||||
allowedExtensions: f.allowedExtensions,
|
||||
maxSizeMb: f.maxSizeMb,
|
||||
order: idx + 1,
|
||||
}));
|
||||
|
||||
replaceMutation.mutate(
|
||||
{ settingId: setting.id, fields: payload },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
onError: (err) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to save fields. Try again.",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
setFields(seed());
|
||||
setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Manage Fields · {setting.label}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add, edit, reorder, or remove upload fields for{" "}
|
||||
<span className="font-mono text-slate-700">{setting.code}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
{fields.length} field{fields.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No fields yet. Click{" "}
|
||||
<span className="font-medium">Add Field</span> to start.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{fields.map((f, i) => (
|
||||
<FieldEditor
|
||||
key={f.key}
|
||||
field={f}
|
||||
index={i}
|
||||
total={fields.length}
|
||||
onChange={(patch) => update(i, patch)}
|
||||
onChangeExtensions={(raw) => updateExtensions(i, raw)}
|
||||
onMove={(dir) => move(i, dir)}
|
||||
onRemove={() => remove(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3 border-t border-slate-100 pt-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={replaceMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={replaceMutation.isPending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{replaceMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Save Fields"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEditor({
|
||||
field,
|
||||
index,
|
||||
total,
|
||||
onChange,
|
||||
onChangeExtensions,
|
||||
onMove,
|
||||
onRemove,
|
||||
}: {
|
||||
field: DraftField;
|
||||
index: number;
|
||||
total: number;
|
||||
onChange: (patch: Partial<DraftField>) => void;
|
||||
onChangeExtensions: (raw: string) => void;
|
||||
onMove: (dir: -1 | 1) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const minFiles = getMinFiles(field);
|
||||
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label="Move up"
|
||||
disabled={index === 0}
|
||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(1)}
|
||||
aria-label="Move down"
|
||||
disabled={index === total - 1}
|
||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
|
||||
Field {index + 1}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
min {minFiles} · max {effectiveMax}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove field"
|
||||
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">File Key *</Label>
|
||||
<Input
|
||||
value={field.fileKey}
|
||||
onChange={(e) => onChange({ fileKey: e.target.value })}
|
||||
placeholder="supporting_doc"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label className="text-xs">File Label *</Label>
|
||||
<Input
|
||||
value={field.fileLabel}
|
||||
onChange={(e) => onChange({ fileLabel: e.target.value })}
|
||||
placeholder="Supporting Document"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Max Size (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={field.maxSizeMb}
|
||||
onChange={(e) =>
|
||||
onChange({ maxSizeMb: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label className="text-xs">Allowed Extensions</Label>
|
||||
<Input
|
||||
value={field.allowedExtensions.join(", ")}
|
||||
onChange={(e) => onChangeExtensions(e.target.value)}
|
||||
placeholder="pdf, docx, jpg"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Comma-separated, no leading dot.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Max Files</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={field.maxFiles}
|
||||
disabled={!field.isMultiple}
|
||||
onChange={(e) =>
|
||||
onChange({ maxFiles: Number(e.target.value) })
|
||||
}
|
||||
className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""}
|
||||
/>
|
||||
{!field.isMultiple ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
Locked to 1 when single-file.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isRequired}
|
||||
onChange={(e) =>
|
||||
onChange({ isRequired: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.isMultiple}
|
||||
onChange={(e) =>
|
||||
onChange({ isMultiple: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||
/>
|
||||
Multiple
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-4">
|
||||
<Label className="text-xs">Help Text (optional)</Label>
|
||||
<Input
|
||||
value={field.helpText ?? ""}
|
||||
onChange={(e) => onChange({ helpText: e.target.value })}
|
||||
placeholder="e.g. PDF or photo of the original document."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface DeleteDropdownSettingDialogProps {
|
||||
settingLabel: string;
|
||||
settingCode: string;
|
||||
onConfirm?: () => void;
|
||||
children?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DeleteDropdownSettingDialog({
|
||||
settingLabel,
|
||||
settingCode,
|
||||
onConfirm,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: DeleteDropdownSettingDialogProps) {
|
||||
const isControlled = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||
|
||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
Delete dropdown setting?
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
This will remove{" "}
|
||||
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
|
||||
(<span className="font-mono text-xs">{settingCode}</span>) and all
|
||||
of its options. Forms referencing this code will fall back to
|
||||
empty options.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-2">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Filter,
|
||||
ListOrdered,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
|
||||
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
|
||||
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
|
||||
import {
|
||||
useDeleteDropdownSetting,
|
||||
useDropdownSettings,
|
||||
} from "@/hooks/useDropdownSettings";
|
||||
import type { DropdownSetting } from "@/types/dropdownSettings";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
Button,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type ActiveDialog = "edit" | "options" | "delete";
|
||||
|
||||
export default function DropdownSettingsPage() {
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
|
||||
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
|
||||
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
|
||||
// `pointer-events: none` on <body> when a menu closes and a dialog opens
|
||||
// in the same frame — wait two RAFs and then explicitly reset the body
|
||||
// style so the dialog interior is interactive.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
document.body.style.pointerEvents = "";
|
||||
setActiveSetting(setting);
|
||||
setActiveDialog(dialog);
|
||||
});
|
||||
});
|
||||
};
|
||||
const closeDialog = () => {
|
||||
setActiveDialog(null);
|
||||
// Keep activeSetting briefly so dialog content doesn't flash empty during
|
||||
// the close animation; cleared on next open.
|
||||
};
|
||||
|
||||
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
|
||||
// dialog changes, schedule a body-style cleanup after the next paint.
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => {
|
||||
if (document.body.style.pointerEvents === "none") {
|
||||
document.body.style.pointerEvents = "";
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [activeDialog]);
|
||||
|
||||
const { data, isLoading, isError, error } = useDropdownSettings();
|
||||
const deleteMutation = useDeleteDropdownSetting();
|
||||
|
||||
const dropdownSettings = useMemo<DropdownSetting[]>(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return dropdownSettings;
|
||||
return dropdownSettings.filter(
|
||||
(s) =>
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
s.label.toLowerCase().includes(q) ||
|
||||
(s.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}, [dropdownSettings, query]);
|
||||
|
||||
const total = filtered.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
const end = Math.min(start + pagination.pageSize, total);
|
||||
|
||||
const paginatedData = useMemo(
|
||||
() => filtered.slice(start, end),
|
||||
[start, end, filtered],
|
||||
);
|
||||
|
||||
const totalOptions = dropdownSettings.reduce(
|
||||
(sum, s) => sum + (s.children?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
|
||||
const searchableCount = dropdownSettings.filter(
|
||||
(s) => s.meta?.searchable,
|
||||
).length;
|
||||
|
||||
const status: "loading" | "error" | "success" = isLoading
|
||||
? "loading"
|
||||
: isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
const columns: ColumnDef<DropdownSetting>[] = [
|
||||
{
|
||||
id: "setting",
|
||||
header: "Setting",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Settings />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{s.label}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{s.description ?? "No description"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
header: "Code",
|
||||
cell: ({ row }) => (
|
||||
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
|
||||
{row.original.code}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "options",
|
||||
header: "Options",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<Boxes />
|
||||
<span className="font-medium">{s.children?.length ?? 0}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "behavior",
|
||||
header: "Behavior",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.multiple ? (
|
||||
<BehaviorChip label="Multi" />
|
||||
) : (
|
||||
<BehaviorChip label="Single" muted />
|
||||
)}
|
||||
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
|
||||
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
header: "Permissions",
|
||||
cell: ({ row }) => {
|
||||
const s = row.original;
|
||||
const perms = s.meta?.permissions ?? [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{perms.length === 0 ? (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
) : (
|
||||
perms.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
<Shield />
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 40,
|
||||
cell: ({ row }) => {
|
||||
const setting = row.original;
|
||||
return (
|
||||
<div
|
||||
className="flex justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Eye />
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => openDialogFor("options", setting)}
|
||||
>
|
||||
<CheckCircle2 />
|
||||
Options
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => openDialogFor("edit", setting)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => openDialogFor("delete", setting)}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6">
|
||||
<div className="space-y-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Admin", href: "/admin" },
|
||||
{ label: "Dropdown Settings" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card className="p-6 flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Dropdown Settings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-secondary-foreground">
|
||||
Manage every dynamic dropdown across the platform — labels,
|
||||
options, ordering, and permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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-2 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);
|
||||
setPagination({
|
||||
pageIndex: 0,
|
||||
pageSize: pagination.pageSize,
|
||||
});
|
||||
}}
|
||||
placeholder="Search by code, label, description..."
|
||||
className="pl-8!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditDropdownSettingDialog mode="create">
|
||||
<Button>
|
||||
<Plus />
|
||||
New Setting
|
||||
</Button>
|
||||
</EditDropdownSettingDialog>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
label="Settings"
|
||||
value={dropdownSettings.length}
|
||||
icon={<Settings />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Options"
|
||||
value={totalOptions}
|
||||
icon={<Boxes />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Multi-select"
|
||||
value={multipleCount}
|
||||
icon={<ListOrdered />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Searchable"
|
||||
value={searchableCount}
|
||||
icon={<Sparkles />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
Failed to load dropdown settings.{" "}
|
||||
{error instanceof Error ? error.message : "Unknown error."}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="gap-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||
<div>
|
||||
<CardTitle>Registered Dropdowns</CardTitle>
|
||||
<CardDescription>
|
||||
Every dynamic dropdown the platform reads from.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" size="sm">
|
||||
<Filter />
|
||||
Filter
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
|
||||
Loading dropdown settings…
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
status={status}
|
||||
onRowClick={() => { }}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-b shadow-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
|
||||
reliably after a menu item is selected. */}
|
||||
{activeSetting ? (
|
||||
<>
|
||||
<EditDropdownSettingDialog
|
||||
key={`edit-${activeSetting.id}`}
|
||||
mode="edit"
|
||||
setting={activeSetting}
|
||||
open={activeDialog === "edit"}
|
||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||
/>
|
||||
<ManageDropdownOptionsDialog
|
||||
key={`options-${activeSetting.id}`}
|
||||
setting={activeSetting}
|
||||
open={activeDialog === "options"}
|
||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||
/>
|
||||
<DeleteDropdownSettingDialog
|
||||
key={`delete-${activeSetting.id}`}
|
||||
settingLabel={activeSetting.label}
|
||||
settingCode={activeSetting.code}
|
||||
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
|
||||
open={activeDialog === "delete"}
|
||||
onOpenChange={(next) => (next ? null : closeDialog())}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{label}</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-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function BehaviorChip({
|
||||
label,
|
||||
muted = false,
|
||||
}: {
|
||||
label: string;
|
||||
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-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Hash, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import type {
|
||||
CreateDropdownSettingDto,
|
||||
DropdownSetting,
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
import {
|
||||
useCreateDropdownSetting,
|
||||
useUpdateDropdownSetting,
|
||||
} from "@/hooks/useDropdownSettings";
|
||||
|
||||
export interface EditDropdownSettingDialogProps {
|
||||
mode?: "create" | "edit";
|
||||
setting?: DropdownSetting;
|
||||
/** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */
|
||||
children?: ReactNode;
|
||||
/** Controlled open state. When provided, internal state is ignored. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function parsePermissions(raw: string): string[] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function EditDropdownSettingDialog({
|
||||
mode = "create",
|
||||
setting,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: EditDropdownSettingDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
const isControlled = openProp !== undefined;
|
||||
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const [code, setCode] = useState(setting?.code ?? "");
|
||||
const [label, setLabel] = useState(setting?.label ?? "");
|
||||
const [description, setDescription] = useState(setting?.description ?? "");
|
||||
const [icon, setIcon] = useState(setting?.meta?.icon ?? "");
|
||||
const [color, setColor] = useState(setting?.meta?.color ?? "");
|
||||
const [permissions, setPermissions] = useState(
|
||||
setting?.meta?.permissions?.join(", ") ?? "",
|
||||
);
|
||||
const [version, setVersion] = useState(setting?.meta?.version ?? "1.0");
|
||||
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
|
||||
const [searchable, setSearchable] = useState<boolean>(
|
||||
setting?.meta?.searchable ?? false,
|
||||
);
|
||||
const [clearable, setClearable] = useState<boolean>(
|
||||
setting?.meta?.clearable ?? false,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createMutation = useCreateDropdownSetting();
|
||||
const updateMutation = useUpdateDropdownSetting();
|
||||
const pending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const reset = () => {
|
||||
setCode(setting?.code ?? "");
|
||||
setLabel(setting?.label ?? "");
|
||||
setDescription(setting?.description ?? "");
|
||||
setIcon(setting?.meta?.icon ?? "");
|
||||
setColor(setting?.meta?.color ?? "");
|
||||
setPermissions(setting?.meta?.permissions?.join(", ") ?? "");
|
||||
setVersion(setting?.meta?.version ?? "1.0");
|
||||
setMultiple(setting?.multiple ?? false);
|
||||
setSearchable(setting?.meta?.searchable ?? false);
|
||||
setClearable(setting?.meta?.clearable ?? false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const buildPayload = (): CreateDropdownSettingDto => ({
|
||||
code: code.trim(),
|
||||
label: label.trim(),
|
||||
description: description.trim() || undefined,
|
||||
multiple,
|
||||
meta: {
|
||||
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||||
...(color.trim() ? { color: color.trim() } : {}),
|
||||
searchable,
|
||||
clearable,
|
||||
...(version.trim() ? { version: version.trim() } : {}),
|
||||
permissions: parsePermissions(permissions),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
setError(null);
|
||||
if (!code.trim() || !label.trim()) {
|
||||
setError("Code and label are required.");
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) {
|
||||
setError(
|
||||
"Code must start with a letter and contain only letters, digits, or underscores.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
|
||||
const onDone = () => {
|
||||
setOpen(false);
|
||||
if (!isEdit) reset();
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong. Try again.",
|
||||
);
|
||||
};
|
||||
|
||||
if (isEdit && setting) {
|
||||
// Update DTO omits `code` (immutable); strip it before sending.
|
||||
const { code: _unused, ...updateDto } = payload;
|
||||
void _unused;
|
||||
updateMutation.mutate(
|
||||
{ id: setting.id, dto: updateDto as UpdateDropdownSettingDto },
|
||||
{ onSuccess: onDone, onError },
|
||||
);
|
||||
} else {
|
||||
createMutation.mutate(payload, { onSuccess: onDone, onError });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
{isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update the metadata for this dropdown setting."
|
||||
: "Define a new dynamic dropdown that admins can manage."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Code *</Label>
|
||||
<div className="relative">
|
||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="e.g. cargo_type"
|
||||
className="pl-10 font-mono"
|
||||
disabled={isEdit}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{isEdit
|
||||
? "Code is immutable after creation."
|
||||
: "Stable identifier used in code. Use snake_case."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Label *</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Cargo Type"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What this dropdown represents and where it's used..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Icon (meta.icon)</Label>
|
||||
<Input
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="lucide icon name, e.g. package"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color (meta.color)</Label>
|
||||
<Input
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
placeholder="#10B981"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Permissions (comma-separated)</Label>
|
||||
<Input
|
||||
value={permissions}
|
||||
onChange={(e) => setPermissions(e.target.value)}
|
||||
placeholder="admin, ops"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Version (meta.version)</Label>
|
||||
<Input
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Behavior</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ToggleChip
|
||||
checked={multiple}
|
||||
onChange={setMultiple}
|
||||
label="Multi-select"
|
||||
description="Users can pick more than one option"
|
||||
/>
|
||||
<ToggleChip
|
||||
checked={searchable}
|
||||
onChange={setSearchable}
|
||||
label="Searchable"
|
||||
description="Show a search input in the dropdown"
|
||||
/>
|
||||
<ToggleChip
|
||||
checked={clearable}
|
||||
onChange={setClearable}
|
||||
label="Clearable"
|
||||
description="Allow users to clear the selection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={pending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : isEdit ? (
|
||||
"Save Changes"
|
||||
) : (
|
||||
"Create Setting"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleChip({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
label: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={
|
||||
checked
|
||||
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#10B981]/40 bg-[#10B981]/10 px-3 py-2 text-sm"
|
||||
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#10B981]/30 hover:bg-[#10B981]/5"
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{label}</p>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import type {
|
||||
CreateDropdownOptionDto,
|
||||
DropdownSetting,
|
||||
} from "@/types/dropdownSettings";
|
||||
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
|
||||
|
||||
export interface ManageDropdownOptionsDialogProps {
|
||||
setting: DropdownSetting;
|
||||
children?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local draft used by the editor — uses a stable client-only `key` so React
|
||||
* keys remain stable across reorders. On save we strip `key` and POST the
|
||||
* remainder as CreateDropdownOptionDto[].
|
||||
*/
|
||||
interface DraftOption extends CreateDropdownOptionDto {
|
||||
key: string;
|
||||
}
|
||||
|
||||
let draftCounter = 0;
|
||||
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
|
||||
|
||||
function makeEmptyDraft(idx: number): DraftOption {
|
||||
return {
|
||||
key: nextKey(),
|
||||
value: "",
|
||||
label: "",
|
||||
disabled: false,
|
||||
order: idx + 1,
|
||||
meta: {},
|
||||
};
|
||||
}
|
||||
|
||||
export default function ManageDropdownOptionsDialog({
|
||||
setting,
|
||||
children,
|
||||
open: openProp,
|
||||
onOpenChange,
|
||||
}: ManageDropdownOptionsDialogProps) {
|
||||
const isControlled = openProp !== undefined;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? openProp : internalOpen;
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
};
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const seed = (): DraftOption[] =>
|
||||
[...(setting.children ?? [])]
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
.map((o, idx) => ({
|
||||
key: o.id,
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
note: o.note ?? undefined,
|
||||
disabled: o.disabled,
|
||||
order: o.order ?? idx + 1,
|
||||
meta: {
|
||||
...(o.meta?.icon ? { icon: o.meta.icon } : {}),
|
||||
...(o.meta?.color ? { color: o.meta.color } : {}),
|
||||
...(o.meta?.badge ? { badge: o.meta.badge } : {}),
|
||||
},
|
||||
}));
|
||||
|
||||
const [options, setOptions] = useState<DraftOption[]>(seed);
|
||||
|
||||
const replaceMutation = useReplaceDropdownOptions();
|
||||
|
||||
const update = (i: number, patch: Partial<DraftOption>) =>
|
||||
setOptions((prev) =>
|
||||
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
|
||||
);
|
||||
|
||||
const updateMeta = (
|
||||
i: number,
|
||||
patch: Partial<NonNullable<DraftOption["meta"]>>,
|
||||
) =>
|
||||
setOptions((prev) =>
|
||||
prev.map((o, idx) =>
|
||||
idx === i ? { ...o, meta: { ...(o.meta ?? {}), ...patch } } : o,
|
||||
),
|
||||
);
|
||||
|
||||
const remove = (i: number) =>
|
||||
setOptions((prev) => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
const add = () =>
|
||||
setOptions((prev) => [...prev, makeEmptyDraft(prev.length)]);
|
||||
|
||||
const move = (i: number, dir: -1 | 1) =>
|
||||
setOptions((prev) => {
|
||||
const next = [...prev];
|
||||
const target = i + dir;
|
||||
if (target < 0 || target >= next.length) return prev;
|
||||
const a = next[i] as DraftOption;
|
||||
const b = next[target] as DraftOption;
|
||||
next[i] = { ...b, order: i + 1 };
|
||||
next[target] = { ...a, order: target + 1 };
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
setError(null);
|
||||
|
||||
const invalid = options.findIndex(
|
||||
(o) => !o.label.trim() || !o.value.trim(),
|
||||
);
|
||||
if (invalid >= 0) {
|
||||
setError(`Option ${invalid + 1} is missing a label or value.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateDropdownOptionDto[] = options.map((o, idx) => {
|
||||
const meta: NonNullable<CreateDropdownOptionDto["meta"]> = {};
|
||||
if (o.meta?.icon?.trim()) meta.icon = o.meta.icon.trim();
|
||||
if (o.meta?.color?.trim()) meta.color = o.meta.color.trim();
|
||||
if (o.meta?.badge?.trim()) meta.badge = o.meta.badge.trim();
|
||||
|
||||
return {
|
||||
value: o.value.trim(),
|
||||
label: o.label.trim(),
|
||||
note: o.note?.trim() || undefined,
|
||||
disabled: o.disabled ?? false,
|
||||
order: idx + 1,
|
||||
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
replaceMutation.mutate(
|
||||
{ settingId: setting.id, options: payload },
|
||||
{
|
||||
onSuccess: () => setOpen(false),
|
||||
onError: (err) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to save options. Try again.",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) setOptions(seed());
|
||||
if (!next) setError(null);
|
||||
}}
|
||||
>
|
||||
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
|
||||
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">
|
||||
Manage Options · {setting.label}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add, edit, reorder, or remove options for{" "}
|
||||
<span className="font-mono text-slate-700">{setting.code}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
{options.length} option{options.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Option
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
|
||||
No options yet. Click{" "}
|
||||
<span className="font-medium">Add Option</span> to start.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{options.map((opt, i) => (
|
||||
<div
|
||||
key={opt.key}
|
||||
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-slate-400">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, -1)}
|
||||
aria-label="Move up"
|
||||
disabled={i === 0}
|
||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(i, 1)}
|
||||
aria-label="Move down"
|
||||
disabled={i === options.length - 1}
|
||||
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Label *</Label>
|
||||
<Input
|
||||
value={opt.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="Display label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Value *</Label>
|
||||
<Input
|
||||
value={opt.value}
|
||||
onChange={(e) => update(i, { value: e.target.value })}
|
||||
placeholder="Stored value"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Note</Label>
|
||||
<Input
|
||||
value={opt.note ?? ""}
|
||||
onChange={(e) => update(i, { note: e.target.value })}
|
||||
placeholder="Helper text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Badge</Label>
|
||||
<Input
|
||||
value={opt.meta?.badge ?? ""}
|
||||
onChange={(e) => updateMeta(i, { badge: e.target.value })}
|
||||
placeholder="—"
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Color</Label>
|
||||
<Input
|
||||
value={opt.meta?.color ?? ""}
|
||||
onChange={(e) => updateMeta(i, { color: e.target.value })}
|
||||
placeholder="#…"
|
||||
className="w-24 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-1 text-xs text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={opt.disabled ?? false}
|
||||
onChange={(e) =>
|
||||
update(i, { disabled: e.target.checked })
|
||||
}
|
||||
className="h-3.5 w-3.5 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
|
||||
/>
|
||||
Off
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
aria-label={`Remove ${opt.label || "option"}`}
|
||||
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={replaceMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={replaceMutation.isPending}
|
||||
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||
>
|
||||
{replaceMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Save Options"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user