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