Add admin page with 4 tabs for managing queues, lifecycles, scrips, and custom fields

This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:02:11 +02:00
parent 1029176873
commit 73cf283f06

745
web/src/app/admin/page.tsx Normal file
View 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>
);
}