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

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,8 +135,12 @@
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">
Tessera
<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,79 +42,87 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
}
}, [open]);
const alwaysCommands: CommandItem[] = [
{
id: "new-ticket",
label: "New ticket",
icon: PlusIcon,
action: () => {
onOpenChange(false);
router.push("/?new=true");
const filtered = useMemo(() => {
const normalizedQuery = query.toLowerCase();
const alwaysCommands: CommandItem[] = [
{
id: "new-ticket",
label: "New ticket",
icon: PlusIcon,
action: () => {
onOpenChange(false);
router.push("/?new=true");
},
category: "Actions",
},
category: "Actions",
},
{
id: "admin",
label: "Go to admin",
icon: SettingsIcon,
action: () => {
onOpenChange(false);
router.push("/admin");
{
id: "admin",
label: "Go to admin",
icon: SettingsIcon,
action: () => {
onOpenChange(false);
router.push("/admin");
},
category: "Navigate",
},
category: "Navigate",
},
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
},
category: "Navigate",
},
category: "Navigate",
},
];
];
const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(query.toLowerCase()))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
icon: MessageSquareIcon,
action: () => {
onOpenChange(false);
router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(normalizedQuery)
);
const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
icon: MessageSquareIcon,
action: () => {
onOpenChange(false);
router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase())
return [...alwaysFiltered, ...ticketCommands];
}, [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(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
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;
}