feat: enhance frontend UI — command palette, admin redesign, API coverage

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 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 10:43:28 +02:00
parent b96ba21e99
commit 06cc7c79a3
14 changed files with 3987 additions and 1331 deletions

View File

@@ -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.

View File

@@ -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*",
},
];
},

95
web/package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

File diff suppressed because it is too large Load Diff

View File

@@ -49,72 +49,72 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(0.982 0.006 106);
--foreground: oklch(0.19 0.018 248);
--card: oklch(0.996 0.003 106);
--card-foreground: oklch(0.19 0.018 248);
--popover: oklch(0.996 0.003 106);
--popover-foreground: oklch(0.19 0.018 248);
--primary: oklch(0.31 0.046 243);
--primary-foreground: oklch(0.99 0.003 106);
--secondary: oklch(0.945 0.01 105);
--secondary-foreground: oklch(0.25 0.026 244);
--muted: oklch(0.948 0.008 106);
--muted-foreground: oklch(0.49 0.023 250);
--accent: oklch(0.925 0.024 184);
--accent-foreground: oklch(0.21 0.028 246);
--destructive: oklch(0.55 0.18 27);
--border: oklch(0.865 0.014 102);
--input: oklch(0.84 0.015 102);
--ring: oklch(0.58 0.068 185);
--chart-1: oklch(0.62 0.095 184);
--chart-2: oklch(0.53 0.078 243);
--chart-3: oklch(0.64 0.12 77);
--chart-4: oklch(0.55 0.15 28);
--chart-5: oklch(0.44 0.055 257);
--radius: 0.5rem;
--sidebar: oklch(0.245 0.026 248);
--sidebar-foreground: oklch(0.93 0.012 108);
--sidebar-primary: oklch(0.69 0.105 184);
--sidebar-primary-foreground: oklch(0.18 0.022 248);
--sidebar-accent: oklch(0.31 0.031 248);
--sidebar-accent-foreground: oklch(0.98 0.006 106);
--sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.66 0.102 184);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.18 0.018 248);
--foreground: oklch(0.94 0.011 105);
--card: oklch(0.225 0.022 248);
--card-foreground: oklch(0.94 0.011 105);
--popover: oklch(0.225 0.022 248);
--popover-foreground: oklch(0.94 0.011 105);
--primary: oklch(0.74 0.105 184);
--primary-foreground: oklch(0.17 0.018 248);
--secondary: oklch(0.27 0.026 248);
--secondary-foreground: oklch(0.94 0.011 105);
--muted: oklch(0.28 0.023 248);
--muted-foreground: oklch(0.7 0.019 105);
--accent: oklch(0.31 0.043 184);
--accent-foreground: oklch(0.94 0.011 105);
--destructive: oklch(0.68 0.17 24);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.68 0.095 184);
--chart-1: oklch(0.74 0.105 184);
--chart-2: oklch(0.7 0.105 74);
--chart-3: oklch(0.66 0.12 25);
--chart-4: oklch(0.61 0.08 245);
--chart-5: oklch(0.8 0.04 108);
--sidebar: oklch(0.145 0.018 248);
--sidebar-foreground: oklch(0.94 0.011 105);
--sidebar-primary: oklch(0.74 0.105 184);
--sidebar-primary-foreground: oklch(0.17 0.018 248);
--sidebar-accent: oklch(0.24 0.026 248);
--sidebar-accent-foreground: oklch(0.94 0.011 105);
--sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.68 0.095 184);
}
@keyframes slide-in-right {
@@ -135,6 +135,10 @@
body {
@apply bg-background text-foreground;
font-feature-settings: "cv01" 1, "ss03" 1;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px);
background-size: 44px 44px;
}
html {
@apply font-sans;

View File

@@ -1,13 +1,14 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
import { Suspense } from "react";
import { ThemeProvider } from "next-themes";
import "./globals.css";
import { AppShell } from "@/components/app-shell";
const inter = Inter({
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
variable: "--font-inter",
weight: ["400", "500", "600", "700"],
variable: "--font-sans",
});
const jetbrainsMono = JetBrains_Mono({
@@ -26,7 +27,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
className={`${ibmPlexSans.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{ fontSize: "15px", lineHeight: 1.5 }}
>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import {
SettingsIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
CommandIcon,
} from "lucide-react";
import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types";
@@ -51,11 +52,11 @@ function SidebarNavItem({
href={href}
title={collapsed ? label : undefined}
className={cn(
"flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
"group flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
collapsed ? "justify-center w-full" : "justify-between",
active
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent font-normal"
? "bg-sidebar-primary text-sidebar-primary-foreground font-semibold shadow-[inset_3px_0_0_color-mix(in_oklch,var(--sidebar-primary-foreground)_55%,transparent)]"
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
)}
>
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
@@ -63,7 +64,10 @@ function SidebarNavItem({
{!collapsed && label}
</span>
{!collapsed && count !== undefined && count > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">
<span className={cn(
"min-w-5 rounded px-1 text-right text-[11px] tabular-nums",
active ? "text-sidebar-primary-foreground/80" : "text-sidebar-foreground/45"
)}>
{count}
</span>
)}
@@ -171,7 +175,7 @@ function SidebarNav() {
{queues.length > 0 && (
<div>
{!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
Queues
</div>
)}
@@ -179,7 +183,7 @@ function SidebarNav() {
const active =
pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-muted-foreground flex-shrink-0" />
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
);
return (
<SidebarNavItem
@@ -203,7 +207,7 @@ function SidebarBottom() {
const collapsed = useSidebarCollapsed();
return (
<div className="border-t border-border p-2">
<div className="border-t border-sidebar-border p-2">
<SidebarNavItem
href="/admin"
icon={SettingsIcon}
@@ -217,13 +221,13 @@ function SidebarBottom() {
)}
title={collapsed ? "User" : undefined}
>
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
<span className="text-primary-foreground text-[10px] font-semibold">
<div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
<span className="text-sidebar-primary-foreground text-[10px] font-semibold">
U
</span>
</div>
{!collapsed && (
<span className="text-[13px] text-muted-foreground truncate">
<span className="text-[13px] text-sidebar-foreground/65 truncate">
User
</span>
)}
@@ -259,39 +263,54 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return (
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
<div className="flex h-screen overflow-hidden">
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */}
<aside
className={cn(
"flex-shrink-0 flex flex-col bg-sidebar border-r border-border transition-all duration-150",
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-150 shadow-[16px_0_42px_color-mix(in_oklch,var(--sidebar)_18%,transparent)]",
sidebarCollapsed ? "w-[60px]" : "w-60"
)}
>
{/* Brand */}
<div className="h-11 flex items-center px-3 border-b border-border">
<div className="h-14 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border">
<Link href="/" className="flex items-center gap-2">
<div className="w-5 h-5 rounded-md bg-primary flex items-center justify-center">
<span className="text-primary-foreground text-[11px] font-semibold">
<div className="w-7 h-7 rounded-md bg-sidebar-primary flex items-center justify-center shadow-[0_0_0_1px_color-mix(in_oklch,var(--sidebar-primary)_55%,white_20%)]">
<span className="text-sidebar-primary-foreground text-[12px] font-bold">
T
</span>
</div>
{!sidebarCollapsed && (
<span className="font-semibold text-foreground text-sm tracking-tight">
<span className="leading-tight">
<span className="block font-semibold text-sidebar-foreground text-sm">
Tessera
</span>
<span className="block text-[10px] text-sidebar-foreground/45">
ScripFoundry
</span>
</span>
)}
</Link>
{!sidebarCollapsed && (
<button
onClick={() => setCommandOpen(true)}
className="flex h-7 items-center gap-1 rounded-md border border-sidebar-border px-2 text-[11px] text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
aria-label="Open command palette"
>
<CommandIcon className="h-3.5 w-3.5" />
K
</button>
)}
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-2 px-2">
<nav className="flex-1 overflow-y-auto py-3 px-2">
<Suspense
fallback={
<div className="space-y-1.5 px-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-7 bg-muted rounded-md animate-pulse"
className="h-7 bg-sidebar-accent rounded-md animate-pulse"
/>
))}
</div>
@@ -306,7 +325,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</aside>
{/* Main */}
<main className="flex-1 overflow-hidden">{children}</main>
<main className="flex-1 overflow-hidden bg-background/88">{children}</main>
{/* Command Palette */}
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
@@ -315,7 +334,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Collapse toggle */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-border border-l-0 text-muted-foreground hover:text-foreground transition-all duration-150"
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-sidebar-border border-l-0 text-sidebar-foreground/55 hover:text-sidebar-foreground transition-all duration-150"
style={{ left: sidebarCollapsed ? 60 : 240 }}
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import type { ComponentType, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import {
SearchIcon,
@@ -15,7 +16,7 @@ import type { Ticket } from "@/lib/types";
interface CommandItem {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
icon: ComponentType<{ className?: string }>;
action: () => void;
category?: string;
}
@@ -41,6 +42,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
}
}, [open]);
const filtered = useMemo(() => {
const normalizedQuery = query.toLowerCase();
const alwaysCommands: CommandItem[] = [
{
id: "new-ticket",
@@ -74,8 +77,11 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
},
];
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(normalizedQuery)
);
const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(query.toLowerCase()))
.filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
@@ -87,33 +93,36 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase())
);
return [...alwaysFiltered, ...ticketCommands];
}, [onOpenChange, query, router, tickets]);
const filtered = [...alwaysFiltered, ...ticketCommands];
const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const grouped = useMemo(
() =>
filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const cat = cmd.category || "Other";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {});
}, {}),
[filtered]
);
useEffect(() => {
if (open) {
queueMicrotask(() => {
setQuery("");
setSelectedIndex(0);
});
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
setSelectedIndex(0);
queueMicrotask(() => setSelectedIndex(0));
}, [query]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
(e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));

View File

@@ -8,7 +8,9 @@ export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
queueMicrotask(() => setMounted(true));
}, []);
if (!mounted) {
return <div className="w-8 h-8" />;

View File

@@ -1,10 +1,15 @@
import type {
Ticket,
Queue,
User,
Transaction,
Scrip,
Template,
TemplatePreview,
Lifecycle,
LifecycleDefinition,
CustomField,
QueueCustomField,
PreviewResult,
UpdateResult,
} from "./types";
@@ -28,10 +33,23 @@ async function request<T>(url: string, options?: RequestInit): Promise<{ data: T
}
}
export async function getTickets(params?: { queue_id?: string; status?: string }): Promise<{ data: Ticket[] | null; error: string | null }> {
export async function getTickets(params?: {
queue_id?: string;
status?: string;
q?: string;
owner_id?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id);
if (params?.status) sp.set("status", params.status);
if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
}
}
const qs = sp.toString();
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`);
}
@@ -40,11 +58,16 @@ export async function getTicket(id: number): Promise<{ data: Ticket | null; erro
return request<Ticket>(`/tickets/${id}`);
}
export async function createTicket(data: { subject: string; queue_id: string }): Promise<{ data: Ticket | null; error: string | null }> {
export async function createTicket(data: {
subject: string;
queue_id: string;
description?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket | null; error: string | null }> {
return request<Ticket>("/tickets", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTicket(id: number, data: { subject?: string; status?: string }): Promise<{ data: UpdateResult | null; error: string | null }> {
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
@@ -64,18 +87,28 @@ export async function getQueues(): Promise<{ data: Queue[] | null; error: string
return request<Queue[]>("/queues");
}
export async function createQueue(data: { name: string; description?: string }): Promise<{ data: Queue | null; error: string | null }> {
export async function getUsers(): Promise<{ data: User[] | null; error: string | null }> {
return request<User[]>("/users");
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
}
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
return request<Scrip[]>("/scrips");
}
export async function createScrip(data: {
name: string;
description?: string | null;
queue_id?: string | null;
condition_type: string;
condition_config?: Record<string, unknown>;
action_type: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
@@ -88,8 +121,10 @@ export async function createScrip(data: {
export async function updateScrip(id: string, data: {
name?: string;
description?: string | null;
queue_id?: string | null;
condition_type?: string;
condition_config?: Record<string, unknown>;
action_type?: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
@@ -100,26 +135,98 @@ export async function updateScrip(id: string, data: {
return request<Scrip>(`/scrips/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getTemplates(): Promise<{ data: Template[] | null; error: string | null }> {
return request<Template[]>("/templates");
}
export async function createTemplate(data: {
name: string;
queue_id?: string | null;
subject_template: string;
body_template: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>("/templates", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTemplate(id: string, data: {
name?: string;
queue_id?: string | null;
subject_template?: string;
body_template?: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>(`/templates/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function previewTemplate(data: {
subject_template: string;
body_template: string;
ticket_id?: number | null;
}): Promise<{ data: TemplatePreview | null; error: string | null }> {
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
}
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
return request<Lifecycle[]>("/lifecycles");
}
export async function createLifecycle(data: {
name: string;
definition: Record<string, unknown>;
definition: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) });
}
export async function updateLifecycle(id: string, data: {
name?: string;
definition?: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>(`/lifecycles/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
return request<CustomField[]>("/custom-fields");
}
export async function getQueueCustomFields(queueId: string): Promise<{ data: QueueCustomField[] | null; error: string | null }> {
return request<QueueCustomField[]>(`/custom-fields/queues/${queueId}`);
}
export async function assignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: QueueCustomField | null; error: string | null }> {
return request<QueueCustomField>(`/custom-fields/queues/${queueId}`, {
method: "POST",
body: JSON.stringify({ custom_field_id: customFieldId }),
});
}
export async function unassignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/custom-fields/queues/${queueId}/${customFieldId}`, { method: "DELETE" });
}
export async function updateTicketCustomField(ticketId: number, customFieldId: string, value: string): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${ticketId}/custom-fields/${customFieldId}`, {
method: "PATCH",
body: JSON.stringify({ value }),
});
}
export async function createCustomField(data: {
key?: string;
name: string;
field_type: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
}
export async function updateCustomField(id: string, data: {
key?: string;
name?: string;
field_type?: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}

View File

@@ -19,6 +19,13 @@ export interface Queue {
lifecycle_id: string | null;
}
export interface User {
id: string;
username: string;
email: string | null;
created_at: string;
}
export interface Transaction {
id: string;
ticket_id: number;
@@ -35,13 +42,16 @@ export interface Scrip {
id: string;
queue_id: string | null;
name: string;
description: string | null;
condition_type: string;
condition_config: Record<string, unknown>;
action_type: string;
action_config: Record<string, unknown>;
template_id: string | null;
stage: string;
sort_order: number;
disabled: boolean;
created_at: string;
}
export interface Template {
@@ -50,6 +60,13 @@ export interface Template {
queue_id: string | null;
subject_template: string;
body_template: string;
created_at: string;
}
export interface TemplatePreview {
subject: string;
body: string;
context: unknown;
}
export interface Lifecycle {
@@ -65,16 +82,26 @@ export interface LifecycleDefinition {
export interface CustomField {
id: string;
key: string;
name: string;
field_type: string;
values: unknown | null;
max_values: number;
pattern: string | null;
}
export interface QueueCustomField {
id: string;
queue_id: string;
custom_field_id: string;
sort_order: number;
custom_field: CustomField | null;
}
export interface CustomFieldValue {
id: string;
custom_field_id: string;
ticket_id: string;
ticket_id: number;
value: string;
custom_field?: CustomField;
}