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 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. 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"; import type { NextConfig } from "next";
const appRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
outputFileTracingRoot: appRoot,
turbopack: {
root: appRoot,
},
async rewrites() { async rewrites() {
return [ return [
{ {
source: '/api/:path*', source: "/api/:path*",
destination: 'http://127.0.0.1:9876/: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", "version": "0.1.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "16.2.7", "next": "16.2.7",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.77.0",
"shadcn": "^4.10.0", "shadcn": "^4.10.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -943,6 +949,18 @@
"hono": "^4" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1981,6 +1999,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2261,6 +2285,39 @@
"tailwindcss": "4.3.0" "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": { "node_modules/@ts-morph/common": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -3947,6 +4004,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "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": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -8002,6 +8079,22 @@
"react": "^19.2.4" "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": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -3,9 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "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", "build": "next build",
"start": "next start", "start": "next start -H 127.0.0.1 --port 3100",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,13 +1,14 @@
import type { Metadata } from "next"; 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 { Suspense } from "react";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import "./globals.css"; import "./globals.css";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
const inter = Inter({ const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-inter", weight: ["400", "500", "600", "700"],
variable: "--font-sans",
}); });
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
@@ -26,7 +27,7 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`} className={`${ibmPlexSans.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{ fontSize: "15px", lineHeight: 1.5 }} style={{ fontSize: "15px", lineHeight: 1.5 }}
> >
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> <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, SettingsIcon,
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftIcon, PanelLeftIcon,
CommandIcon,
} from "lucide-react"; } from "lucide-react";
import { getTickets, getQueues } from "@/lib/api"; import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types"; import type { Queue } from "@/lib/types";
@@ -51,11 +52,11 @@ function SidebarNavItem({
href={href} href={href}
title={collapsed ? label : undefined} title={collapsed ? label : undefined}
className={cn( 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", collapsed ? "justify-center w-full" : "justify-between",
active active
? "bg-accent text-foreground font-medium" ? "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-muted-foreground hover:text-foreground hover:bg-accent font-normal" : "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
)} )}
> >
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}> <span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
@@ -63,7 +64,10 @@ function SidebarNavItem({
{!collapsed && label} {!collapsed && label}
</span> </span>
{!collapsed && count !== undefined && count > 0 && ( {!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} {count}
</span> </span>
)} )}
@@ -171,7 +175,7 @@ function SidebarNav() {
{queues.length > 0 && ( {queues.length > 0 && (
<div> <div>
{!collapsed && ( {!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 Queues
</div> </div>
)} )}
@@ -179,7 +183,7 @@ function SidebarNav() {
const active = const active =
pathname === "/" && searchParams.get("queue") === queue.id; pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => ( 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 ( return (
<SidebarNavItem <SidebarNavItem
@@ -203,7 +207,7 @@ function SidebarBottom() {
const collapsed = useSidebarCollapsed(); const collapsed = useSidebarCollapsed();
return ( return (
<div className="border-t border-border p-2"> <div className="border-t border-sidebar-border p-2">
<SidebarNavItem <SidebarNavItem
href="/admin" href="/admin"
icon={SettingsIcon} icon={SettingsIcon}
@@ -217,13 +221,13 @@ function SidebarBottom() {
)} )}
title={collapsed ? "User" : undefined} title={collapsed ? "User" : undefined}
> >
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center flex-shrink-0"> <div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
<span className="text-primary-foreground text-[10px] font-semibold"> <span className="text-sidebar-primary-foreground text-[10px] font-semibold">
U U
</span> </span>
</div> </div>
{!collapsed && ( {!collapsed && (
<span className="text-[13px] text-muted-foreground truncate"> <span className="text-[13px] text-sidebar-foreground/65 truncate">
User User
</span> </span>
)} )}
@@ -259,39 +263,54 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return ( return (
<SidebarCollapsedContext.Provider value={sidebarCollapsed}> <SidebarCollapsedContext.Provider value={sidebarCollapsed}>
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={cn( 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" sidebarCollapsed ? "w-[60px]" : "w-60"
)} )}
> >
{/* Brand */} {/* 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"> <Link href="/" className="flex items-center gap-2">
<div className="w-5 h-5 rounded-md bg-primary flex items-center justify-center"> <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-primary-foreground text-[11px] font-semibold"> <span className="text-sidebar-primary-foreground text-[12px] font-bold">
T T
</span> </span>
</div> </div>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<span className="font-semibold text-foreground text-sm tracking-tight"> <span className="leading-tight">
Tessera <span className="block font-semibold text-sidebar-foreground text-sm">
Tessera
</span>
<span className="block text-[10px] text-sidebar-foreground/45">
ScripFoundry
</span>
</span> </span>
)} )}
</Link> </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> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 overflow-y-auto py-2 px-2"> <nav className="flex-1 overflow-y-auto py-3 px-2">
<Suspense <Suspense
fallback={ fallback={
<div className="space-y-1.5 px-2"> <div className="space-y-1.5 px-2">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div <div
key={i} key={i}
className="h-7 bg-muted rounded-md animate-pulse" className="h-7 bg-sidebar-accent rounded-md animate-pulse"
/> />
))} ))}
</div> </div>
@@ -306,7 +325,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</aside> </aside>
{/* Main */} {/* Main */}
<main className="flex-1 overflow-hidden">{children}</main> <main className="flex-1 overflow-hidden bg-background/88">{children}</main>
{/* Command Palette */} {/* Command Palette */}
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} /> <CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
@@ -315,7 +334,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Collapse toggle */} {/* Collapse toggle */}
<button <button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)} 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 }} style={{ left: sidebarCollapsed ? 60 : 240 }}
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
> >

View File

@@ -1,6 +1,7 @@
"use client"; "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 { useRouter } from "next/navigation";
import { import {
SearchIcon, SearchIcon,
@@ -15,7 +16,7 @@ import type { Ticket } from "@/lib/types";
interface CommandItem { interface CommandItem {
id: string; id: string;
label: string; label: string;
icon: React.ComponentType<{ className?: string }>; icon: ComponentType<{ className?: string }>;
action: () => void; action: () => void;
category?: string; category?: string;
} }
@@ -41,79 +42,87 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
} }
}, [open]); }, [open]);
const alwaysCommands: CommandItem[] = [ const filtered = useMemo(() => {
{ const normalizedQuery = query.toLowerCase();
id: "new-ticket", const alwaysCommands: CommandItem[] = [
label: "New ticket", {
icon: PlusIcon, id: "new-ticket",
action: () => { label: "New ticket",
onOpenChange(false); icon: PlusIcon,
router.push("/?new=true"); action: () => {
onOpenChange(false);
router.push("/?new=true");
},
category: "Actions",
}, },
category: "Actions", {
}, id: "admin",
{ label: "Go to admin",
id: "admin", icon: SettingsIcon,
label: "Go to admin", action: () => {
icon: SettingsIcon, onOpenChange(false);
action: () => { router.push("/admin");
onOpenChange(false); },
router.push("/admin"); category: "Navigate",
}, },
category: "Navigate", {
}, id: "all-tickets",
{ label: "All tickets",
id: "all-tickets", icon: LayoutGridIcon,
label: "All tickets", action: () => {
icon: LayoutGridIcon, onOpenChange(false);
action: () => { router.push("/");
onOpenChange(false); },
router.push("/"); category: "Navigate",
}, },
category: "Navigate", ];
},
];
const ticketCommands: CommandItem[] = tickets const alwaysFiltered = alwaysCommands.filter((cmd) =>
.filter((t) => t.subject.toLowerCase().includes(query.toLowerCase())) cmd.label.toLowerCase().includes(normalizedQuery)
.map((t) => ({ );
id: `ticket-${t.id}`, const ticketCommands: CommandItem[] = tickets
label: t.subject, .filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
icon: MessageSquareIcon, .map((t) => ({
action: () => { id: `ticket-${t.id}`,
onOpenChange(false); label: t.subject,
router.push(`/tickets/${t.id}`); icon: MessageSquareIcon,
}, action: () => {
category: "Tickets", onOpenChange(false);
})); router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) => return [...alwaysFiltered, ...ticketCommands];
cmd.label.toLowerCase().includes(query.toLowerCase()) }, [onOpenChange, query, router, tickets]);
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]
); );
const filtered = [...alwaysFiltered, ...ticketCommands];
const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const cat = cmd.category || "Other";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {});
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setQuery(""); queueMicrotask(() => {
setSelectedIndex(0); setQuery("");
setSelectedIndex(0);
});
setTimeout(() => inputRef.current?.focus(), 50); setTimeout(() => inputRef.current?.focus(), 50);
} }
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
setSelectedIndex(0); queueMicrotask(() => setSelectedIndex(0));
}, [query]); }, [query]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: KeyboardEvent) => {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));

