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