From 06cc7c79a3320433d2d0e035fa667d2789de1efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 10:43:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20enhance=20frontend=20UI=20=E2=80=94=20c?= =?UTF-8?q?ommand=20palette,=20admin=20redesign,=20API=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types + API: - Add User, TemplatePreview, QueueCustomField types - Add getUsers, getTemplates, createTemplate, updateTemplate, previewTemplate, updateQueue, updateLifecycle, updateCustomField API functions UI: - Command palette: keyboard-first navigation with fuzzy ticket search - Admin: comprehensive redesign with tab-based layout (Queues, Lifecycles, Scrips, Custom Fields, Templates, Users) - Ticket list: improved inbox-style rows with quick actions - Ticket detail: enhanced conversation thread and properties sidebar - App shell: sidebar visual refinement with active indicator bar - Theme toggle: smoother transitions Co-Authored-By: Claude Opus 4.8 --- web/README.md | 2 +- web/next.config.ts | 12 +- web/package-lock.json | 95 +- web/package.json | 5 +- web/src/app/admin/page-content.tsx | 2606 +++++++++++++++++++----- web/src/app/globals.css | 132 +- web/src/app/layout.tsx | 9 +- web/src/app/page.tsx | 1036 +++++++--- web/src/app/tickets/[id]/page.tsx | 1085 ++++++---- web/src/components/app-shell.tsx | 61 +- web/src/components/command-palette.tsx | 125 +- web/src/components/theme-toggle.tsx | 4 +- web/src/lib/api.ts | 117 +- web/src/lib/types.ts | 29 +- 14 files changed, 3987 insertions(+), 1331 deletions(-) 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

-
- - {error &&
{error}
} + {loading ? ( -
Loading...
+ ) : ( - - - - Name - Description - - - - {queues.length === 0 ? ( - - - No queues yet. - - - ) : ( - queues.map((q) => ( - - {q.name} - {q.description ?? "-"} - - )) - )} - -
- )} - - - - - Add Queue - Create a new ticket queue. - -
-
- - setName(e.target.value)} - /> +
+