feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,7 @@ import {
|
||||
removeTeamMember,
|
||||
} from "@/lib/api";
|
||||
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
|
||||
import { ScripWizard } from "@/components/scrip-wizard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function AdminHeader() {
|
||||
@@ -802,6 +803,7 @@ return { message: "Metadata fetched" };`);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
|
||||
const fetchScrips = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -1189,10 +1191,15 @@ return { message: "Metadata fetched" };`);
|
||||
Build automations visually, then fine-tune the exact action payload in JSON.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={resetBuilder} className="h-8 bg-primary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New scrip
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={resetBuilder} className="h-8 bg-primary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
New scrip
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setWizardOpen(true)} className="h-8">
|
||||
Guided setup
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorBanner error={error} />
|
||||
{loading ? (
|
||||
@@ -1701,6 +1708,24 @@ return { message: "Metadata fetched" };`);
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ScripWizard
|
||||
open={wizardOpen}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
error={saveError}
|
||||
onCreate={async (data) => {
|
||||
// Strip nulls for Zod optional fields
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(data).filter(([, v]) => v !== null)
|
||||
);
|
||||
const { error: createErr } = await createScrip(payload as any);
|
||||
if (createErr) { setSaveError(createErr); return; }
|
||||
setWizardOpen(false);
|
||||
await fetchScrips();
|
||||
}}
|
||||
queues={queues}
|
||||
customFields={customFields}
|
||||
templates={templates}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import { CountWidget } from "@/components/widgets/count-widget";
|
||||
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
|
||||
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
|
||||
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
|
||||
import { TrendChartWidget } from "@/components/widgets/trend-chart-widget";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
|
||||
@@ -331,13 +332,18 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
||||
|
||||
switch (widget.data.type) {
|
||||
case "count":
|
||||
case "my_tickets":
|
||||
return <CountWidget data={widget.data} />;
|
||||
case "overdue":
|
||||
return <CountWidget data={{ ...widget.data, type: "count" }} />;
|
||||
case "ticket_list":
|
||||
return <TicketListWidget data={widget.data} />;
|
||||
case "status_chart":
|
||||
return <StatusChartWidget data={widget.data} />;
|
||||
case "grouped_counts":
|
||||
return <GroupedCountsWidget data={widget.data} />;
|
||||
case "trend_chart":
|
||||
return <TrendChartWidget data={widget.data} />;
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||
@@ -554,6 +560,9 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
||||
<option value="ticket_list">Ticket list (mini table)</option>
|
||||
<option value="status_chart">Status chart (donut)</option>
|
||||
<option value="grouped_counts">Grouped counts (bar chart)</option>
|
||||
<option value="my_tickets">My tickets (auto-scoped)</option>
|
||||
<option value="overdue">Overdue / stale</option>
|
||||
<option value="trend_chart">Trend chart (bar)</option>
|
||||
</select>
|
||||
</div>
|
||||
{addType === "grouped_counts" && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
|
||||
const ibmPlexSans = IBM_Plex_Sans({
|
||||
subsets: ["latin"],
|
||||
@@ -31,9 +32,11 @@ export default function RootLayout({
|
||||
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
|
||||
<AppShell>{children}</AppShell>
|
||||
</Suspense>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
|
||||
<AppShell>{children}</AppShell>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
97
web/src/app/login/page.tsx
Normal file
97
web/src/app/login/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { LogInIcon } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, user } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Already logged in
|
||||
if (user) {
|
||||
router.replace("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await login(username.trim(), password);
|
||||
setLoading(false);
|
||||
|
||||
if (result) {
|
||||
setError(result);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background/80">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Tessera</h1>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="username" className="text-[10px] font-medium text-muted-foreground">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="password" className="text-[10px] font-medium text-muted-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
className="flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
) : (
|
||||
<LogInIcon className="h-4 w-4" />
|
||||
)}
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/60">
|
||||
Demo: admin / admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArrowDownAZIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
GaugeIcon,
|
||||
LayoutGridIcon,
|
||||
LayoutListIcon,
|
||||
@@ -18,8 +19,8 @@ import {
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket } from "@/lib/api";
|
||||
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types";
|
||||
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket, batchUpdateTickets, getTeams } from "@/lib/api";
|
||||
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Team, Ticket, User } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn, formatTicketId } from "@/lib/utils";
|
||||
import { SearchableSelect } from "@/components/searchable-select";
|
||||
import { LayoutBuilder, type SubtitleEntry } from "@/components/layout-builder";
|
||||
|
||||
const STATUS_META: Record<string, { label: string; color: string; tone: string }> = {
|
||||
new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" },
|
||||
@@ -51,35 +54,22 @@ type SortKey = "updated" | "created" | "id";
|
||||
interface ColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
width: number; // px
|
||||
visible: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const ALL_COLUMNS: ColumnConfig[] = [
|
||||
{ key: "id", label: "ID", width: 100, visible: true },
|
||||
{ key: "subject", label: "Subject", width: 320, visible: true },
|
||||
{ key: "status", label: "Status", width: 120, visible: true },
|
||||
{ key: "queue", label: "Queue", width: 140, visible: true },
|
||||
{ key: "owner", label: "Owner", width: 130, visible: true },
|
||||
{ key: "created", label: "Created", width: 130, visible: false },
|
||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
||||
const ALL_FIELDS: ColumnConfig[] = [
|
||||
{ key: "id", label: "ID", width: 100 },
|
||||
{ key: "subject", label: "Subject", width: 400 },
|
||||
{ key: "status", label: "Status", width: 120 },
|
||||
{ key: "queue", label: "Queue", width: 140 },
|
||||
{ key: "owner", label: "Owner", width: 130 },
|
||||
{ key: "created", label: "Created", width: 130 },
|
||||
{ key: "updated", label: "Updated", width: 130 },
|
||||
{ key: "team", label: "Team", width: 130 },
|
||||
];
|
||||
|
||||
function baseColumns(): ColumnConfig[] {
|
||||
return [
|
||||
{ key: "id", label: "ID", width: 100, visible: true },
|
||||
{ key: "subject", label: "Subject", width: 400, visible: true },
|
||||
{ key: "status", label: "Status", width: 120, visible: true },
|
||||
{ key: "queue", label: "Queue", width: 140, visible: true },
|
||||
{ key: "owner", label: "Owner", width: 130, visible: true },
|
||||
{ key: "created", label: "Created", width: 130, visible: false },
|
||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
||||
];
|
||||
}
|
||||
|
||||
function defaultColumns(): ColumnConfig[] {
|
||||
return baseColumns().map((c) => ({ ...c }));
|
||||
}
|
||||
const DEFAULT_ROW1 = ["id", "subject", "status"];
|
||||
const DEFAULT_ROW2 = ["queue", "owner"];
|
||||
|
||||
const LS_KEY = "tessera_columns";
|
||||
|
||||
@@ -105,6 +95,22 @@ function queueName(queues: Queue[], queueId: string) {
|
||||
return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8);
|
||||
}
|
||||
|
||||
function getSubtitleValue(key: string, ticket: Ticket, context: { users: User[]; queues: Queue[]; teamsList: Team[] }): string | null {
|
||||
if (key === "subject") return null;
|
||||
if (key === "id") return formatTicketId(ticket.id);
|
||||
if (key === "status") return statusLabel(ticket.status);
|
||||
if (key === "queue") return context.queues.find((q) => q.id === ticket.queue_id)?.name ?? ticket.queue_id.slice(0, 8);
|
||||
if (key === "owner") return ticket.owner_id ? (context.users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned") : "Unassigned";
|
||||
if (key === "team") return context.teamsList.find((t) => t.id === ticket.team_id)?.name ?? null;
|
||||
if (key === "created") return relativeTime(ticket.created_at);
|
||||
if (key === "updated") return relativeTime(ticket.updated_at);
|
||||
if (key.startsWith("cf.")) {
|
||||
const cfKey = key.slice(3);
|
||||
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function relativeTime(value: string) {
|
||||
return formatDistanceToNow(new Date(value), { addSuffix: true });
|
||||
}
|
||||
@@ -178,8 +184,10 @@ function TicketWorkbenchContent() {
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [teamsList, setTeamsList] = useState<Team[]>([]);
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||
const [clock, setClock] = useState(0);
|
||||
const [initialLoad, setInitialLoad] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -187,47 +195,82 @@ function TicketWorkbenchContent() {
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
const searchRef = useRef(searchQuery);
|
||||
searchRef.current = searchQuery;
|
||||
// Debounce search: update debouncedQuery 300ms after user stops typing
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnConfig[]>(() => {
|
||||
if (typeof window === "undefined") return defaultColumns();
|
||||
const [row1Keys, setRow1Keys] = useState<string[]>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_ROW1;
|
||||
try {
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
if (stored) return JSON.parse(stored) as ColumnConfig[];
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.row1) return parsed.row1 as string[];
|
||||
// Migrate old format
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((c: any) => c.visible !== false && c.display !== "subtitle" && c.display !== "hidden").map((c: any) => c.key);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return defaultColumns();
|
||||
return DEFAULT_ROW1;
|
||||
});
|
||||
const [row2Entries, setRow2Entries] = useState<SubtitleEntry[]>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
|
||||
try {
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.row2Entries) return parsed.row2Entries as SubtitleEntry[];
|
||||
if (parsed.row2 && Array.isArray(parsed.row2)) {
|
||||
// Migrate: old flat keys → entries with self as under
|
||||
if (typeof parsed.row2[0] === "string") return parsed.row2.map((k: string) => ({ key: k, under: k }));
|
||||
return parsed.row2 as SubtitleEntry[];
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
|
||||
});
|
||||
const [density, setDensity] = useState<Density>("comfortable");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
||||
const [resizingCol, setResizingCol] = useState<string | null>(null);
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||
const [colPickerOpen, setColPickerOpen] = useState(false);
|
||||
|
||||
// Persist columns to localStorage
|
||||
// Persist layout to localStorage
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(columns)); } catch { /* ignore */ }
|
||||
}, [columns]);
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify({ row1: row1Keys, row2Entries })); } catch { /* ignore */ }
|
||||
}, [row1Keys, row2Entries]);
|
||||
|
||||
// Build available columns: base + custom fields
|
||||
const availableColumns = useMemo(() => {
|
||||
const base = baseColumns();
|
||||
const cfCols: ColumnConfig[] = customFields
|
||||
// Build available fields: base + custom fields
|
||||
const allFields = useMemo(() => {
|
||||
const cfFields: ColumnConfig[] = customFields
|
||||
.filter((cf) => cf.key)
|
||||
.map((cf) => ({
|
||||
key: `cf.${cf.key}`,
|
||||
label: cf.name,
|
||||
width: 140,
|
||||
visible: columns.find((c) => c.key === `cf.${cf.key}`)?.visible ?? false,
|
||||
}));
|
||||
// Merge with current visibility state
|
||||
const merged = base.map((bc) => {
|
||||
const current = columns.find((c) => c.key === bc.key);
|
||||
return current ?? bc;
|
||||
});
|
||||
for (const cf of cfCols) {
|
||||
const current = columns.find((c) => c.key === cf.key);
|
||||
merged.push(current ?? cf);
|
||||
}
|
||||
return merged;
|
||||
}, [customFields, columns]);
|
||||
.map((cf) => ({ key: `cf.${cf.key}`, label: cf.name, width: 140 }));
|
||||
return [...ALL_FIELDS, ...cfFields];
|
||||
}, [customFields]);
|
||||
|
||||
const fieldByKey = useMemo(() => {
|
||||
const map = new Map<string, ColumnConfig>();
|
||||
for (const f of allFields) map.set(f.key, f);
|
||||
return map;
|
||||
}, [allFields]);
|
||||
|
||||
const row1Fields = row1Keys.map((k) => fieldByKey.get(k)).filter(Boolean) as ColumnConfig[];
|
||||
const row2EntriesResolved = row2Entries.filter((e) => fieldByKey.has(e.key));
|
||||
// Group subtitle entries by which column they sit under
|
||||
const subsByColumn = new Map<string, SubtitleEntry[]>();
|
||||
for (const e of row2EntriesResolved) {
|
||||
const list = subsByColumn.get(e.under) ?? [];
|
||||
list.push(e);
|
||||
subsByColumn.set(e.under, list);
|
||||
}
|
||||
|
||||
const colWidth = (key: string, fallback: number) => colWidths[key] ?? fallback;
|
||||
|
||||
// Saved views
|
||||
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
||||
@@ -268,6 +311,9 @@ function TicketWorkbenchContent() {
|
||||
const apiStatus = filters.find((f) => f.field === "status")?.value;
|
||||
const apiOwner = filters.find((f) => f.field === "owner")?.value;
|
||||
const apiQueue = filters.find((f) => f.field === "queue")?.value;
|
||||
const apiSubject = filters.find((f) => f.field === "subject");
|
||||
const apiCreated = filters.find((f) => f.field === "created");
|
||||
const apiUpdated = filters.find((f) => f.field === "updated");
|
||||
const customFieldFilters: Record<string, string> = {};
|
||||
for (const f of filters) {
|
||||
if (f.field.startsWith("cf.")) {
|
||||
@@ -276,19 +322,23 @@ function TicketWorkbenchContent() {
|
||||
}
|
||||
|
||||
const routeTeamId = searchParams.get("team_id") ?? "";
|
||||
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
|
||||
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes, teamsRes] = await Promise.all([
|
||||
getTickets({
|
||||
q: searchQuery.trim() || undefined,
|
||||
q: debouncedQuery.trim() || undefined,
|
||||
status: apiStatus || undefined,
|
||||
queue_id: activeQueue || apiQueue || undefined,
|
||||
owner_id: apiOwner || undefined,
|
||||
team_id: routeTeamId || undefined,
|
||||
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
||||
subject: apiSubject ? `${apiSubject.operator}:${apiSubject.value}` : undefined,
|
||||
created: apiCreated ? `${apiCreated.operator}:${apiCreated.value}` : undefined,
|
||||
updated: apiUpdated ? `${apiUpdated.operator}:${apiUpdated.value}` : undefined,
|
||||
}),
|
||||
getQueues(),
|
||||
getUsers(),
|
||||
getCustomFields(),
|
||||
getLifecycles(),
|
||||
getTeams(),
|
||||
]);
|
||||
|
||||
if (ticketsRes.error) {
|
||||
@@ -314,6 +364,12 @@ function TicketWorkbenchContent() {
|
||||
setUsers(usersRes.data ?? []);
|
||||
}
|
||||
|
||||
if (teamsRes?.error) {
|
||||
setError((current) => current ?? teamsRes.error);
|
||||
} else if (teamsRes?.data) {
|
||||
setTeamsList(teamsRes.data);
|
||||
}
|
||||
|
||||
if (fieldsRes.error) {
|
||||
setError((current) => current ?? fieldsRes.error);
|
||||
} else {
|
||||
@@ -327,10 +383,11 @@ function TicketWorkbenchContent() {
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setInitialLoad(false);
|
||||
setRefreshing(false);
|
||||
setClock(fetchedAt);
|
||||
},
|
||||
[filters, newQueueId, routeQueue, searchQuery]
|
||||
[filters, newQueueId, routeQueue, debouncedQuery]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -422,7 +479,12 @@ function TicketWorkbenchContent() {
|
||||
);
|
||||
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
||||
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
|
||||
setColumns(view.columns as ColumnConfig[]);
|
||||
// Load row1/row2 from saved view columns if available, else fall back to default
|
||||
const cols = view.columns as any[];
|
||||
const r1 = cols.filter((c: any) => c.display !== "subtitle" && c.visible !== false).map((c: any) => c.key);
|
||||
const r2 = cols.filter((c: any) => c.display === "subtitle").map((c: any) => c.key);
|
||||
if (r1.length > 0) setRow1Keys(r1);
|
||||
if (r2.length > 0) setRow2Entries(r2.map((k: string) => ({ key: k, under: k })));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -430,7 +492,8 @@ function TicketWorkbenchContent() {
|
||||
// User navigated away from a view — clear filters and reset columns
|
||||
setFilters([]);
|
||||
setSearchQuery("");
|
||||
setColumns(defaultColumns());
|
||||
setRow1Keys(DEFAULT_ROW1);
|
||||
setRow2Entries(DEFAULT_ROW2.map((k) => ({ key: k, under: k })));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -485,7 +548,6 @@ function TicketWorkbenchContent() {
|
||||
}, [clock, inactiveStatuses, tickets]);
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const now = clock || 0;
|
||||
const queue = routeQueue;
|
||||
const statusFilterValue = filters.find((f) => f.field === "status")?.value;
|
||||
@@ -506,13 +568,7 @@ function TicketWorkbenchContent() {
|
||||
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
|
||||
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
|
||||
if (queue && ticket.queue_id !== queue) return false;
|
||||
if (!query) return true;
|
||||
return (
|
||||
ticket.subject.toLowerCase().includes(query) ||
|
||||
formatTicketId(ticket.id).toLowerCase().includes(query) ||
|
||||
statusLabel(ticket.status).toLowerCase().includes(query) ||
|
||||
queueName(queues, ticket.queue_id).toLowerCase().includes(query)
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortKey === "id") return b.id - a.id;
|
||||
@@ -520,7 +576,7 @@ function TicketWorkbenchContent() {
|
||||
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
||||
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
||||
});
|
||||
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]);
|
||||
}, [clock, filters, queues, routeQueue, sortKey, tickets, view]);
|
||||
|
||||
|
||||
|
||||
@@ -538,24 +594,19 @@ function TicketWorkbenchContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchStatus = async (newStatus: string) => {
|
||||
const handleBatchAction = async (update: { status?: string; owner_id?: string | null; team_id?: string | null }) => {
|
||||
setBatchSaving(true);
|
||||
for (const id of batchIds) {
|
||||
await updateTicket(id, { status: newStatus });
|
||||
}
|
||||
const ids = Array.from(batchIds);
|
||||
const { data, error } = await batchUpdateTickets({ ticket_ids: ids, ...update });
|
||||
setBatchSaving(false);
|
||||
setBatchIds(new Set());
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleBatchAssign = async () => {
|
||||
const me = users[0]?.id;
|
||||
if (!me) return;
|
||||
setBatchSaving(true);
|
||||
for (const id of batchIds) {
|
||||
await updateTicket(id, { owner_id: me });
|
||||
if (!error && data) {
|
||||
const failed = data.results.filter((r) => !r.ok);
|
||||
if (failed.length > 0) {
|
||||
setError(`${failed.length} of ${ids.length} tickets failed to update`);
|
||||
}
|
||||
} else if (error) {
|
||||
setError(error);
|
||||
}
|
||||
setBatchSaving(false);
|
||||
setBatchIds(new Set());
|
||||
await fetchData();
|
||||
};
|
||||
@@ -572,30 +623,27 @@ function TicketWorkbenchContent() {
|
||||
e.stopPropagation();
|
||||
setResizingCol(leftKey);
|
||||
const startX = e.clientX;
|
||||
const leftCol = columns.find((c) => c.key === leftKey);
|
||||
const rightCol = rightKey ? columns.find((c) => c.key === rightKey) : null;
|
||||
const leftStart = leftCol?.width ?? 140;
|
||||
const rightStart = rightCol?.width ?? 140;
|
||||
const leftField = fieldByKey.get(leftKey);
|
||||
const rightField = rightKey ? fieldByKey.get(rightKey) : null;
|
||||
const leftStart = colWidths[leftKey] ?? leftField?.width ?? 140;
|
||||
const rightStart = rightField ? (colWidths[rightField.key] ?? rightField.width) : 140;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const newLeft = Math.max(50, Math.min(800, leftStart + delta));
|
||||
const newRight = rightCol ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.key === leftKey) return { ...c, width: newLeft };
|
||||
if (rightCol && c.key === rightCol.key) return { ...c, width: newRight! };
|
||||
return c;
|
||||
})
|
||||
);
|
||||
const newRight = rightField ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
|
||||
setColWidths((prev) => {
|
||||
const next = { ...prev, [leftKey]: newLeft };
|
||||
if (rightField && newRight !== undefined) next[rightField.key] = newRight;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizingCol(null);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.classList.remove("select-none");
|
||||
setResizingCol(null);
|
||||
};
|
||||
document.body.classList.add("select-none");
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
@@ -659,7 +707,7 @@ function TicketWorkbenchContent() {
|
||||
if (data) router.push(`/tickets/${data.ticket.id}`);
|
||||
};
|
||||
|
||||
if (loading) return <SkeletonWorkbench />;
|
||||
if (initialLoad && loading) return <SkeletonWorkbench />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background/80">
|
||||
@@ -676,6 +724,36 @@ function TicketWorkbenchContent() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Build CSV from visible columns
|
||||
const allCols = [...row1Fields, ...(density === "comfortable" ? row2EntriesResolved.map((e) => fieldByKey.get(e.key)).filter(Boolean) as ColumnConfig[] : [])];
|
||||
const headers = allCols.map((c) => c.label);
|
||||
const rows = filteredTickets.map((ticket) => {
|
||||
const ctx = { users, queues, teamsList };
|
||||
return allCols.map((col) => {
|
||||
if (col.key.startsWith("cf.")) {
|
||||
const cfKey = col.key.slice(3);
|
||||
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? "";
|
||||
}
|
||||
const v = getSubtitleValue(col.key, ticket, ctx);
|
||||
return v ?? "";
|
||||
});
|
||||
});
|
||||
const csv = [headers.join(','), ...rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `tickets-${new Date().toISOString().slice(0,10)}.csv`;
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="h-8 border-border/80 bg-card/70"
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -723,7 +801,7 @@ function TicketWorkbenchContent() {
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search subject, ticket ID, queue, or status"
|
||||
placeholder="Search tickets, comments, custom fields..."
|
||||
className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
@@ -830,7 +908,7 @@ function TicketWorkbenchContent() {
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
ref={addFilterBtnRef}
|
||||
type="button"
|
||||
@@ -846,6 +924,15 @@ function TicketWorkbenchContent() {
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
Add filter
|
||||
</button>
|
||||
{filters.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters([])}
|
||||
className="inline-flex h-7 items-center rounded px-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -881,6 +968,35 @@ function TicketWorkbenchContent() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{batchIds.size > 0 && (
|
||||
<div className="flex items-center gap-3 border-b border-border bg-primary/5 px-4 py-2">
|
||||
<span className="text-xs font-semibold text-foreground">{batchIds.size} selected</span>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val) { handleBatchAction({ status: val }); e.target.value = ""; }
|
||||
}}
|
||||
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] outline-none"
|
||||
>
|
||||
<option value="">Set status...</option>
|
||||
{Array.from(new Set(filteredTickets.map((t) => t.status))).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleBatchAction({ owner_id: null })}
|
||||
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] hover:bg-accent"
|
||||
>
|
||||
Unassign
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBatchIds(new Set())}
|
||||
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Table layout for consistent column alignment */}
|
||||
<div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}>
|
||||
{/* Column header */}
|
||||
@@ -889,11 +1005,11 @@ function TicketWorkbenchContent() {
|
||||
density === "compact" ? "min-h-7" : "min-h-8"
|
||||
)} style={{ display: "table-row" }}>
|
||||
<div style={{ display: "table-cell", width: 48 }} />
|
||||
{availableColumns.filter((c) => c.visible).map((col, idx, arr) => (
|
||||
{row1Fields.map((col, idx, arr) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="relative border-r border-border/60 px-3 align-middle last:border-r-0"
|
||||
style={{ display: "table-cell", width: col.width }}
|
||||
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
||||
>
|
||||
{/* Resize handle: drags the boundary, resizes column to the LEFT */}
|
||||
{idx > 0 && (
|
||||
@@ -910,6 +1026,39 @@ function TicketWorkbenchContent() {
|
||||
<div style={{ display: "table-cell", width: 48 }} />
|
||||
</div>
|
||||
|
||||
{/* Subtitle header — labels for each row2 field under its matching column */}
|
||||
{row2EntriesResolved.length > 0 && (
|
||||
<div className="border-b border-border/30 bg-muted/30" style={{ display: "table-row" }}>
|
||||
<div style={{ display: "table-cell", width: 48 }} />
|
||||
{row1Fields.map((col) => {
|
||||
const subsHere = subsByColumn.get(col.key) ?? [];
|
||||
const orphans = col.key === "subject" ? row2EntriesResolved.filter((e) => !row1Fields.some((rf) => rf.key === e.under)) : [];
|
||||
if (subsHere.length > 0 || orphans.length > 0) {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className="px-3 py-0.5 align-middle"
|
||||
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[9px] font-medium uppercase text-muted-foreground/50">
|
||||
{subsHere.map((e) => {
|
||||
const f = fieldByKey.get(e.key);
|
||||
return <span key={e.key}>{f?.label ?? e.key}</span>;
|
||||
})}
|
||||
{orphans.map((e) => {
|
||||
const f = fieldByKey.get(e.key);
|
||||
return <span key={e.key}>{f?.label ?? e.key}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div key={col.key} style={{ display: "table-cell", width: colWidth(col.key, col.width) }} />;
|
||||
})}
|
||||
<div style={{ display: "table-cell", width: 48 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTickets.map((ticket) => {
|
||||
const selected = false;
|
||||
const ownerName = ticket.owner_id
|
||||
@@ -946,10 +1095,10 @@ function TicketWorkbenchContent() {
|
||||
title={statusLabel(ticket.status)}
|
||||
/>
|
||||
</div>
|
||||
{availableColumns.filter((c) => c.visible).map((col) => {
|
||||
{row1Fields.map((col) => {
|
||||
const cellStyle = {
|
||||
display: "table-cell" as const,
|
||||
width: col.width,
|
||||
width: colWidth(col.key, col.width),
|
||||
verticalAlign: "middle" as const,
|
||||
padding: density === "compact" ? "4px 12px" : "8px 12px",
|
||||
};
|
||||
@@ -957,64 +1106,121 @@ function TicketWorkbenchContent() {
|
||||
if (col.key.startsWith("cf.")) {
|
||||
const cfKey = col.key.slice(3);
|
||||
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
|
||||
const cfSubs = subsByColumn.get(col.key) ?? [];
|
||||
return (
|
||||
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
||||
{cfValue ?? "—"}
|
||||
{density === "comfortable" && cfSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (col.key) {
|
||||
case "id":
|
||||
case "id": {
|
||||
const idSubs = subsByColumn.get("id") ?? [];
|
||||
return (
|
||||
<div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}>
|
||||
{formatTicketId(ticket.id)}
|
||||
{density === "comfortable" && idSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
|
||||
</div>
|
||||
);
|
||||
case "subject":
|
||||
}
|
||||
case "subject": {
|
||||
// Subtitle fields that don't have a matching row1 column
|
||||
// Subtitle under subject + orphans (under column not in row1)
|
||||
const subsHere = row2EntriesResolved.filter((e) =>
|
||||
e.under === "subject" || !row1Fields.some((rf) => rf.key === e.under)
|
||||
);
|
||||
const subParts: string[] = [];
|
||||
const ctx = { users, queues, teamsList };
|
||||
for (const e of subsHere) {
|
||||
const v = getSubtitleValue(e.key, ticket, ctx);
|
||||
if (v) subParts.push(v);
|
||||
}
|
||||
return (
|
||||
<div key={col.key} className="min-w-[200px]" style={cellStyle}>
|
||||
<div key={col.key} className="min-w-[240px]" style={cellStyle}>
|
||||
<span className="block truncate text-sm font-semibold text-foreground">
|
||||
{ticket.subject}
|
||||
</span>
|
||||
{density === "comfortable" && (
|
||||
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{ownerName ?? "Unassigned"}
|
||||
<span className="h-1 w-1 rounded-full bg-border" />
|
||||
Created {relativeTime(ticket.created_at)}
|
||||
</span>
|
||||
{density === "comfortable" && subParts.length > 0 && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{subParts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span className="h-1 w-1 rounded-full bg-border shrink-0" />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "status":
|
||||
const statusSubs = subsByColumn.get("status") ?? [];
|
||||
return (
|
||||
<div key={col.key} style={cellStyle}>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
{density === "comfortable" && statusSubs.map((e) => (
|
||||
<div key={e.key} className="mt-0.5 text-xs text-muted-foreground">
|
||||
{getSubtitleValue(e.key, ticket, { users, queues, teamsList })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "queue":
|
||||
case "queue": {
|
||||
const subs = subsByColumn.get("queue") ?? [];
|
||||
return (
|
||||
<div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}>
|
||||
{queueName(queues, ticket.queue_id)}
|
||||
{density === "comfortable" && subs.map((e) => (
|
||||
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "owner":
|
||||
}
|
||||
case "owner": {
|
||||
const subs = subsByColumn.get("owner") ?? [];
|
||||
return (
|
||||
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
||||
{ownerName ?? "—"}
|
||||
{density === "comfortable" && subs.map((e) => (
|
||||
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "created":
|
||||
}
|
||||
case "created": {
|
||||
const subs = subsByColumn.get("created") ?? [];
|
||||
return (
|
||||
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
||||
{relativeTime(ticket.created_at)}
|
||||
{density === "comfortable" && subs.map((e) => (
|
||||
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
case "updated":
|
||||
}
|
||||
case "updated": {
|
||||
const subs = subsByColumn.get("updated") ?? [];
|
||||
return (
|
||||
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
||||
{relativeTime(ticket.updated_at)}
|
||||
{density === "comfortable" && subs.map((e) => (
|
||||
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "team": {
|
||||
const subs = subsByColumn.get("team") ?? [];
|
||||
return (
|
||||
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
||||
{teamsList.find((t) => t.id === ticket.team_id)?.name ?? "—"}
|
||||
{density === "comfortable" && subs.map((e) => (
|
||||
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <div key={col.key} style={cellStyle} />;
|
||||
}
|
||||
@@ -1188,7 +1394,7 @@ function TicketWorkbenchContent() {
|
||||
name: saveViewName.trim(),
|
||||
filters: storedFilters,
|
||||
sort_key: sortKey,
|
||||
columns,
|
||||
columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
|
||||
});
|
||||
if (!error && data) {
|
||||
setSavedViewsList((prev) => [...prev, data]);
|
||||
@@ -1220,7 +1426,7 @@ function TicketWorkbenchContent() {
|
||||
name: saveViewName.trim(),
|
||||
filters: storedFilters,
|
||||
sort_key: sortKey,
|
||||
columns,
|
||||
columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
|
||||
});
|
||||
if (!error && data) {
|
||||
setSavedViewsList((prev) => [...prev, data]);
|
||||
@@ -1250,32 +1456,26 @@ function TicketWorkbenchContent() {
|
||||
>
|
||||
{!addFilterField ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
if (!filters.find((f) => f.field === "queue")) {
|
||||
setAddFilterField("queue");
|
||||
setAddFilterOperator("is");
|
||||
{[
|
||||
{ field: "queue", label: "Queue" },
|
||||
{ field: "owner", label: "Owner" },
|
||||
{ field: "subject", label: "Subject" },
|
||||
{ field: "created", label: "Created date" },
|
||||
{ field: "updated", label: "Updated date" },
|
||||
].map(({ field, label }) => (
|
||||
<button
|
||||
key={field}
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
if (field === "queue" && filters.find((f) => f.field === "queue")) { setAddFilterOpen(false); return; }
|
||||
setAddFilterField(field);
|
||||
setAddFilterOperator(field === "created" || field === "updated" ? "before" : "contains");
|
||||
setAddFilterValue("");
|
||||
} else {
|
||||
setAddFilterOpen(false);
|
||||
}
|
||||
}}
|
||||
>Queue</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
if (!filters.find((f) => f.field === "owner")) {
|
||||
setAddFilterField("owner");
|
||||
setAddFilterOperator("is");
|
||||
setAddFilterValue("");
|
||||
} else {
|
||||
setAddFilterOpen(false);
|
||||
}
|
||||
}}
|
||||
>Owner</button>
|
||||
}}
|
||||
>{label}</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-border/30" />
|
||||
{customFields.map((cf) => (
|
||||
<button
|
||||
key={`cf-portal-${cf.id}`}
|
||||
@@ -1283,7 +1483,7 @@ function TicketWorkbenchContent() {
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
setAddFilterField(`cf.${cf.key}`);
|
||||
setAddFilterOperator("is");
|
||||
setAddFilterOperator(cf.field_type === "date" || cf.field_type === "datetime" ? "before" : "contains");
|
||||
setAddFilterValue("");
|
||||
}}
|
||||
>{cf.name}</button>
|
||||
@@ -1295,26 +1495,55 @@ function TicketWorkbenchContent() {
|
||||
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground">←</button>
|
||||
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
|
||||
</div>
|
||||
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||
<option value="is">is</option>
|
||||
<option value="is_not">is not</option>
|
||||
</select>
|
||||
{addFilterField === "queue" || addFilterField === "owner" ? (
|
||||
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||
<option value="is">is</option>
|
||||
<option value="is_not">is not</option>
|
||||
</select>
|
||||
) : addFilterField === "created" || addFilterField === "updated" ? (
|
||||
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||
<option value="before">before</option>
|
||||
<option value="after">after</option>
|
||||
</select>
|
||||
) : (
|
||||
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||
<option value="contains">contains</option>
|
||||
<option value="is">is</option>
|
||||
<option value="is_not">is not</option>
|
||||
<option value="starts_with">starts with</option>
|
||||
</select>
|
||||
)}
|
||||
{addFilterField === "queue" ? (
|
||||
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||
<option value="">Select queue...</option>
|
||||
{queues.map((q) => (<option key={q.id} value={q.id}>{q.name}</option>))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
value={addFilterValue}
|
||||
onChange={setAddFilterValue}
|
||||
options={queues.map((q) => ({ value: q.id, label: q.name }))}
|
||||
placeholder="Select queue..."
|
||||
searchPlaceholder="Search queues..."
|
||||
className="w-48"
|
||||
/>
|
||||
) : addFilterField === "owner" ? (
|
||||
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
||||
<option value="">Select owner...</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{users.map((u) => (<option key={u.id} value={u.id}>{u.username}</option>))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
value={addFilterValue}
|
||||
onChange={setAddFilterValue}
|
||||
options={[
|
||||
{ value: "unassigned", label: "Unassigned" },
|
||||
...users.map((u) => ({ value: u.id, label: u.username })),
|
||||
]}
|
||||
placeholder="Select owner..."
|
||||
searchPlaceholder="Search users..."
|
||||
className="w-48"
|
||||
/>
|
||||
) : addFilterField === "created" || addFilterField === "updated" ? (
|
||||
<input type="date" value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none" />
|
||||
) : (
|
||||
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && addFilterValue.trim()) {
|
||||
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field: addFilterField, operator: addFilterOperator, value: addFilterValue, label: buildFilterLabel(addFilterField, addFilterOperator, addFilterValue) }]);
|
||||
const field = addFilterField!;
|
||||
const value = addFilterValue;
|
||||
let valueLabel = field === "created" || field === "updated" ? new Date(value).toLocaleDateString() : value;
|
||||
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
||||
setAddFilterField(null);
|
||||
setAddFilterOpen(false);
|
||||
}
|
||||
@@ -1326,11 +1555,12 @@ function TicketWorkbenchContent() {
|
||||
<button type="button" disabled={!addFilterValue.trim()}
|
||||
onClick={() => {
|
||||
if (!addFilterValue.trim()) return;
|
||||
const field = addFilterField;
|
||||
const field = addFilterField!;
|
||||
const value = addFilterValue;
|
||||
let valueLabel = value;
|
||||
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
|
||||
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
|
||||
else if (field === "created" || field === "updated") valueLabel = new Date(value).toLocaleDateString();
|
||||
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
||||
setAddFilterField(null);
|
||||
setAddFilterOpen(false);
|
||||
@@ -1346,33 +1576,16 @@ function TicketWorkbenchContent() {
|
||||
)}
|
||||
|
||||
{typeof document !== "undefined" && colPickerOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={() => setColPickerOpen(false)} />
|
||||
<div className="fixed z-[9999] w-48 rounded-md border border-border bg-card p-1 shadow-lg"
|
||||
style={{ left: "calc(100% - 220px)", top: "72px" }}
|
||||
>
|
||||
{availableColumns.map((col) => {
|
||||
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
|
||||
return (
|
||||
<button
|
||||
key={col.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => c.key === col.key ? { ...c, visible: !c.visible } : c)
|
||||
);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent"
|
||||
>
|
||||
<span className={cn("text-xs", isVisible ? "text-primary" : "text-muted-foreground/30")}>
|
||||
{isVisible ? "✓" : "—"}
|
||||
</span>
|
||||
{col.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
<LayoutBuilder
|
||||
fields={allFields}
|
||||
row1={row1Fields}
|
||||
row2={row2EntriesResolved}
|
||||
onChange={(r1, r2) => {
|
||||
setRow1Keys(r1.map((f) => f.key));
|
||||
setRow2Entries(r2);
|
||||
}}
|
||||
onClose={() => setColPickerOpen(false)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use, useCallback } from "react";
|
||||
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
CircleIcon,
|
||||
Clock3Icon,
|
||||
FileTextIcon,
|
||||
Link2Icon,
|
||||
MessageSquareIcon,
|
||||
PaperclipIcon,
|
||||
PencilIcon,
|
||||
SaveIcon,
|
||||
SendIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@@ -32,6 +34,12 @@ import {
|
||||
updateTicket,
|
||||
updateTicketCustomField,
|
||||
sendComment,
|
||||
uploadAttachments,
|
||||
getAttachmentUrl,
|
||||
getTicketLinks,
|
||||
createTicketLink,
|
||||
deleteTicketLink,
|
||||
mergeTickets,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
Ticket,
|
||||
@@ -43,8 +51,12 @@ import type {
|
||||
QueueCustomField,
|
||||
PreviewResult,
|
||||
UpdateResult,
|
||||
AttachmentUploadResult,
|
||||
Attachment,
|
||||
TicketLink,
|
||||
} from "@/lib/types";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SearchableSelect } from "@/components/searchable-select";
|
||||
import { cn, formatTicketId } from "@/lib/utils";
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
@@ -101,6 +113,25 @@ function userLabel(users: User[], userId: string | null) {
|
||||
return user?.username ?? userId;
|
||||
}
|
||||
|
||||
function formatCfValue(value: string, fieldType: string): string {
|
||||
if (!value) return "";
|
||||
if (fieldType === "date") {
|
||||
try {
|
||||
return format(new Date(value), "MMM d, yyyy");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (fieldType === "datetime") {
|
||||
try {
|
||||
return format(new Date(value), "MMM d, yyyy HH:mm");
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function TransactionCard({
|
||||
tx,
|
||||
users,
|
||||
@@ -117,6 +148,8 @@ function TransactionCard({
|
||||
tx.transaction_type === "SetOwner" ||
|
||||
tx.transaction_type === "SetTeam" ||
|
||||
tx.transaction_type === "CustomFieldChange" ||
|
||||
tx.transaction_type === "LinkCreate" ||
|
||||
tx.transaction_type === "LinkDelete" ||
|
||||
tx.transaction_type === "Create";
|
||||
const isInternal = tx.transaction_type === "Comment";
|
||||
const isMessage = tx.transaction_type === "Correspond" || isInternal;
|
||||
@@ -141,6 +174,26 @@ function TransactionCard({
|
||||
} else if (tx.transaction_type === "CustomFieldChange") {
|
||||
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
|
||||
message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`;
|
||||
} else if (tx.transaction_type === "LinkCreate") {
|
||||
const targetId = typeof tx.data === "object" && tx.data !== null && "target_ticket_id" in (tx.data as Record<string, unknown>)
|
||||
? Number((tx.data as Record<string, unknown>).target_ticket_id)
|
||||
: null;
|
||||
const targetSubject = typeof tx.data === "object" && tx.data !== null && "target_subject" in (tx.data as Record<string, unknown>)
|
||||
? String((tx.data as Record<string, unknown>).target_subject)
|
||||
: "";
|
||||
const linkType = tx.field || "RelatedTo";
|
||||
const targetLabel = targetId ? `${formatTicketId(targetId)}${targetSubject ? ` (${targetSubject})` : ""}` : "?";
|
||||
message = `Linked as ${linkType} to ${targetLabel}`;
|
||||
} else if (tx.transaction_type === "LinkDelete") {
|
||||
const targetId = typeof tx.data === "object" && tx.data !== null && "target_ticket_id" in (tx.data as Record<string, unknown>)
|
||||
? Number((tx.data as Record<string, unknown>).target_ticket_id)
|
||||
: null;
|
||||
const targetSubject = typeof tx.data === "object" && tx.data !== null && "target_subject" in (tx.data as Record<string, unknown>)
|
||||
? String((tx.data as Record<string, unknown>).target_subject)
|
||||
: "";
|
||||
const linkType = tx.field || "RelatedTo";
|
||||
const targetLabel = targetId ? `${formatTicketId(targetId)}${targetSubject ? ` (${targetSubject})` : ""}` : "?";
|
||||
message = `Link ${linkType} to ${targetLabel} removed`;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -172,10 +225,42 @@ function TransactionCard({
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground/50">{timeAgo}</span>
|
||||
{(tx.time_worked_minutes ?? 0) > 0 && (
|
||||
<span className="rounded bg-emerald-500/10 px-1 py-0 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
+{tx.time_worked_minutes}m
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
|
||||
{body}
|
||||
</p>
|
||||
{tx.attachments && tx.attachments.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{tx.attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={getAttachmentUrl(att.id)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border/50 bg-card px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:border-primary/30 hover:bg-accent/30"
|
||||
>
|
||||
{att.mime_type.startsWith("image/") ? (
|
||||
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate max-w-48">{att.filename}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/70">
|
||||
{att.size_bytes < 1024
|
||||
? `${att.size_bytes}B`
|
||||
: att.size_bytes < 1024 * 1024
|
||||
? `${(att.size_bytes / 1024).toFixed(0)}KB`
|
||||
: `${(att.size_bytes / (1024 * 1024)).toFixed(1)}MB`}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,8 +297,12 @@ export default function TicketDetailPage({
|
||||
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
||||
const [timeMinutes, setTimeMinutes] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
|
||||
const [pendingStatus, setPendingStatus] = useState<string | null>(null);
|
||||
@@ -224,6 +313,16 @@ export default function TicketDetailPage({
|
||||
const [editingSubject, setEditingSubject] = useState(false);
|
||||
const [subjectDraft, setSubjectDraft] = useState("");
|
||||
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null);
|
||||
const [links, setLinks] = useState<TicketLink[]>([]);
|
||||
const [linkTargetId, setLinkTargetId] = useState("");
|
||||
const [linkType, setLinkType] = useState("RelatedTo");
|
||||
const [linkSaving, setLinkSaving] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const [linkDeleting, setLinkDeleting] = useState<string | null>(null);
|
||||
const [mergeTargetId, setMergeTargetId] = useState("");
|
||||
const [mergeSaving, setMergeSaving] = useState(false);
|
||||
const [mergeError, setMergeError] = useState<string | null>(null);
|
||||
|
||||
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
|
||||
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
|
||||
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
|
||||
@@ -287,6 +386,14 @@ export default function TicketDetailPage({
|
||||
setTeams(teamsRes.data ?? []);
|
||||
}
|
||||
|
||||
// Load ticket links
|
||||
const linksRes = await getTicketLinks(id);
|
||||
if (linksRes.error) {
|
||||
setError((prev) => prev || linksRes.error);
|
||||
} else {
|
||||
setLinks(linksRes.data ?? []);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
@@ -466,13 +573,34 @@ export default function TicketDetailPage({
|
||||
};
|
||||
|
||||
const handleSendComment = async () => {
|
||||
if (!replyText.trim() || sending) return;
|
||||
if ((!replyText.trim() && pendingFiles.length === 0) || sending) return;
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
|
||||
let attachmentIds: string[] = [];
|
||||
|
||||
// Upload files first if any
|
||||
if (pendingFiles.length > 0) {
|
||||
setUploadingFiles(true);
|
||||
const { data, error } = await uploadAttachments(id, pendingFiles);
|
||||
setUploadingFiles(false);
|
||||
|
||||
if (error) {
|
||||
setSendError(error);
|
||||
setSending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
attachmentIds = data.attachments.map((a) => a.id);
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await sendComment(id, {
|
||||
body: replyText.trim(),
|
||||
body: replyText.trim() || "(attached files)",
|
||||
internal: replyMode === "internal",
|
||||
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||
time_worked_minutes: timeMinutes.trim() ? Number(timeMinutes.trim()) : undefined,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
@@ -481,12 +609,88 @@ export default function TicketDetailPage({
|
||||
setSendError(error);
|
||||
} else {
|
||||
setReplyText("");
|
||||
setTimeMinutes("");
|
||||
setPendingFiles([]);
|
||||
setSendError(null);
|
||||
const txRes = await getTicketTransactions(id);
|
||||
if (txRes.data) setTransactions(txRes.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateLink = async () => {
|
||||
// Accept both raw numbers and TKT-XXXX format
|
||||
const raw = linkTargetId.trim().replace(/^TKT-0*/i, '');
|
||||
const targetId = Number(raw);
|
||||
if (!raw || isNaN(targetId)) {
|
||||
setLinkError('Enter a ticket ID (e.g. "42" or "TKT-0042")');
|
||||
return;
|
||||
}
|
||||
if (linkSaving) return;
|
||||
|
||||
setLinkSaving(true);
|
||||
setLinkError(null);
|
||||
|
||||
const { data, error } = await createTicketLink(id, {
|
||||
target_ticket_id: targetId,
|
||||
link_type: linkType,
|
||||
});
|
||||
|
||||
setLinkSaving(false);
|
||||
|
||||
if (error) {
|
||||
setLinkError(error);
|
||||
} else {
|
||||
setLinkTargetId("");
|
||||
setLinkType("RelatedTo");
|
||||
// Refresh links and transactions
|
||||
const [linksRes, txRes] = await Promise.all([
|
||||
getTicketLinks(id),
|
||||
getTicketTransactions(id),
|
||||
]);
|
||||
if (linksRes.data) setLinks(linksRes.data);
|
||||
if (txRes.data) setTransactions(txRes.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (linkDeleting) return;
|
||||
setLinkDeleting(linkId);
|
||||
|
||||
const { error } = await deleteTicketLink(id, linkId);
|
||||
|
||||
setLinkDeleting(null);
|
||||
|
||||
if (!error) {
|
||||
const [linksRes, txRes] = await Promise.all([
|
||||
getTicketLinks(id),
|
||||
getTicketTransactions(id),
|
||||
]);
|
||||
if (linksRes.data) setLinks(linksRes.data);
|
||||
if (txRes.data) setTransactions(txRes.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
const targetId = Number(mergeTargetId.trim().replace(/^TKT-0*/i, ''));
|
||||
if (!targetId || isNaN(targetId) || mergeSaving) return;
|
||||
setMergeSaving(true);
|
||||
setMergeError(null);
|
||||
const { data, error } = await mergeTickets(id, targetId);
|
||||
setMergeSaving(false);
|
||||
if (error) {
|
||||
setMergeError(error);
|
||||
} else if (data?.ok) {
|
||||
setMergeTargetId("");
|
||||
// Reload ticket and transactions
|
||||
const [ticketRes, txRes] = await Promise.all([
|
||||
getTicket(id),
|
||||
getTicketTransactions(id),
|
||||
]);
|
||||
if (ticketRes.data) setTicket(ticketRes.data);
|
||||
if (txRes.data) setTransactions(txRes.data);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid h-full grid-cols-1 xl:grid-cols-[minmax(0,1fr)_348px]">
|
||||
@@ -546,10 +750,13 @@ export default function TicketDetailPage({
|
||||
const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
|
||||
const currentStatusLabel = statusLabel(ticket.status);
|
||||
const customFieldLabels = Object.fromEntries(
|
||||
queueFields.map((assignment) => [
|
||||
assignment.custom_field_id,
|
||||
assignment.custom_field?.name ?? assignment.custom_field_id,
|
||||
])
|
||||
queueFields.flatMap((assignment) => {
|
||||
const name = assignment.custom_field?.name ?? assignment.custom_field_id;
|
||||
const key = assignment.custom_field?.key;
|
||||
const entries: [string, string][] = [[assignment.custom_field_id, name]];
|
||||
if (key) entries.push([key, name]);
|
||||
return entries;
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -707,6 +914,28 @@ export default function TicketDetailPage({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{pendingFiles.map((file, i) => (
|
||||
<span
|
||||
key={`${file.name}-${i}`}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-accent/20 px-2 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
<FileTextIcon className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="truncate max-w-32">{file.name}</span>
|
||||
<button
|
||||
onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
||||
title="Remove file"
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={replyText}
|
||||
@@ -719,19 +948,46 @@ export default function TicketDetailPage({
|
||||
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={timeMinutes}
|
||||
onChange={(e) => setTimeMinutes(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="min"
|
||||
className="h-9 w-14 rounded-md border border-input bg-card/90 px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||
title="Time worked (minutes)"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
if (selected.length > 0) {
|
||||
setPendingFiles((prev) => [...prev, ...selected]);
|
||||
}
|
||||
// Reset so the same file can be re-selected
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Attach file (coming soon)"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card transition-colors hover:bg-accent",
|
||||
pendingFiles.length > 0
|
||||
? "text-primary border-primary/50"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
title="Attach files"
|
||||
type="button"
|
||||
>
|
||||
<PaperclipIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSendComment}
|
||||
disabled={!replyText.trim() || sending}
|
||||
disabled={(!replyText.trim() && pendingFiles.length === 0) || sending}
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-md transition-colors",
|
||||
replyText.trim() && !sending
|
||||
(replyText.trim() || pendingFiles.length > 0) && !sending
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
@@ -749,6 +1005,14 @@ export default function TicketDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingFiles.length > 0 && (
|
||||
<p className="mt-1.5 text-[10px] text-muted-foreground/70">
|
||||
{pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""} selected — will be uploaded when you send
|
||||
</p>
|
||||
)}
|
||||
{uploadingFiles && (
|
||||
<p className="mt-1.5 text-[10px] text-muted-foreground/70">Uploading files...</p>
|
||||
)}
|
||||
{sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>}
|
||||
</div>
|
||||
</footer>
|
||||
@@ -803,6 +1067,30 @@ export default function TicketDetailPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{ticket.blocked_by && ticket.blocked_by.length > 0 && (
|
||||
<section className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-amber-500" /> Blocked
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
This ticket depends on unresolved tickets:
|
||||
</p>
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{ticket.blocked_by.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={`/tickets/${b.id}`}
|
||||
className="flex items-center gap-1.5 text-[11px] text-foreground hover:text-primary"
|
||||
>
|
||||
<span className="font-mono text-[10px]">{formatTicketId(b.id)}</span>
|
||||
<span className="truncate">{b.subject}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{b.status}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<section className="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||
@@ -851,29 +1139,27 @@ export default function TicketDetailPage({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label>
|
||||
<select
|
||||
<SearchableSelect
|
||||
value={ticket.owner_id ?? ""}
|
||||
onChange={(event) => void handleOwnerChange(event.target.value)}
|
||||
onChange={(val) => void handleOwnerChange(val)}
|
||||
options={users.map((u) => ({ value: u.id, label: u.username }))}
|
||||
placeholder="Unassigned"
|
||||
searchPlaceholder="Search users..."
|
||||
disabled={fieldSaving === "owner"}
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50"
|
||||
aria-label="Owner"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{users.map((user) => (<option key={user.id} value={user.id}>{user.username}</option>))}
|
||||
</select>
|
||||
clearLabel="Unassigned"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Team</label>
|
||||
<select
|
||||
<SearchableSelect
|
||||
value={ticket.team_id ?? ""}
|
||||
onChange={(event) => void handleTeamChange(event.target.value)}
|
||||
onChange={(val) => void handleTeamChange(val)}
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
placeholder="No team"
|
||||
searchPlaceholder="Search teams..."
|
||||
disabled={fieldSaving === "team"}
|
||||
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50"
|
||||
aria-label="Team"
|
||||
>
|
||||
<option value="">No team</option>
|
||||
{teams.map((team) => (<option key={team.id} value={team.id}>{team.name}</option>))}
|
||||
</select>
|
||||
clearLabel="No team"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -900,9 +1186,117 @@ export default function TicketDetailPage({
|
||||
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Time worked</span>
|
||||
<span className="text-foreground">
|
||||
{(() => {
|
||||
const totalMin = transactions.reduce((sum, tx) => sum + (tx.time_worked_minutes ?? 0), 0);
|
||||
if (totalMin < 60) return `${totalMin}m`;
|
||||
const h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Linked tickets */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Linked tickets</h2>
|
||||
{links.length === 0 && !linkSaving && (
|
||||
<p className="text-xs text-muted-foreground">No linked tickets.</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="group flex items-center gap-1.5 rounded px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent/30"
|
||||
>
|
||||
<Link2Icon className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
<span className="text-[10px] font-medium text-muted-foreground shrink-0">{link.link_type}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50 shrink-0">→</span>
|
||||
{link.target_ticket ? (
|
||||
<Link
|
||||
href={`/tickets/${link.target_ticket_id}`}
|
||||
className="font-mono text-[11px] font-semibold text-foreground shrink-0 transition-colors hover:text-primary"
|
||||
>
|
||||
{formatTicketId(link.target_ticket_id)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{link.target_ticket_id}</span>
|
||||
)}
|
||||
{link.target_ticket && (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{link.target_ticket.subject}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteLink(link.id)}
|
||||
disabled={linkDeleting === link.id}
|
||||
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/20 hover:text-destructive group-hover:opacity-100 disabled:opacity-50"
|
||||
title="Remove link"
|
||||
type="button"
|
||||
>
|
||||
{linkDeleting === link.id ? (
|
||||
<div className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
) : (
|
||||
<Trash2Icon className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Inline link form */}
|
||||
<div className="mt-2.5 space-y-2 rounded-md border border-border/30 p-2">
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
value={linkTargetId}
|
||||
onChange={(event) => {
|
||||
setLinkTargetId(event.target.value);
|
||||
setLinkError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleCreateLink();
|
||||
}}
|
||||
placeholder="Ticket ID..."
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
<select
|
||||
value={linkType}
|
||||
onChange={(event) => setLinkType(event.target.value)}
|
||||
className="h-7 w-28 shrink-0 rounded-md border border-input bg-transparent px-1.5 text-sm text-foreground outline-none focus:border-ring"
|
||||
>
|
||||
<option value="RelatedTo">Related to</option>
|
||||
<option value="DependsOn">Depends on</option>
|
||||
<option value="Blocks">Blocks</option>
|
||||
<option value="RefersTo">Refers to</option>
|
||||
<option value="Duplicates">Duplicates</option>
|
||||
<option value="MemberOf">Member of</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateLink}
|
||||
disabled={!linkTargetId.trim() || linkSaving}
|
||||
className="flex h-6.5 w-full items-center justify-center gap-1 rounded-md bg-primary text-[11px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
type="button"
|
||||
>
|
||||
{linkSaving ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
) : (
|
||||
<Link2Icon className="h-3 w-3" />
|
||||
)}
|
||||
Link
|
||||
</button>
|
||||
{linkError && <p className="text-[10px] text-destructive">{linkError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Custom fields — flat, no heavy borders */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2>
|
||||
@@ -944,6 +1338,31 @@ export default function TicketDetailPage({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field?.field_type === 'date' ? (
|
||||
<input
|
||||
type="date"
|
||||
value={draftValue}
|
||||
onChange={(event) => {
|
||||
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }));
|
||||
void handleCustomFieldSave(fieldId, event.target.value);
|
||||
}}
|
||||
onBlur={() => setEditingFieldId(null)}
|
||||
autoFocus
|
||||
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
) : field?.field_type === 'datetime' ? (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={draftValue ? draftValue.slice(0, 16) : ""}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value ? new Date(event.target.value).toISOString() : "";
|
||||
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
|
||||
void handleCustomFieldSave(fieldId, nextValue);
|
||||
}}
|
||||
onBlur={() => setEditingFieldId(null)}
|
||||
autoFocus
|
||||
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={draftValue}
|
||||
@@ -1001,7 +1420,7 @@ export default function TicketDetailPage({
|
||||
currentValue ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{currentValue || "Not set"}
|
||||
{currentValue ? formatCfValue(currentValue, field?.field_type ?? "text") : "Not set"}
|
||||
</span>
|
||||
<PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
@@ -1013,6 +1432,34 @@ export default function TicketDetailPage({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Merge */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Merge</h2>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Move all transactions, attachments, and links into another ticket. This ticket will be closed.
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={mergeTargetId}
|
||||
onChange={(e) => { setMergeTargetId(e.target.value); setMergeError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleMerge(); }}
|
||||
placeholder="Target ticket ID..."
|
||||
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={handleMerge}
|
||||
disabled={!mergeTargetId.trim() || mergeSaving}
|
||||
className="h-7 shrink-0 rounded-md bg-destructive/90 px-2.5 text-[11px] font-semibold text-destructive-foreground hover:bg-destructive disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{mergeSaving ? "..." : "Merge"}
|
||||
</button>
|
||||
</div>
|
||||
{mergeError && <p className="mt-1.5 text-[10px] text-destructive">{mergeError}</p>}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BellIcon,
|
||||
CircleIcon,
|
||||
LayoutGridIcon,
|
||||
UserIcon,
|
||||
@@ -15,11 +16,12 @@ import {
|
||||
PanelLeftIcon,
|
||||
CommandIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { cn, formatTicketId } from "@/lib/utils";
|
||||
|
||||
const SidebarCollapsedContext = createContext(false);
|
||||
|
||||
@@ -77,6 +79,7 @@ function SidebarNavItem({
|
||||
function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const [counts, setCounts] = useState<ViewCounts>({
|
||||
all: 0,
|
||||
@@ -87,20 +90,17 @@ function SidebarNav() {
|
||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
||||
const [newDashboardName, setNewDashboardName] = useState("");
|
||||
const [addingDashboard, setAddingDashboard] = useState(false);
|
||||
|
||||
const currentUserId = authUser?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
// Find current user
|
||||
const myId = currentUserId;
|
||||
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
||||
const data = ticketRes.data;
|
||||
const users = userRes.data ?? [];
|
||||
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
|
||||
const myId = currentUser?.id ?? null;
|
||||
setCurrentUserId(myId);
|
||||
|
||||
if (data) {
|
||||
const now = Date.now();
|
||||
@@ -135,9 +135,9 @@ function SidebarNav() {
|
||||
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
||||
const allDashboards = dashRes.data ?? [];
|
||||
const allTeams = teamRes.data ?? [];
|
||||
const userTeams = allTeams.filter((t) =>
|
||||
(t.members ?? []).some((m) => m.id === myId)
|
||||
);
|
||||
const userTeams = myId
|
||||
? allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId))
|
||||
: [];
|
||||
setMyTeamId(userTeams[0]?.id ?? null);
|
||||
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||
const visible = allDashboards.filter((d) =>
|
||||
@@ -146,7 +146,7 @@ function SidebarNav() {
|
||||
setDashboards(visible);
|
||||
}
|
||||
void load();
|
||||
}, []);
|
||||
}, [currentUserId]);
|
||||
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
@@ -326,22 +326,268 @@ function SidebarNav() {
|
||||
function SidebarBottom() {
|
||||
const pathname = usePathname();
|
||||
const collapsed = useSidebarCollapsed();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [tokenOpen, setTokenOpen] = useState(false);
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [newTokenName, setNewTokenName] = useState("");
|
||||
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
const loadTokens = async () => {
|
||||
const { data } = await getApiTokens();
|
||||
if (data) setTokens(data);
|
||||
};
|
||||
|
||||
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
|
||||
|
||||
const handleCreateToken = async () => {
|
||||
if (!newTokenName.trim()) return;
|
||||
setTokenError(null);
|
||||
const { data, error } = await createApiToken(newTokenName.trim());
|
||||
if (error) { setTokenError(error); return; }
|
||||
if (data) {
|
||||
setNewTokenValue(data.token);
|
||||
setNewTokenName("");
|
||||
await loadTokens();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
await revokeApiToken(id);
|
||||
await loadTokens();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-sidebar-border/50 p-2">
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
|
||||
<div className="border-t border-sidebar-border/50 p-2 space-y-1">
|
||||
{isAdmin && (
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
)}
|
||||
{user ? (
|
||||
<>
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
|
||||
{user.username}
|
||||
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTokenOpen(true)}
|
||||
className={cn(
|
||||
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
<span className="opacity-50">API tokens</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className={cn(
|
||||
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
<span className="opacity-50">Sign out</span>
|
||||
</button>
|
||||
|
||||
{/* Token dialog */}
|
||||
{tokenOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
|
||||
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{newTokenValue ? (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-xs font-semibold text-foreground">Token created — copy it now:</p>
|
||||
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
placeholder="Token name..."
|
||||
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateToken}
|
||||
disabled={!newTokenName.trim()}
|
||||
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
|
||||
{tokens.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{tokens.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
|
||||
<div>
|
||||
<p className="text-xs font-medium">{t.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Created {new Date(t.created_at).toLocaleDateString()}
|
||||
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevoke(t.id)}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<SidebarNavItem
|
||||
href="/login"
|
||||
icon={UserIcon}
|
||||
label="Sign in"
|
||||
active={pathname === "/login"}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [notifs, setNotifs] = useState<Notification[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const load = async () => {
|
||||
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
|
||||
if (countRes.data) setUnread(countRes.data.count);
|
||||
if (notifRes.data) setNotifs(notifRes.data);
|
||||
};
|
||||
void load();
|
||||
// Poll every 30s
|
||||
const interval = setInterval(() => { void load(); }, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await markNotificationRead(id);
|
||||
setUnread((c) => Math.max(0, c - 1));
|
||||
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
await markAllNotificationsRead();
|
||||
setUnread(0);
|
||||
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
|
||||
>
|
||||
<BellIcon className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-foreground">Notifications</span>
|
||||
{unread > 0 && (
|
||||
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{notifs.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No notifications yet.
|
||||
</div>
|
||||
) : (
|
||||
notifs.slice(0, 20).map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
handleMarkRead(n.id);
|
||||
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
|
||||
}}
|
||||
className={cn(
|
||||
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
|
||||
!n.read && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
||||
n.read ? "bg-border" : "bg-primary"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">{n.title}</p>
|
||||
{n.body && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
|
||||
)}
|
||||
{n.ticket_id && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
||||
{formatTicketId(n.ticket_id)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -384,15 +630,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
<NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
|
||||
210
web/src/components/layout-builder.tsx
Normal file
210
web/src/components/layout-builder.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { GripVerticalIcon, XIcon, ArrowDownIcon } from "lucide-react";
|
||||
|
||||
export interface LayoutField {
|
||||
key: string;
|
||||
label: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface SubtitleEntry {
|
||||
key: string;
|
||||
under: string; // which row1 column this subtitle field sits under
|
||||
}
|
||||
|
||||
interface LayoutBuilderProps {
|
||||
fields: LayoutField[];
|
||||
row1: LayoutField[];
|
||||
row2: SubtitleEntry[];
|
||||
onChange: (row1: LayoutField[], row2: SubtitleEntry[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LayoutBuilder({ fields: allFields, row1, row2, onChange, onClose }: LayoutBuilderProps) {
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, key: string) => {
|
||||
setDragKey(key);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", key);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragKey(null);
|
||||
}, []);
|
||||
|
||||
const makeRow1Drop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const key = e.dataTransfer.getData("text/plain");
|
||||
if (!key) return;
|
||||
// Remove from row2 if present
|
||||
const newRow2 = row2.filter((e) => e.key !== key);
|
||||
const field = allFields.find((f) => f.key === key);
|
||||
if (!field) return;
|
||||
// Insert into row1 via drop position
|
||||
const container = e.currentTarget;
|
||||
const children = Array.from(container.children).filter((c) => (c as HTMLElement).dataset?.chipkey);
|
||||
const mouseX = e.clientX;
|
||||
let idx = children.length;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const rect = (children[i] as HTMLElement).getBoundingClientRect();
|
||||
if (mouseX < rect.left + rect.width / 2) { idx = i; break; }
|
||||
}
|
||||
const newRow1 = [...row1.filter((f) => f.key !== key)];
|
||||
newRow1.splice(idx, 0, field);
|
||||
onChange(newRow1, newRow2);
|
||||
}, [allFields, row1, row2, onChange]);
|
||||
|
||||
const makeSubtitleDrop = useCallback((underCol: string) => {
|
||||
return (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const key = e.dataTransfer.getData("text/plain");
|
||||
if (!key || key === "subject") return;
|
||||
// Remove from row1 if present
|
||||
const newRow1 = row1.filter((f) => f.key !== key);
|
||||
// Remove from row2 if present
|
||||
const newRow2 = row2.filter((e) => e.key !== key);
|
||||
// Add to row2 under this column
|
||||
newRow2.push({ key, under: underCol });
|
||||
onChange(newRow1, newRow2);
|
||||
};
|
||||
}, [row1, row2, onChange]);
|
||||
|
||||
const makePaletteDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const key = e.dataTransfer.getData("text/plain");
|
||||
if (!key) return;
|
||||
onChange(
|
||||
row1.filter((f) => f.key !== key),
|
||||
row2.filter((e) => e.key !== key),
|
||||
);
|
||||
}, [row1, row2, onChange]);
|
||||
|
||||
const renderChip = (label: string, key: string) => (
|
||||
<div
|
||||
key={key}
|
||||
data-chipkey={key}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, key)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="flex cursor-grab items-center gap-1 rounded border border-border/50 bg-card px-2 py-1 text-xs text-foreground shadow-sm transition-colors hover:border-primary/30 active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="h-3 w-3 text-muted-foreground/50" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Fields not in row1 or row2
|
||||
const usedKeys = new Set([...row1.map((f) => f.key), ...row2.map((e) => e.key)]);
|
||||
const palette = allFields.filter((f) => !usedKeys.has(f.key) && f.key !== "subject");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
||||
<div className="fixed left-1/2 top-1/2 z-[9999] w-[560px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2.5">
|
||||
<h3 className="text-sm font-semibold text-foreground">Layout builder</h3>
|
||||
<button onClick={onClose} className="rounded text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
{/* Row 1 */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Main row</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={makeRow1Drop}
|
||||
className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-dashed border-border/50 p-2 transition-colors"
|
||||
>
|
||||
{row1.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">Drop fields here</span>
|
||||
) : (
|
||||
row1.map((f) => renderChip(f.label, f.key))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — subtitle fields under specific columns */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Subtitle (drop under a column)</div>
|
||||
<div className="space-y-2">
|
||||
{row1.map((col) => {
|
||||
const entries = row2.filter((e) => e.under === col.key);
|
||||
return (
|
||||
<div key={col.key} className="flex items-start gap-2">
|
||||
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
|
||||
{col.label}
|
||||
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={makeSubtitleDrop(col.key)}
|
||||
className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1 transition-colors"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground/40">drop here</span>
|
||||
) : (
|
||||
entries.map((e) => {
|
||||
const field = allFields.find((f) => f.key === e.key);
|
||||
return renderChip(field?.label ?? e.key, e.key);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Orphans: subtitle fields under columns not in row1 */}
|
||||
{(() => {
|
||||
const orphanEntries = row2.filter((e) => !row1.some((c) => c.key === e.under));
|
||||
if (orphanEntries.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
|
||||
subject
|
||||
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
|
||||
</div>
|
||||
<div className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1">
|
||||
{orphanEntries.map((e) => {
|
||||
const field = allFields.find((f) => f.key === e.key);
|
||||
return renderChip(field?.label ?? e.key, e.key);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Available</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={makePaletteDrop}
|
||||
className="flex min-h-9 flex-wrap gap-1.5 rounded-md border border-dashed border-border/30 p-2 transition-colors"
|
||||
>
|
||||
{palette.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">All fields are placed</span>
|
||||
) : (
|
||||
palette.map((f) => renderChip(f.label, f.key))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/50 px-4 py-2.5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-7 rounded-md bg-primary px-3 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
395
web/src/components/scrip-wizard.tsx
Normal file
395
web/src/components/scrip-wizard.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { XIcon, ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Queue, CustomField, Template } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
error?: string | null;
|
||||
onCreate: (data: {
|
||||
name: string; condition_type: string; condition_config: Record<string, unknown>;
|
||||
action_type: string; action_config: Record<string, unknown>;
|
||||
template_id: string | null; queue_id: string | null;
|
||||
stage: string; sort_order: number;
|
||||
}) => Promise<void>;
|
||||
queues: Queue[];
|
||||
customFields: CustomField[];
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
const CONDITIONS = [
|
||||
{ type: "OnCreate", icon: "➕", label: "Ticket created", desc: "When a new ticket is created" },
|
||||
{ type: "OnStatusChange", icon: "🔄", label: "Status changes", desc: "When ticket status changes" },
|
||||
{ type: "OnResolve", icon: "✅", label: "Ticket resolved", desc: "When a ticket is resolved" },
|
||||
{ type: "OnCustomFieldChange", icon: "📝", label: "Custom field changes", desc: "When a field value changes" },
|
||||
{ type: "OnLinkCreate", icon: "🔗", label: "Ticket linked", desc: "When linked to another ticket" },
|
||||
{ type: "OnOverdue", icon: "⏰", label: "Ticket overdue", desc: "When a date field passes due" },
|
||||
];
|
||||
|
||||
const ACTIONS = [
|
||||
{ type: "SendEmail", icon: "📧", label: "Send email", desc: "Send a templated email notification" },
|
||||
{ type: "SetCustomField", icon: "🏷️", label: "Set custom field", desc: "Update a field on the ticket" },
|
||||
{ type: "Webhook", icon: "🌐", label: "Call webhook", desc: "POST to an external URL" },
|
||||
{ type: "FetchMetadata", icon: "📡", label: "Fetch metadata", desc: "Pull data from an API" },
|
||||
{ type: "RunScript", icon: "⚡", label: "Run script", desc: "Execute custom JavaScript" },
|
||||
];
|
||||
|
||||
export function ScripWizard({ open, onClose, onCreate, queues, customFields, templates, error: externalError }: Props) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Trigger
|
||||
const [conditionType, setConditionType] = useState("OnCreate");
|
||||
const [fromStatus, setFromStatus] = useState("");
|
||||
const [toStatus, setToStatus] = useState("");
|
||||
const [conditionFieldKey, setConditionFieldKey] = useState("");
|
||||
|
||||
// Action
|
||||
const [actionType, setActionType] = useState("SendEmail");
|
||||
const [emailTo, setEmailTo] = useState("requestor");
|
||||
const [emailRecipients, setEmailRecipients] = useState("");
|
||||
const [emailSubject, setEmailSubject] = useState("");
|
||||
const [emailBody, setEmailBody] = useState("");
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
const [fieldKey, setFieldKey] = useState("");
|
||||
const [fieldValue, setFieldValue] = useState("");
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
|
||||
// Scope
|
||||
const [name, setName] = useState("");
|
||||
const [queueId, setQueueId] = useState("");
|
||||
const [stage, setStage] = useState("TransactionCreate");
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
|
||||
let conditionConfig: Record<string, unknown> = {};
|
||||
if (conditionType === "OnStatusChange" || conditionType === "OnResolve") {
|
||||
if (fromStatus) conditionConfig.from_status = fromStatus;
|
||||
if (toStatus) conditionConfig.to_status = toStatus;
|
||||
} else if (conditionType === "OnOverdue") {
|
||||
if (conditionFieldKey) conditionConfig.field_key = conditionFieldKey;
|
||||
}
|
||||
|
||||
let actionConfig: Record<string, unknown> = {};
|
||||
if (actionType === "SendEmail") {
|
||||
const sources = emailTo === "requestor" ? ["requestor"] : emailTo === "owner" ? ["owner"] : [];
|
||||
actionConfig = {
|
||||
recipients: emailRecipients ? emailRecipients.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
||||
recipient_sources: sources,
|
||||
subject: emailSubject || "",
|
||||
body: emailBody || "",
|
||||
};
|
||||
} else if (actionType === "SetCustomField") {
|
||||
actionConfig = { field_key: fieldKey || "", value: fieldValue || "" };
|
||||
} else if (actionType === "Webhook") {
|
||||
actionConfig = { url: webhookUrl || "", method: "POST" };
|
||||
}
|
||||
|
||||
await onCreate({
|
||||
name: name || `Scrip: ${conditionType} → ${actionType}`,
|
||||
condition_type: conditionType,
|
||||
condition_config: conditionConfig,
|
||||
action_type: actionType,
|
||||
action_config: actionConfig,
|
||||
template_id: templateId || null,
|
||||
queue_id: queueId || null,
|
||||
stage,
|
||||
sort_order: 0,
|
||||
});
|
||||
setSaving(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep(1); setConditionType("OnCreate"); setFromStatus(""); setToStatus(""); setConditionFieldKey("");
|
||||
setActionType("SendEmail"); setEmailTo("requestor"); setEmailRecipients(""); setEmailSubject("");
|
||||
setEmailBody(""); setTemplateId(""); setFieldKey(""); setFieldValue(""); setWebhookUrl("");
|
||||
setName(""); setQueueId(""); setStage("TransactionCreate");
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
|
||||
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
|
||||
const dateFields = customFields.filter((cf) => cf.field_type === "date" || cf.field_type === "datetime");
|
||||
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { onClose(); reset(); }} />
|
||||
<div className="fixed left-1/2 top-1/2 z-50 w-[600px] max-h-[80vh] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-lg border border-border bg-popover shadow-xl">
|
||||
{/* Header with steps */}
|
||||
<div className="border-b border-border/50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">New automation</h2>
|
||||
<button onClick={() => { onClose(); reset(); }} className="text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{stepLabels.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold",
|
||||
step > i + 1 ? "bg-primary text-primary-foreground" :
|
||||
step === i + 1 ? "bg-primary text-primary-foreground ring-2 ring-primary/30" :
|
||||
"bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{step > i + 1 ? <CheckIcon className="h-3 w-3" /> : i + 1}
|
||||
</div>
|
||||
<span className={cn("text-[11px] font-medium", step === i + 1 ? "text-foreground" : "text-muted-foreground")}>{label}</span>
|
||||
{i < 3 && <div className="h-px w-6 bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Step 1: Trigger */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">When should this automation run?</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{CONDITIONS.map((c) => (
|
||||
<button
|
||||
key={c.type}
|
||||
type="button"
|
||||
onClick={() => { setConditionType(c.type); setFromStatus(""); setToStatus(""); setConditionFieldKey(""); }}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
conditionType === c.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{c.icon}</span>
|
||||
<div className="mt-1 text-sm font-semibold">{c.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{c.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(conditionType === "OnStatusChange" || conditionType === "OnResolve") && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px]">From status</Label>
|
||||
<Select value={fromStatus || "_any"} onValueChange={(v) => setFromStatus((v === "_any" || !v) ? "" : v)}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Any status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_any">Any status</SelectItem>
|
||||
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px]">To status</Label>
|
||||
<Select value={toStatus || "_any"} onValueChange={(v) => setToStatus((v === "_any" || !v) ? "" : v)}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder={conditionType === "OnResolve" ? "Any resolved" : "Any status"} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_any">{conditionType === "OnResolve" ? "Any resolved" : "Any status"}</SelectItem>
|
||||
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conditionType === "OnOverdue" && dateFields.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-[10px]">Date field to check</Label>
|
||||
<Select value={conditionFieldKey} onValueChange={(v) => setConditionFieldKey(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a date field..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{dateFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Action */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">What should happen when triggered?</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ACTIONS.map((a) => (
|
||||
<button
|
||||
key={a.type}
|
||||
type="button"
|
||||
onClick={() => setActionType(a.type)}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
actionType === a.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{a.icon}</span>
|
||||
<div className="mt-1 text-sm font-semibold">{a.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{a.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Configure */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Configure the details.</p>
|
||||
|
||||
{actionType === "SendEmail" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Recipients</Label>
|
||||
<Select value={emailTo} onValueChange={(v) => setEmailTo(v ?? "requestor")}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="requestor">Ticket requestor (creator)</SelectItem>
|
||||
<SelectItem value="owner">Ticket owner</SelectItem>
|
||||
<SelectItem value="manual">Custom recipients</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{emailTo === "manual" && (
|
||||
<div>
|
||||
<Label>Email addresses (comma-separated)</Label>
|
||||
<Input placeholder="user@example.com, other@example.com" value={emailRecipients} onChange={(e) => setEmailRecipients(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Subject</Label>
|
||||
<Input placeholder="Ticket #{{ticket.id}}: {{ticket.subject}}" value={emailSubject} onChange={(e) => setEmailSubject(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Body</Label>
|
||||
<Textarea rows={3} placeholder="The ticket has been updated..." value={emailBody} onChange={(e) => setEmailBody(e.target.value)} />
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">Variables: {"{{ticket.id}} {{ticket.subject}} {{ticket.status}} {{queue.name}} {{transaction.old_value}} {{transaction.new_value}}"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Template (optional)</Label>
|
||||
<Select value={templateId} onValueChange={(v) => setTemplateId(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="No template — use subject/body above" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No template</SelectItem>
|
||||
{emailTemplates.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === "SetCustomField" && (() => {
|
||||
const selectedField = customFields.find((cf) => cf.key === fieldKey);
|
||||
const fieldOptions: string[] = Array.isArray(selectedField?.values) ? selectedField.values.map((v: any) => String(v)) : [];
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>Field</Label>
|
||||
<Select value={fieldKey} onValueChange={(v) => { setFieldKey((v && v !== "_any") ? v : ""); setFieldValue(""); }}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a field" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label>Value</Label>
|
||||
{fieldOptions.length > 0 ? (
|
||||
<Select value={fieldValue} onValueChange={(v) => setFieldValue(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a value" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input placeholder="Value to set" value={fieldValue} onChange={(e) => setFieldValue(e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{actionType === "Webhook" && (
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Input placeholder="https://hooks.slack.com/..." value={webhookUrl} onChange={(e) => setWebhookUrl(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/30 pt-3 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>Name (optional)</Label>
|
||||
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label>Queue scope</Label>
|
||||
<Select value={queueId} onValueChange={(v) => setQueueId(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="All queues" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All queues (global)</SelectItem>
|
||||
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Review */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm space-y-2">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground/60">Summary</div>
|
||||
<p><strong>When:</strong> {CONDITIONS.find((c) => c.type === conditionType)?.label}</p>
|
||||
<p><strong>Then:</strong> {ACTIONS.find((a) => a.type === actionType)?.label}</p>
|
||||
{queueId && <p><strong>Queue:</strong> {selectedQueue?.name}</p>}
|
||||
<p><strong>Stage:</strong> {stage === "TransactionCreate" ? "Per transaction" : "After batch"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{externalError && (
|
||||
<div className="border-t border-destructive/20 bg-destructive/10 px-6 py-2 text-sm text-destructive">{externalError}</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between border-t border-border/50 px-6 py-3">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setStep(step - 1)}>
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 mr-1" /> Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{step < 4 ? (
|
||||
<Button size="sm" onClick={() => setStep(step + 1)}>
|
||||
Next <ArrowRightIcon className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="bg-primary" disabled={saving} onClick={handleCreate}>
|
||||
<CheckIcon className="h-3.5 w-3.5 mr-1" />
|
||||
{saving ? "Creating..." : "Create automation"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
web/src/components/searchable-select.tsx
Normal file
163
web/src/components/searchable-select.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
options: SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
allowClear?: boolean;
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
disabled = false,
|
||||
className,
|
||||
allowClear = true,
|
||||
clearLabel = "None",
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightIdx, setHighlightIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = search.trim()
|
||||
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: options;
|
||||
|
||||
// Reset highlight when search changes
|
||||
useEffect(() => {
|
||||
setHighlightIdx(0);
|
||||
}, [search]);
|
||||
|
||||
// Focus search input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
setSearch("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const select = useCallback((optValue: string) => {
|
||||
onChange(optValue);
|
||||
setOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIdx((prev) => Math.min(prev + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIdx((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered[highlightIdx]) {
|
||||
select(filtered[highlightIdx].value);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setOpen(!open)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2.5 text-sm outline-none transition-colors",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "hover:border-ring/50 focus:border-ring",
|
||||
!selected && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||
<ChevronDownIcon className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-lg">
|
||||
<div className="flex items-center gap-1.5 border-b border-border/50 px-2">
|
||||
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} className="shrink-0 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
{allowClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select("")}
|
||||
className="flex w-full items-center gap-2 border-b border-border/30 px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2.5 py-3 text-center text-xs text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((opt, idx) => (
|
||||
<button
|
||||
key={opt.value || "__empty__"}
|
||||
type="button"
|
||||
onClick={() => select(opt.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center px-2.5 py-1.5 text-xs transition-colors",
|
||||
idx === highlightIdx ? "bg-accent text-foreground" : "text-foreground hover:bg-accent/50",
|
||||
opt.value === value && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
web/src/components/widgets/trend-chart-widget.tsx
Normal file
43
web/src/components/widgets/trend-chart-widget.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { WidgetData } from "@/lib/types";
|
||||
|
||||
export function TrendChartWidget({ data }: { data: WidgetData }) {
|
||||
const points = data.counts ?? {};
|
||||
const entries = Object.entries(points).sort(([a], [b]) => a.localeCompare(b));
|
||||
const maxVal = Math.max(1, ...Object.values(points));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-lg border border-border/50 bg-card p-3">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase text-muted-foreground/60">
|
||||
{data.title}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground tabular-nums">{data.total}</div>
|
||||
<div className="mt-2 flex flex-1 items-end gap-px">
|
||||
{entries.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">No data</span>
|
||||
) : (
|
||||
entries.map(([label, count]) => {
|
||||
const h = Math.max(4, (count / maxVal) * 100);
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-1 flex-col items-center justify-end"
|
||||
title={`${label}: ${count}`}
|
||||
>
|
||||
<div className="text-[9px] tabular-nums text-muted-foreground">{count}</div>
|
||||
<div
|
||||
className="w-full min-w-[3px] rounded-t bg-primary/60"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] text-muted-foreground/50 text-right">
|
||||
{entries.length > 0 && `${entries[0]?.[0]} — ${entries[entries.length - 1]?.[0]}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,15 +17,30 @@ import type {
|
||||
QueueCustomField,
|
||||
PreviewResult,
|
||||
UpdateResult,
|
||||
Attachment,
|
||||
AttachmentUploadResult,
|
||||
TicketLink,
|
||||
LoginResult,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = "/api";
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
// Merge with options headers if any
|
||||
const opts = { ...options };
|
||||
if (opts.headers) {
|
||||
Object.assign(headers, opts.headers as Record<string, string>);
|
||||
delete opts.headers;
|
||||
}
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
headers,
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
@@ -45,6 +60,9 @@ export async function getTickets(params?: {
|
||||
owner_id?: string;
|
||||
team_id?: string;
|
||||
custom_fields?: Record<string, string>;
|
||||
subject?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
||||
@@ -52,6 +70,9 @@ export async function getTickets(params?: {
|
||||
if (params?.q) sp.set("q", params.q);
|
||||
if (params?.owner_id) sp.set("owner_id", params.owner_id);
|
||||
if (params?.team_id) sp.set("team_id", params.team_id);
|
||||
if (params?.subject) sp.set("subject", params.subject);
|
||||
if (params?.created) sp.set("created", params.created);
|
||||
if (params?.updated) sp.set("updated", params.updated);
|
||||
if (params?.custom_fields) {
|
||||
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
||||
if (value) sp.set(`cf.${fieldId}`, value);
|
||||
@@ -86,10 +107,80 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
|
||||
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
||||
}
|
||||
|
||||
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||
export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function batchUpdateTickets(data: {
|
||||
ticket_ids: number[];
|
||||
status?: string;
|
||||
owner_id?: string | null;
|
||||
team_id?: string | null;
|
||||
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
|
||||
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
|
||||
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
|
||||
}
|
||||
|
||||
// Notifications
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: number | null;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
|
||||
return request<Notification[]>("/notifications");
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
|
||||
return request<{ count: number }>("/notifications/unread-count");
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
|
||||
}
|
||||
|
||||
// API Tokens
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
|
||||
return request<ApiToken[]>("/auth/tokens");
|
||||
}
|
||||
|
||||
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
|
||||
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
||||
return request<Queue[]>("/queues");
|
||||
}
|
||||
@@ -101,6 +192,8 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
|
||||
export async function createUser(data: {
|
||||
username: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
password?: string | null;
|
||||
}): Promise<{ data: User | null; error: string | null }> {
|
||||
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
@@ -108,6 +201,8 @@ export async function createUser(data: {
|
||||
export async function updateUser(id: string, data: {
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
password?: string | null;
|
||||
}): Promise<{ data: User | null; error: string | null }> {
|
||||
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
@@ -268,7 +363,7 @@ export async function createView(data: {
|
||||
name: string;
|
||||
filters: { field: string; operator: string; value: string }[];
|
||||
sort_key?: string;
|
||||
columns?: { key: string; label: string; width: number; visible: boolean }[];
|
||||
columns?: { key: string; label: string; width: number; display: string }[];
|
||||
is_public?: boolean;
|
||||
}): Promise<{ data: SavedView | null; error: string | null }> {
|
||||
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
|
||||
@@ -373,3 +468,207 @@ export async function addTeamMember(teamId: string, userId: string): Promise<{ d
|
||||
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function uploadAttachments(
|
||||
ticketId: number,
|
||||
files: File[],
|
||||
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getAttachmentUrl(attachmentId: string): string {
|
||||
return `/api/attachments/${attachmentId}`;
|
||||
}
|
||||
|
||||
export async function getTicketAttachments(
|
||||
ticketId: number,
|
||||
): Promise<{ data: Attachment[] | null; error: string | null }> {
|
||||
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
|
||||
}
|
||||
|
||||
export async function getTicketLinks(
|
||||
ticketId: number,
|
||||
): Promise<{ data: TicketLink[] | null; error: string | null }> {
|
||||
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
|
||||
}
|
||||
|
||||
export async function createTicketLink(
|
||||
ticketId: number,
|
||||
data: { target_ticket_id: number; link_type: string },
|
||||
): Promise<{ data: TicketLink | null; error: string | null }> {
|
||||
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteTicketLink(
|
||||
ticketId: number,
|
||||
linkId: string,
|
||||
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Queue Permissions (admin)
|
||||
|
||||
export interface QueuePermission {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
team_id: string;
|
||||
right_name: string;
|
||||
team_name?: string;
|
||||
queue_name?: string;
|
||||
}
|
||||
|
||||
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
|
||||
return request<QueuePermission[]>("/queue-permissions");
|
||||
}
|
||||
|
||||
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
|
||||
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
|
||||
}
|
||||
|
||||
export async function grantQueuePermission(
|
||||
queue_id: string,
|
||||
team_id: string,
|
||||
right_name: string,
|
||||
): Promise<{ data: QueuePermission | null; error: string | null }> {
|
||||
return request<QueuePermission>("/queue-permissions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queue_id, team_id, right_name }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// User Permissions (admin)
|
||||
|
||||
export interface UserPermission {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
user_id: string;
|
||||
right_name: string;
|
||||
username?: string;
|
||||
queue_name?: string;
|
||||
}
|
||||
|
||||
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
|
||||
return request<UserPermission[]>("/user-permissions");
|
||||
}
|
||||
|
||||
export async function grantUserPermission(
|
||||
queue_id: string,
|
||||
user_id: string,
|
||||
right_name: string,
|
||||
): Promise<{ data: UserPermission | null; error: string | null }> {
|
||||
return request<UserPermission>("/user-permissions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queue_id, user_id, right_name }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Auth
|
||||
|
||||
function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem("tessera_token");
|
||||
}
|
||||
|
||||
export function setStoredToken(token: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (token) {
|
||||
localStorage.setItem("tessera_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("tessera_token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ data: LoginResult | null; error: string | null }> {
|
||||
const result = await request<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (result.data?.token) {
|
||||
setStoredToken(result.data.token);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
setStoredToken(null);
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
|
||||
const token = getStoredToken();
|
||||
if (!token) return { data: null, error: "Not authenticated" };
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setStoredToken(null);
|
||||
return { data: null, error: "Session expired" };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that includes the auth token.
|
||||
*/
|
||||
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setStoredToken(null);
|
||||
}
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
61
web/src/lib/auth-context.tsx
Normal file
61
web/src/lib/auth-context.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { login as apiLogin, logout as apiLogout, getMe } from "./api";
|
||||
import type { User, LoginResult } from "./types";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<string | null>;
|
||||
logout: () => void;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: async () => null,
|
||||
logout: () => {},
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Check existing session on mount
|
||||
useEffect(() => {
|
||||
void Promise.resolve().then(async () => {
|
||||
const { data } = await getMe();
|
||||
if (data) {
|
||||
setUser(data);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string): Promise<string | null> => {
|
||||
const { data, error } = await apiLogin(username, password);
|
||||
if (error || !data) {
|
||||
return error || "Login failed";
|
||||
}
|
||||
setUser(data.user);
|
||||
return null; // null = success
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
apiLogout();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin: user?.role === "admin" }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface Ticket {
|
||||
started_at: string | null;
|
||||
resolved_at: string | null;
|
||||
custom_fields?: CustomFieldValue[];
|
||||
blocked_by?: Array<{ id: number; subject: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface Queue {
|
||||
@@ -25,9 +26,15 @@ export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
@@ -36,8 +43,10 @@ export interface Transaction {
|
||||
old_value: string | null;
|
||||
new_value: string | null;
|
||||
data: unknown;
|
||||
time_worked_minutes: number;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface Scrip {
|
||||
@@ -53,6 +62,7 @@ export interface Scrip {
|
||||
stage: string;
|
||||
sort_order: number;
|
||||
disabled: boolean;
|
||||
applicable_trans_types: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -197,4 +207,50 @@ export interface WidgetData {
|
||||
counts?: Record<string, number>;
|
||||
groups?: Record<string, number>;
|
||||
group_by?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
transaction_id: string | null;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_path: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AttachmentUploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface TicketLink {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
target_ticket_id: number;
|
||||
link_type: string;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
target_ticket?: { id: number; subject: string; status: string } | null;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: number | null;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user