Add admin page with 4 tabs for managing queues, lifecycles, scrips, and custom fields
This commit is contained in:
745
web/src/app/admin/page.tsx
Normal file
745
web/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,745 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
getQueues,
|
||||
createQueue,
|
||||
getLifecycles,
|
||||
createLifecycle,
|
||||
getScrips,
|
||||
createScrip,
|
||||
getCustomFields,
|
||||
createCustomField,
|
||||
} from "@/lib/api";
|
||||
import type { Queue, Lifecycle, Scrip, CustomField } from "@/lib/types";
|
||||
|
||||
const LIFECYCLE_PLACEHOLDER = `{
|
||||
"statuses": {
|
||||
"initial": ["new"],
|
||||
"active": ["open", "in_progress"],
|
||||
"inactive": ["resolved", "closed"]
|
||||
},
|
||||
"transitions": {
|
||||
"new": ["open"],
|
||||
"open": ["in_progress", "resolved"],
|
||||
"in_progress": ["resolved"],
|
||||
"*": ["closed"]
|
||||
}
|
||||
}`;
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Admin</h1>
|
||||
<Tabs defaultValue="queues">
|
||||
<TabsList>
|
||||
<TabsTrigger value="queues">Queues</TabsTrigger>
|
||||
<TabsTrigger value="lifecycles">Lifecycles</TabsTrigger>
|
||||
<TabsTrigger value="scrips">Scrips</TabsTrigger>
|
||||
<TabsTrigger value="customfields">Custom Fields</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="queues">
|
||||
<QueuesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="lifecycles">
|
||||
<LifecyclesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="scrips">
|
||||
<ScripsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="customfields">
|
||||
<CustomFieldsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuesTab() {
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const fetchQueues = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error } = await getQueues();
|
||||
if (error) setError(error);
|
||||
else setQueues(data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueues();
|
||||
}, [fetchQueues]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const { error } = await createQueue({ name: name.trim(), description: description.trim() || undefined });
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setSaveError(error);
|
||||
} else {
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
fetchQueues();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Queues</h2>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Queue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="text-sm text-neutral-400 py-6">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{queues.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-neutral-400 text-center py-6">
|
||||
No queues yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
queues.map((q) => (
|
||||
<TableRow key={q.id}>
|
||||
<TableCell className="font-medium">{q.name}</TableCell>
|
||||
<TableCell className="text-neutral-400">{q.description ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Queue</DialogTitle>
|
||||
<DialogDescription>Create a new ticket queue.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="q-name">Name</Label>
|
||||
<Input
|
||||
id="q-name"
|
||||
placeholder="Queue name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="q-desc">Description</Label>
|
||||
<Textarea
|
||||
id="q-desc"
|
||||
placeholder="Optional description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{saveError && <div className="text-sm text-red-400">{saveError}</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || saving}>
|
||||
{saving ? "Saving..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LifecyclesTab() {
|
||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [definition, setDefinition] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const fetchLifecycles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error } = await getLifecycles();
|
||||
if (error) setError(error);
|
||||
else setLifecycles(data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLifecycles();
|
||||
}, [fetchLifecycles]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim() || !definition.trim()) return;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(definition);
|
||||
} catch {
|
||||
setSaveError("Invalid JSON definition.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const { error } = await createLifecycle({ name: name.trim(), definition: parsed });
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setSaveError(error);
|
||||
} else {
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
setDefinition("");
|
||||
fetchLifecycles();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Lifecycles</h2>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Lifecycle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="text-sm text-neutral-400 py-6">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lifecycles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell className="text-neutral-400 text-center py-6">
|
||||
No lifecycles yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
lifecycles.map((l) => (
|
||||
<TableRow key={l.id}>
|
||||
<TableCell className="font-medium">{l.name}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Lifecycle</DialogTitle>
|
||||
<DialogDescription>Create a new lifecycle with a JSON definition.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="lc-name">Name</Label>
|
||||
<Input
|
||||
id="lc-name"
|
||||
placeholder="Lifecycle name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="lc-def">Definition (JSON)</Label>
|
||||
<Textarea
|
||||
id="lc-def"
|
||||
placeholder={LIFECYCLE_PLACEHOLDER}
|
||||
value={definition}
|
||||
onChange={(e) => setDefinition(e.target.value)}
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{saveError && <div className="text-sm text-red-400">{saveError}</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || !definition.trim() || saving}>
|
||||
{saving ? "Saving..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScripsTab() {
|
||||
const [scrips, setScrips] = useState<Scrip[]>([]);
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [queueId, setQueueId] = useState("");
|
||||
const [conditionType, setConditionType] = useState("OnCreate");
|
||||
const [actionType, setActionType] = useState("SendEmail");
|
||||
const [actionConfig, setActionConfig] = useState("");
|
||||
const [stage, setStage] = useState("TransactionCreate");
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const fetchScrips = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [sRes, qRes] = await Promise.all([getScrips(), getQueues()]);
|
||||
if (sRes.error) setError(sRes.error);
|
||||
else setScrips(sRes.data ?? []);
|
||||
if (qRes.error) setError(qRes.error);
|
||||
else setQueues(qRes.data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchScrips();
|
||||
}, [fetchScrips]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
let parsedConfig: Record<string, unknown> | undefined;
|
||||
if (actionConfig.trim()) {
|
||||
try {
|
||||
parsedConfig = JSON.parse(actionConfig);
|
||||
} catch {
|
||||
setSaveError("Invalid JSON in action config.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const { error } = await createScrip({
|
||||
name: name.trim(),
|
||||
queue_id: queueId || null,
|
||||
condition_type: conditionType,
|
||||
action_type: actionType,
|
||||
action_config: parsedConfig,
|
||||
stage,
|
||||
sort_order: sortOrder,
|
||||
disabled,
|
||||
});
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setSaveError(error);
|
||||
} else {
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
setQueueId("");
|
||||
setConditionType("OnCreate");
|
||||
setActionType("SendEmail");
|
||||
setActionConfig("");
|
||||
setStage("TransactionCreate");
|
||||
setSortOrder(0);
|
||||
setDisabled(false);
|
||||
fetchScrips();
|
||||
}
|
||||
};
|
||||
|
||||
const queueName = (id: string | null) => {
|
||||
if (!id) return "Global";
|
||||
return queues.find((q) => q.id === id)?.name ?? id;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Scrips</h2>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Scrip
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="text-sm text-neutral-400 py-6">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Queue</TableHead>
|
||||
<TableHead>Condition</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{scrips.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-neutral-400 text-center py-6">
|
||||
No scrips yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
scrips.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{queueName(s.queue_id)}</TableCell>
|
||||
<TableCell>{s.condition_type}</TableCell>
|
||||
<TableCell>{s.action_type}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.disabled ? "outline" : "default"}>
|
||||
{s.disabled ? "Disabled" : "Enabled"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Scrip</DialogTitle>
|
||||
<DialogDescription>Create a new automation scrip.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-name">Name</Label>
|
||||
<Input
|
||||
id="s-name"
|
||||
placeholder="Scrip name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-queue">Queue</Label>
|
||||
<Select value={queueId} onValueChange={(v) => setQueueId(v === "_global" || !v ? "" : v)}>
|
||||
<SelectTrigger id="s-queue">
|
||||
<SelectValue placeholder="Global" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_global">Global</SelectItem>
|
||||
{queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-cond">Condition Type</Label>
|
||||
<Select value={conditionType} onValueChange={(v) => setConditionType(v ?? "OnCreate")}>
|
||||
<SelectTrigger id="s-cond">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OnCreate">OnCreate</SelectItem>
|
||||
<SelectItem value="OnStatusChange">OnStatusChange</SelectItem>
|
||||
<SelectItem value="OnResolve">OnResolve</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-action">Action Type</Label>
|
||||
<Select value={actionType} onValueChange={(v) => setActionType(v ?? "SendEmail")}>
|
||||
<SelectTrigger id="s-action">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SendEmail">SendEmail</SelectItem>
|
||||
<SelectItem value="Webhook">Webhook</SelectItem>
|
||||
<SelectItem value="SetCustomField">SetCustomField</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-config">Action Config (JSON)</Label>
|
||||
<Textarea
|
||||
id="s-config"
|
||||
placeholder='{"key": "value"}'
|
||||
value={actionConfig}
|
||||
onChange={(e) => setActionConfig(e.target.value)}
|
||||
className="min-h-20 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-stage">Stage</Label>
|
||||
<Select value={stage} onValueChange={(v) => setStage(v ?? "TransactionCreate")}>
|
||||
<SelectTrigger id="s-stage">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TransactionCreate">TransactionCreate</SelectItem>
|
||||
<SelectItem value="TransactionBatch">TransactionBatch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="s-order">Sort Order</Label>
|
||||
<Input
|
||||
id="s-order"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="s-disabled"
|
||||
type="checkbox"
|
||||
checked={disabled}
|
||||
onChange={(e) => setDisabled(e.target.checked)}
|
||||
className="size-4 rounded border-neutral-700 bg-neutral-800 accent-primary"
|
||||
/>
|
||||
<Label htmlFor="s-disabled" className="cursor-pointer">
|
||||
Disabled
|
||||
</Label>
|
||||
</div>
|
||||
{saveError && <div className="text-sm text-red-400">{saveError}</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || saving}>
|
||||
{saving ? "Saving..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomFieldsTab() {
|
||||
const [fields, setFields] = useState<CustomField[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [fieldType, setFieldType] = useState("Freeform");
|
||||
const [values, setValues] = useState("");
|
||||
const [maxValues, setMaxValues] = useState(0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const fetchFields = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error } = await getCustomFields();
|
||||
if (error) setError(error);
|
||||
else setFields(data ?? []);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFields();
|
||||
}, [fetchFields]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) return;
|
||||
let parsedValues: unknown = null;
|
||||
if (values.trim()) {
|
||||
try {
|
||||
parsedValues = JSON.parse(values);
|
||||
} catch {
|
||||
setSaveError("Invalid JSON in values.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const { error } = await createCustomField({
|
||||
name: name.trim(),
|
||||
field_type: fieldType,
|
||||
values: parsedValues,
|
||||
max_values: maxValues,
|
||||
});
|
||||
setSaving(false);
|
||||
if (error) {
|
||||
setSaveError(error);
|
||||
} else {
|
||||
setDialogOpen(false);
|
||||
setName("");
|
||||
setFieldType("Freeform");
|
||||
setValues("");
|
||||
setMaxValues(0);
|
||||
fetchFields();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Custom Fields</h2>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add Custom Field
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="text-sm text-neutral-400 py-6">Loading...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Max Values</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-neutral-400 text-center py-6">
|
||||
No custom fields yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
fields.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell className="font-medium">{f.name}</TableCell>
|
||||
<TableCell>{f.field_type}</TableCell>
|
||||
<TableCell>{f.max_values === 0 ? "Unlimited" : f.max_values}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Custom Field</DialogTitle>
|
||||
<DialogDescription>Create a new custom field definition.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cf-name">Name</Label>
|
||||
<Input
|
||||
id="cf-name"
|
||||
placeholder="Field name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cf-type">Field Type</Label>
|
||||
<Select value={fieldType} onValueChange={(v) => setFieldType(v ?? "Freeform")}>
|
||||
<SelectTrigger id="cf-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Freeform">Freeform</SelectItem>
|
||||
<SelectItem value="SelectSingle">SelectSingle</SelectItem>
|
||||
<SelectItem value="SelectMulti">SelectMulti</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cf-values">Values (JSON array, for select types)</Label>
|
||||
<Textarea
|
||||
id="cf-values"
|
||||
placeholder='["Option A", "Option B"]'
|
||||
value={values}
|
||||
onChange={(e) => setValues(e.target.value)}
|
||||
className="min-h-20 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cf-max">Max Values (0 = unlimited)</Label>
|
||||
<Input
|
||||
id="cf-max"
|
||||
type="number"
|
||||
min={0}
|
||||
value={maxValues}
|
||||
onChange={(e) => setMaxValues(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
{saveError && <div className="text-sm text-red-400">{saveError}</div>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || saving}>
|
||||
{saving ? "Saving..." : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user