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

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