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:
Gjermund Høsøien Wiggen
2026-06-15 20:42:17 +02:00
parent 1d4dc38d06
commit 70f0924d4b
59 changed files with 21795 additions and 321 deletions

View File

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