diff --git a/web/README.md b/web/README.md
index e215bc4..9bd8146 100644
--- a/web/README.md
+++ b/web/README.md
@@ -14,7 +14,7 @@ pnpm dev
bun dev
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Open [http://127.0.0.1:3100](http://127.0.0.1:3100) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
diff --git a/web/next.config.ts b/web/next.config.ts
index 2b8042b..97057e2 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -1,11 +1,19 @@
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
+const appRoot = dirname(fileURLToPath(import.meta.url));
+
const nextConfig: NextConfig = {
+ outputFileTracingRoot: appRoot,
+ turbopack: {
+ root: appRoot,
+ },
async rewrites() {
return [
{
- source: '/api/:path*',
- destination: 'http://127.0.0.1:9876/:path*',
+ source: "/api/:path*",
+ destination: "http://127.0.0.1:9876/:path*",
},
];
},
diff --git a/web/package-lock.json b/web/package-lock.json
index 862645b..9ff1620 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,15 +9,21 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.5.0",
+ "@hookform/resolvers": "^5.4.0",
+ "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.4.0",
"lucide-react": "^1.17.0",
"next": "16.2.7",
+ "next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-hook-form": "^7.77.0",
"shadcn": "^4.10.0",
"tailwind-merge": "^3.6.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -943,6 +949,18 @@
"hono": "^4"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
+ "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1981,6 +1999,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2261,6 +2285,39 @@
"tailwindcss": "4.3.0"
}
},
+ "node_modules/@tanstack/react-table": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
+ "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/table-core": "8.21.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/@tanstack/table-core": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
+ "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -3947,6 +4004,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7217,6 +7284,16 @@
}
}
},
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+ "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -8002,6 +8079,22 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.77.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz",
+ "integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
diff --git a/web/package.json b/web/package.json
index 0e1fa7a..0a2f6dc 100644
--- a/web/package.json
+++ b/web/package.json
@@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev",
+ "dev": "next dev --webpack -H 127.0.0.1 --port 3100",
+ "dev:prod": "next build && next start -H 127.0.0.1 --port 3100",
"build": "next build",
- "start": "next start",
+ "start": "next start -H 127.0.0.1 --port 3100",
"lint": "eslint"
},
"dependencies": {
diff --git a/web/src/app/admin/page-content.tsx b/web/src/app/admin/page-content.tsx
index 7cf1687..02a4b28 100644
--- a/web/src/app/admin/page-content.tsx
+++ b/web/src/app/admin/page-content.tsx
@@ -1,7 +1,16 @@
"use client";
-import { useEffect, useState, useCallback } from "react";
-import { Plus, RefreshCw } from "lucide-react";
+import { useEffect, useState, useCallback, useRef } from "react";
+import type { ReactNode } from "react";
+import {
+ ActivityIcon,
+ DatabaseIcon,
+ FileTextIcon,
+ GitBranchIcon,
+ PlusIcon,
+ Settings2Icon,
+ SlidersHorizontalIcon,
+} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -28,63 +37,135 @@ import {
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog";
import {
getQueues,
+ getTickets,
createQueue,
+ updateQueue,
getLifecycles,
createLifecycle,
+ updateLifecycle,
getScrips,
createScrip,
+ updateScrip,
+ getTemplates,
+ createTemplate,
+ updateTemplate,
+ previewTemplate,
getCustomFields,
+ getQueueCustomFields,
+ assignQueueCustomField,
+ unassignQueueCustomField,
createCustomField,
+ updateCustomField,
} from "@/lib/api";
-import type { Queue, Lifecycle, Scrip, CustomField } from "@/lib/types";
+import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview } from "@/lib/types";
+import { cn } from "@/lib/utils";
-const LIFECYCLE_PLACEHOLDER = `{
- "statuses": {
- "initial": ["new"],
- "active": ["open", "in_progress"],
- "inactive": ["resolved", "closed"]
- },
- "transitions": {
- "new": ["open"],
- "open": ["in_progress", "resolved"],
- "in_progress": ["resolved"],
- "*": ["closed"]
- }
-}`;
+function AdminHeader() {
+ return (
+
+
+
+
+
+ Configuration
+
+
+ Admin console
+
+
+ Configure queues, lifecycle state machines, automation rules, and custom ticket metadata.
+
+
+
+
+ );
+}
+
+function LoadingState() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
+function EmptyRow({ colSpan, label }: { colSpan: number; label: string }) {
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+function ErrorBanner({ error }: { error: string | null }) {
+ if (!error) return null;
+ return (
+
+ {error}
+
+ );
+}
+
+function tableHeadClass() {
+ return "h-9 px-4 text-[11px] font-semibold uppercase text-muted-foreground";
+}
+
+function tableCellClass(extra?: string) {
+ return cn("px-4 py-3", extra);
+}
export default function AdminPage() {
return (
-
-
Admin
-
-
- Queues
- Lifecycles
- Scrips
- Custom Fields
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ Queues
+
+
+
+ Lifecycles
+
+
+
+ Scrips
+
+
+
+ Templates
+
+
+
+ Custom fields
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
@@ -92,121 +173,168 @@ export default function AdminPage() {
function QueuesTab() {
const [queues, setQueues] = useState([]);
+ const [lifecycles, setLifecycles] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
+ const [lifecycleId, setLifecycleId] = 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 ?? []);
+ const [queueRes, lifecycleRes] = await Promise.all([getQueues(), getLifecycles()]);
+ if (queueRes.error) setError(queueRes.error);
+ else setQueues(queueRes.data ?? []);
+ if (lifecycleRes.error) setError((prev) => prev || lifecycleRes.error);
+ else setLifecycles(lifecycleRes.data ?? []);
setLoading(false);
}, []);
useEffect(() => {
- fetchQueues();
+ void Promise.resolve().then(() => fetchQueues());
}, [fetchQueues]);
- const handleCreate = async () => {
+ const resetBuilder = () => {
+ setEditingId(null);
+ setName("");
+ setDescription("");
+ setLifecycleId("");
+ setSaveError(null);
+ };
+
+ const selectQueue = (queue: Queue) => {
+ setEditingId(queue.id);
+ setName(queue.name);
+ setDescription(queue.description ?? "");
+ setLifecycleId(queue.lifecycle_id ?? "");
+ setSaveError(null);
+ };
+
+ const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
setSaveError(null);
- const { error } = await createQueue({ name: name.trim(), description: description.trim() || undefined });
+ const payload = {
+ name: name.trim(),
+ description: description.trim() || null,
+ lifecycle_id: lifecycleId || null,
+ };
+ const { data, error } = editingId
+ ? await updateQueue(editingId, payload)
+ : await createQueue(payload);
setSaving(false);
if (error) {
setSaveError(error);
- } else {
- setDialogOpen(false);
- setName("");
- setDescription("");
- fetchQueues();
+ return;
}
+ await fetchQueues();
+ if (data) selectQueue(data);
+ };
+
+ const lifecycleName = (id: string | null) => {
+ if (!id) return "No lifecycle";
+ return lifecycles.find((lifecycle) => lifecycle.id === id)?.name ?? id;
};
return (
-
-
-
Queues
-