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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user