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:
319
web/src/app/dashboards/[id]/page.tsx
Normal file
319
web/src/app/dashboards/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user