feat: add dashboards — tables, CRUD API, widget data endpoint

- New dashboards table (name, description, layout, is_default)
- New dashboard_widgets table (view_id, title, widget_type, position, config)
- GET/POST/PATCH/DELETE /dashboards
- GET/POST/PATCH/DELETE /dashboards/:id/widgets
- GET /dashboards/:id/widgets/:id/data — runs saved view filters,
  returns pre-aggregated data for count/ticket_list/status_chart/grouped_counts
- is_default uniqueness enforced on PATCH

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 11:26:22 +02:00
parent aa90b88991
commit b70a133ea2
15 changed files with 2349 additions and 90 deletions

View File

@@ -0,0 +1,319 @@
"use client";
import { useState, useEffect, use, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
PlusIcon,
Trash2Icon,
RefreshCwIcon,
GaugeIcon,
LayoutGridIcon,
} from "lucide-react";
import {
getDashboard,
createWidget,
deleteWidget,
getWidgetData,
getViews,
} from "@/lib/api";
import type {
Dashboard,
DashboardWidget,
SavedView,
WidgetData,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 { cn } from "@/lib/utils";
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
return {
gridColumn: `${position.x + 1} / span ${position.w}`,
gridRow: `${position.y + 1} / span ${position.h}`,
};
}
export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]);
const [views, setViews] = useState<SavedView[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add widget dialog
const [addOpen, setAddOpen] = useState(false);
const [addViewId, setAddViewId] = useState("");
const [addTitle, setAddTitle] = useState("");
const [addType, setAddType] = useState("count");
const [addGroupBy, setAddGroupBy] = useState("owner");
const [adding, setAdding] = useState(false);
const fetchDashboard = useCallback(async () => {
const { data, error } = await getDashboard(id);
if (error || !data) {
setError(error ?? "Dashboard not found");
setLoading(false);
return;
}
setDashboard(data);
const widgetList = data.widgets ?? [];
setWidgets(widgetList);
// Fetch data for each widget
for (const widget of widgetList) {
const { data: wData } = await getWidgetData(id, widget.id);
if (wData) {
setWidgets((prev) =>
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
);
}
}
setLoading(false);
}, [id]);
useEffect(() => {
fetchDashboard();
getViews().then(({ data }) => {
if (data) setViews(data);
});
}, [fetchDashboard]);
const handleAddWidget = async () => {
if (!addViewId || !addTitle.trim()) return;
setAdding(true);
const pos = { x: 0, y: widgets.length, w: 4, h: 2 };
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
const { data, error } = await createWidget(id, {
view_id: addViewId,
title: addTitle.trim(),
widget_type: addType,
position: pos,
config,
});
if (!error && data) {
setWidgets((prev) => [...prev, data]);
const { data: wData } = await getWidgetData(id, data.id);
if (wData) {
setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w)));
}
setAddOpen(false);
setAddViewId("");
setAddTitle("");
setAddType("count");
}
setAdding(false);
};
const handleDeleteWidget = async (widgetId: string) => {
await deleteWidget(id, widgetId);
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
};
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
if (!widget.data) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
switch (widget.data.type) {
case "count":
return <CountWidget data={widget.data} />;
case "ticket_list":
return <TicketListWidget data={widget.data} />;
case "status_chart":
return <StatusChartWidget data={widget.data} />;
case "grouped_counts":
return <GroupedCountsWidget data={widget.data} />;
default:
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">Unknown type: {widget.data.type}</p>
</div>
);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (error || !dashboard) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<p className="text-sm text-muted-foreground">{error ?? "Dashboard not found"}</p>
<Link href="/" className="text-sm text-primary hover:underline">
Go to ticket list
</Link>
</div>
);
}
return (
<div className="flex h-full flex-col bg-background/80">
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
<div className="flex items-center justify-between px-5 py-3 lg:px-6">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
<LayoutGridIcon className="h-3.5 w-3.5" />
Dashboard
</div>
<h1 className="mt-1 text-xl font-semibold text-foreground">{dashboard.name}</h1>
{dashboard.description && (
<p className="mt-0.5 text-sm text-muted-foreground">{dashboard.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchDashboard}
className="h-8 border-border/80 bg-card/70"
>
<RefreshCwIcon className="h-4 w-4" />
Refresh
</Button>
<Button size="sm" onClick={() => setAddOpen(true)} className="h-8 bg-primary shadow-sm">
<PlusIcon className="h-4 w-4" />
Add widget
</Button>
</div>
</div>
</header>
<div className="flex-1 overflow-auto p-5 lg:p-6">
{widgets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<LayoutGridIcon className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No widgets yet</p>
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<PlusIcon className="h-4 w-4" />
Add your first widget
</Button>
</div>
) : (
<div className="grid auto-rows-[minmax(120px,auto)] grid-cols-12 gap-4">
{widgets.map((widget) => (
<div
key={widget.id}
className="group relative"
style={widgetGridStyle(widget.position)}
>
{renderWidget(widget)}
<button
type="button"
onClick={() => handleDeleteWidget(widget.id)}
className="absolute right-2 top-2 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
title="Remove widget"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add widget</DialogTitle>
<DialogDescription>
Choose a saved view and widget type to add to this dashboard.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Widget title</label>
<input
value={addTitle}
onChange={(e) => setAddTitle(e.target.value)}
placeholder="e.g. Open tickets"
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
/>
</div>
<div>
<label className="text-sm font-medium">Saved view</label>
<select
value={addViewId}
onChange={(e) => {
setAddViewId(e.target.value);
const view = views.find((v) => v.id === e.target.value);
if (view && !addTitle) setAddTitle(view.name);
}}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="">Select a view...</option>
{views.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">Widget type</label>
<select
value={addType}
onChange={(e) => setAddType(e.target.value)}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="count">Count (big number)</option>
<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>
</select>
</div>
{addType === "grouped_counts" && (
<div>
<label className="text-sm font-medium">Group by</label>
<select
value={addGroupBy}
onChange={(e) => setAddGroupBy(e.target.value)}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="owner">Owner</option>
<option value="queue">Queue</option>
</select>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button
size="sm"
disabled={!addViewId || !addTitle.trim() || adding}
onClick={handleAddWidget}
>
{adding ? "Adding..." : "Add widget"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -16,7 +16,7 @@ import {
XIcon,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView } from "@/lib/api";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards } from "@/lib/api";
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
@@ -158,6 +158,7 @@ function TicketWorkbenchContent() {
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false);
const [saveViewName, setSaveViewName] = useState("");
const [addFilterOpen, setAddFilterOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [newSubject, setNewSubject] = useState("");
@@ -253,6 +254,15 @@ function TicketWorkbenchContent() {
void Promise.resolve().then(() => fetchData());
}, [fetchData]);
// Redirect to default dashboard if one exists and no params set
useEffect(() => {
if (searchParams.toString()) return;
getDashboards().then(({ data }) => {
const def = data?.find((d) => d.is_default);
if (def) router.replace(`/dashboards/${def.id}`);
});
}, [searchParams]);
useEffect(() => {
if (searchParams.get("new") === "true") {
queueMicrotask(() => setDialogOpen(true));
@@ -315,6 +325,7 @@ function TicketWorkbenchContent() {
getViews().then(({ data }) => {
const view = data?.find((v) => v.id === paramViewId);
if (view?.filters && Array.isArray(view.filters)) {
setSearchQuery("");
setFilters(
(view.filters as { field: string; operator: string; value: string }[])
.filter((f) => f.field && f.value)
@@ -329,6 +340,10 @@ function TicketWorkbenchContent() {
if (view.sort_key) setSortKey(view.sort_key as SortKey);
}
});
} else if (!paramViewId && viewIdFromUrl) {
// User navigated away from a view — clear filters
setFilters([]);
setSearchQuery("");
}
}, [searchParams]);
@@ -641,98 +656,112 @@ function TicketWorkbenchContent() {
<div className="relative">
<button
type="button"
onClick={() => {
const el = document.getElementById("add-filter-select");
el?.focus();
}}
onClick={() => setAddFilterOpen((prev) => !prev)}
className="inline-flex h-7 items-center gap-1 rounded border border-dashed border-border px-2 text-xs font-medium text-muted-foreground hover:border-ring hover:text-foreground transition-colors"
>
<PlusIcon className="h-3 w-3" />
Add filter
</button>
<select
id="add-filter-select"
value=""
onChange={(event) => {
const value = event.target.value;
if (!value) return;
const [fieldType, fieldKey] = value.split(":");
const existing = filters.find((f) => f.field === (fieldType === "cf" ? `cf.${fieldKey}` : fieldType));
if (existing) return;
if (fieldType === "queue") {
const q = queues.find((x) => x.id === fieldKey);
if (q) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "queue",
operator: "is",
value: fieldKey,
label: buildFilterLabel("queue", "is", q.name),
}]);
}
} else if (fieldType === "owner") {
if (fieldKey === "unassigned") {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "owner",
operator: "is",
value: "unassigned",
label: buildFilterLabel("owner", "is", "Unassigned"),
}]);
} else {
const u = users.find((x) => x.id === fieldKey);
if (u) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "owner",
operator: "is",
value: fieldKey,
label: buildFilterLabel("owner", "is", u.username),
}]);
}
}
} else if (fieldType === "cf") {
const cf = customFields.find((x) => x.key === fieldKey);
if (cf) {
const cfFilter: Filter = {
id: crypto.randomUUID(),
field: `cf.${fieldKey}`,
operator: "is",
value: "",
label: `${cf.name} is ...`,
};
setFilters((prev) => [...prev, cfFilter]);
// Focus value input after adding
setTimeout(() => {
const input = document.getElementById(`cf-value-${cfFilter.id}`) as HTMLInputElement;
input?.focus();
}, 50);
}
}
event.target.value = "";
}}
className="sr-only"
aria-label="Add filter"
>
<option value="">Add filter...</option>
<optgroup label="Queue">
{queues.map((q) => (
<option key={`q:${q.id}`} value={`queue:${q.id}`}>{q.name}</option>
))}
</optgroup>
<optgroup label="Owner">
<option value="owner:unassigned">Unassigned</option>
{users.map((u) => (
<option key={`o:${u.id}`} value={`owner:${u.id}`}>{u.username}</option>
))}
</optgroup>
<optgroup label="Custom field">
{customFields.map((cf) => (
<option key={`cf:${cf.id}`} value={`cf:${cf.key}`}>{cf.name}</option>
))}
</optgroup>
</select>
{addFilterOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setAddFilterOpen(false)} />
<div className="absolute left-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-card p-1 shadow-lg">
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Queue</div>
{queues.map((q) => (
<button
key={`q:${q.id}`}
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")) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "queue",
operator: "is",
value: q.id,
label: buildFilterLabel("queue", "is", q.name),
}]);
}
setAddFilterOpen(false);
}}
>
{q.name}
</button>
))}
<div className="mt-0.5 border-t border-border pt-0.5" />
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Owner</div>
<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")) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "owner",
operator: "is",
value: "unassigned",
label: buildFilterLabel("owner", "is", "Unassigned"),
}]);
}
setAddFilterOpen(false);
}}
>
Unassigned
</button>
{users.map((u) => (
<button
key={`o:${u.id}`}
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")) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "owner",
operator: "is",
value: u.id,
label: buildFilterLabel("owner", "is", u.username),
}]);
}
setAddFilterOpen(false);
}}
>
{u.username}
</button>
))}
{customFields.length > 0 && (
<>
<div className="mt-0.5 border-t border-border pt-0.5" />
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Custom field</div>
{customFields.map((cf) => (
<button
key={`cf:${cf.id}`}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
const cfFilter: Filter = {
id: crypto.randomUUID(),
field: `cf.${cf.key}`,
operator: "is",
value: "",
label: `${cf.name} is ...`,
};
setFilters((prev) => [...prev, cfFilter]);
setAddFilterOpen(false);
setTimeout(() => {
const input = document.getElementById(`cf-value-${cfFilter.id}`) as HTMLInputElement;
input?.focus();
}, 50);
}}
>
{cf.name}
</button>
))}
</>
)}
</div>
</>
)}
</div>
</div>