View File

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

View File

@@ -1,10 +1,15 @@
import type { import type {
Ticket, Ticket,
Queue, Queue,
User,
Transaction, Transaction,
Scrip, Scrip,
Template,
TemplatePreview,
Lifecycle, Lifecycle,
LifecycleDefinition,
CustomField, CustomField,
QueueCustomField,
PreviewResult, PreviewResult,
UpdateResult, UpdateResult,
} from "./types"; } 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(); const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id); if (params?.queue_id) sp.set("queue_id", params.queue_id);
if (params?.status) sp.set("status", params.status); 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(); const qs = sp.toString();
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`); 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}`); 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) }); 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) }); 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"); 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) }); 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 }> { export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
return request<Scrip[]>("/scrips"); return request<Scrip[]>("/scrips");
} }
export async function createScrip(data: { export async function createScrip(data: {
name: string; name: string;
description?: string | null;
queue_id?: string | null; queue_id?: string | null;
condition_type: string; condition_type: string;
condition_config?: Record<string, unknown>;
action_type: string; action_type: string;
action_config?: Record<string, unknown>; action_config?: Record<string, unknown>;
template_id?: string | null; template_id?: string | null;
@@ -88,8 +121,10 @@ export async function createScrip(data: {
export async function updateScrip(id: string, data: { export async function updateScrip(id: string, data: {
name?: string; name?: string;
description?: string | null;
queue_id?: string | null; queue_id?: string | null;
condition_type?: string; condition_type?: string;
condition_config?: Record<string, unknown>;
action_type?: string; action_type?: string;
action_config?: Record<string, unknown>; action_config?: Record<string, unknown>;
template_id?: string | null; 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) }); 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 }> { export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
return request<Lifecycle[]>("/lifecycles"); return request<Lifecycle[]>("/lifecycles");
} }
export async function createLifecycle(data: { export async function createLifecycle(data: {
name: string; name: string;
definition: Record<string, unknown>; definition: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> { }): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) }); 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 }> { export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
return request<CustomField[]>("/custom-fields"); 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: { export async function createCustomField(data: {
key?: string;
name: string; name: string;
field_type: string; field_type: string;
values?: unknown | null; values?: unknown | null;
max_values?: number; max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> { }): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) }); 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; lifecycle_id: string | null;
} }
export interface User {
id: string;
username: string;
email: string | null;
created_at: string;
}
export interface Transaction { export interface Transaction {
id: string; id: string;
ticket_id: number; ticket_id: number;
@@ -35,13 +42,16 @@ export interface Scrip {
id: string; id: string;
queue_id: string | null; queue_id: string | null;
name: string; name: string;
description: string | null;
condition_type: string; condition_type: string;
condition_config: Record<string, unknown>;
action_type: string; action_type: string;
action_config: Record<string, unknown>; action_config: Record<string, unknown>;
template_id: string | null; template_id: string | null;
stage: string; stage: string;
sort_order: number; sort_order: number;
disabled: boolean; disabled: boolean;
created_at: string;
} }
export interface Template { export interface Template {
@@ -50,6 +60,13 @@ export interface Template {
queue_id: string | null; queue_id: string | null;
subject_template: string; subject_template: string;
body_template: string; body_template: string;
created_at: string;
}
export interface TemplatePreview {
subject: string;
body: string;
context: unknown;
} }
export interface Lifecycle { export interface Lifecycle {
@@ -65,16 +82,26 @@ export interface LifecycleDefinition {
export interface CustomField { export interface CustomField {
id: string; id: string;
key: string;
name: string; name: string;
field_type: string; field_type: string;
values: unknown | null; values: unknown | null;
max_values: number; 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 { export interface CustomFieldValue {
id: string; id: string;
custom_field_id: string; custom_field_id: string;
ticket_id: string; ticket_id: number;
value: string; value: string;
custom_field?: CustomField; custom_field?: CustomField;
} }