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

@@ -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>
);
}

View File

@@ -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" && (

View File

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

View 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>
);
}

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>

View File

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

View File

@@ -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 */}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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" };
}
}

View 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);
}

View File

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