View File

@@ -13,8 +13,8 @@ import {
PanelLeftIcon,
CommandIcon,
} from "lucide-react";
import { getTickets, getQueues, getViews } from "@/lib/api";
import type { Queue, SavedView } from "@/lib/types";
import { getTickets, getQueues, getViews, getDashboards } from "@/lib/api";
import type { Dashboard, Queue, SavedView } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
@@ -87,6 +87,7 @@ function SidebarNav() {
});
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
useEffect(() => {
getTickets().then(({ data }) => {
@@ -120,6 +121,10 @@ function SidebarNav() {
getViews().then(({ data }) => {
if (data) setSavedViews(data);
});
getDashboards().then(({ data }) => {
if (data) setDashboards(data);
});
}, []);
const collapsed = useSidebarCollapsed();
@@ -204,6 +209,29 @@ function SidebarNav() {
</div>
)}
{dashboards.length > 0 && (
<div className="mt-4">
{!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
Dashboards
</div>
)}
{dashboards.map((dash) => {
const active =
pathname.startsWith("/dashboards/") && pathname.endsWith(dash.id);
return (
<SidebarNavItem
key={dash.id}
href={`/dashboards/${dash.id}`}
icon={LayoutGridIcon}
label={dash.name}
active={active}
/>
);
})}
</div>
)}
{savedViews.length > 0 && (
<div className="mt-4">
{!collapsed && (

View File

@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
import type { WidgetData } from "@/lib/types";
export function CountWidget({ data }: { data: WidgetData }) {
const params = new URLSearchParams();
if (data.view_id) params.set("view_id", data.view_id);
return (
<Link
href={`/?${params.toString()}`}
className="flex h-full flex-col items-center justify-center rounded-lg border border-border bg-card p-4 transition-colors hover:border-ring/50 hover:bg-accent/30"
>
<span className="text-3xl font-bold tabular-nums text-foreground">
{data.total}
</span>
<span className="mt-1 text-sm text-muted-foreground">{data.title}</span>
</Link>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import type { WidgetData } from "@/lib/types";
export function GroupedCountsWidget({ data }: { data: WidgetData }) {
const groups = data.groups ?? {};
const entries = Object.entries(groups).sort(([, a], [, b]) => b - a);
const max = entries.length > 0 ? Math.max(...entries.map(([, c]) => c)) : 1;
if (entries.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">No data</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
</div>
<div className="flex-1 space-y-1.5 overflow-auto p-3">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 truncate text-foreground">{label}</span>
<div className="flex-1">
<div
className="h-2.5 rounded-sm bg-primary/60 transition-all"
style={{ width: `${Math.round((count / max) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right tabular-nums text-muted-foreground">{count}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import type { WidgetData } from "@/lib/types";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
function statusLabel(status: string) {
return status.replaceAll("_", " ");
}
export function StatusChartWidget({ data }: { data: WidgetData }) {
const counts = data.counts ?? {};
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
if (entries.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">No data</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
</div>
<div className="flex flex-1 items-center gap-4 p-4">
{/* Donut */}
<svg viewBox="0 0 40 40" className="h-16 w-16 shrink-0">
{entries.map(([, count], index) => {
const total = entries.reduce((sum, [, c]) => sum + c, 0);
const offset = entries
.slice(0, index)
.reduce((sum, [, c]) => sum + (c / total) * 100, 0);
const pct = (count / total) * 100;
const circumference = 2 * Math.PI * 15;
const dash = (pct / 100) * circumference;
const color = STATUS_COLORS[entries[index][0]] ?? "#71717a";
return (
<circle
key={entries[index][0]}
cx="20" cy="20" r="15"
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-(offset / 100) * circumference}
transform="rotate(-90 20 20)"
/>
);
})}
</svg>
{/* Legend */}
<div className="min-w-0 flex-1 space-y-1.5">
{entries.map(([status, count]) => (
<div key={status} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: STATUS_COLORS[status] ?? "#71717a" }}
/>
<span className="flex-1 capitalize text-foreground">{statusLabel(status)}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { CircleIcon } from "lucide-react";
import type { WidgetData } from "@/lib/types";
import { cn, formatTicketId } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
function statusLabel(status: string) {
return status.replaceAll("_", " ");
}
export function TicketListWidget({ data }: { data: WidgetData }) {
const tickets = data.tickets ?? [];
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
<span className="text-[11px] tabular-nums text-muted-foreground">{data.total}</span>
</div>
<div className="flex-1 overflow-auto">
{tickets.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">No tickets</p>
) : (
tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="flex items-center gap-2 border-b border-border/50 px-3 py-2 text-xs transition-colors hover:bg-accent/40 last:border-b-0"
>
<CircleIcon
className="h-2 w-2 shrink-0"
style={{ color: STATUS_COLORS[ticket.status] ?? "#71717a", fill: STATUS_COLORS[ticket.status] ?? "#71717a" }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{ticket.subject}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{ticket.owner_name ?? "unassigned"}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground/60">
{formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
</span>
</Link>
))
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,9 @@
import type {
Ticket,
Queue,
Dashboard,
DashboardWidget,
WidgetData,
User,
Transaction,
SavedView,
@@ -259,3 +262,54 @@ export async function updateView(id: string, data: {
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
}
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
return request<Dashboard[]>("/dashboards");
}
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`);
}
export async function createDashboard(data: {
name: string;
description?: string;
is_default?: boolean;
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
}
export async function updateDashboard(id: string, data: {
name?: string;
description?: string | null;
is_default?: boolean;
layout?: unknown[];
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
}
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
}
export async function createWidget(dashboardId: string, data: {
view_id: string;
title: string;
widget_type: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
}
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
}
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
}

View File

@@ -145,3 +145,45 @@ export interface SavedView {
creator_id: string | null;
created_at: string;
}
export interface Dashboard {
id: string;
name: string;
description: string | null;
layout: unknown[];
is_default: boolean;
created_at: string;
widgets?: DashboardWidget[];
}
export interface DashboardWidget {
id: string;
dashboard_id: string;
view_id: string;
title: string;
widget_type: string;
position: { x: number; y: number; w: number; h: number };
config: Record<string, unknown>;
created_at: string;
}
export interface WidgetTicket {
id: number;
subject: string;
status: string;
owner_id: string | null;
owner_name: string | null;
queue_name: string;
updated_at: string;
}
export interface WidgetData {
type: string;
title: string;
total: number;
view_id: string;
tickets?: WidgetTicket[];
counts?: Record<string, number>;
groups?: Record<string, number>;
group_by?: string;
}