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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
95
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user