diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx
new file mode 100644
index 0000000..7cf1687
--- /dev/null
+++ b/web/src/app/admin/page.tsx
@@ -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 (
+
+
Admin
+
+
+ Queues
+ Lifecycles
+ Scrips
+ Custom Fields
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function QueuesTab() {
+ const [queues, setQueues] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(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 (
+
+
+
Queues
+
+
+
+ {error &&
{error}
}
+ {loading ? (
+
Loading...
+ ) : (
+
+
+
+ Name
+ Description
+
+
+
+ {queues.length === 0 ? (
+
+
+ No queues yet.
+
+
+ ) : (
+ queues.map((q) => (
+
+ {q.name}
+ {q.description ?? "-"}
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ );
+}
+
+function LifecyclesTab() {
+ const [lifecycles, setLifecycles] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [definition, setDefinition] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(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;
+ 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 (
+
+
+
Lifecycles
+
+
+
+ {error &&
{error}
}
+ {loading ? (
+
Loading...
+ ) : (
+
+
+
+ Name
+
+
+
+ {lifecycles.length === 0 ? (
+
+
+ No lifecycles yet.
+
+
+ ) : (
+ lifecycles.map((l) => (
+
+ {l.name}
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ );
+}
+
+function ScripsTab() {
+ const [scrips, setScrips] = useState([]);
+ const [queues, setQueues] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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(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 | 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 (
+
+
+
Scrips
+
+
+
+ {error &&
{error}
}
+ {loading ? (
+
Loading...
+ ) : (
+
+
+
+ Name
+ Queue
+ Condition
+ Action
+ Enabled
+
+
+
+ {scrips.length === 0 ? (
+
+
+ No scrips yet.
+
+
+ ) : (
+ scrips.map((s) => (
+
+ {s.name}
+ {queueName(s.queue_id)}
+ {s.condition_type}
+ {s.action_type}
+
+
+ {s.disabled ? "Disabled" : "Enabled"}
+
+
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ );
+}
+
+function CustomFieldsTab() {
+ const [fields, setFields] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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(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 (
+
+
+
Custom Fields
+
+
+
+ {error &&
{error}
}
+ {loading ? (
+
Loading...
+ ) : (
+
+
+
+ Name
+ Type
+ Max Values
+
+
+
+ {fields.length === 0 ? (
+
+
+ No custom fields yet.
+
+
+ ) : (
+ fields.map((f) => (
+
+ {f.name}
+ {f.field_type}
+ {f.max_values === 0 ? "Unlimited" : f.max_values}
+
+ ))
+ )}
+
+
+ )}
+
+
+
+ );
+}