The onUp handler was capturing stale widgets from the render closure, overwriting the resize dimensions. Now uses setWidgets(current => ...) to read latest state and apply overlap resolution correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
549 lines
20 KiB
TypeScript
549 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, use, useCallback, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import {
|
|
GripIcon,
|
|
PencilIcon,
|
|
PlusIcon,
|
|
Trash2Icon,
|
|
RefreshCwIcon,
|
|
LayoutGridIcon,
|
|
} from "lucide-react";
|
|
import {
|
|
getDashboard,
|
|
createWidget,
|
|
deleteWidget,
|
|
updateWidget,
|
|
getWidgetData,
|
|
getViews,
|
|
getTeams,
|
|
updateDashboard,
|
|
} from "@/lib/api";
|
|
import type {
|
|
Dashboard,
|
|
DashboardWidget,
|
|
SavedView,
|
|
Team,
|
|
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);
|
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
|
const [editMode, setEditMode] = useState(false);
|
|
const [teams, setTeams] = useState<Team[]>([]);
|
|
|
|
// 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); });
|
|
getTeams().then(({ data }) => { if (data) setTeams(data); });
|
|
}, [fetchDashboard]);
|
|
|
|
// Auto-refresh: only refresh widget data, not structure
|
|
useEffect(() => {
|
|
if (!autoRefresh || !dashboard) return;
|
|
const interval = setInterval(() => {
|
|
for (const widget of widgets) {
|
|
getWidgetData(dashboard.id, widget.id).then(({ data: wData }) => {
|
|
if (wData) {
|
|
setWidgets((prev) =>
|
|
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}, 30_000);
|
|
return () => clearInterval(interval);
|
|
}, [autoRefresh, dashboard?.id]);
|
|
|
|
const handleAddWidget = async () => {
|
|
if (!addViewId || !addTitle.trim()) return;
|
|
setAdding(true);
|
|
// Smart positioning: fill a 3-column grid (4 units each in 12-col grid)
|
|
const COLS = 3; const W = 4; const H = 2;
|
|
const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`));
|
|
let x = 0; let y = 0;
|
|
while (occupied.has(`${x},${y}`)) {
|
|
x += W;
|
|
if (x >= COLS * W) { x = 0; y += H; }
|
|
}
|
|
const pos = { x, y, w: W, h: H };
|
|
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 gridRef = useRef<HTMLDivElement>(null);
|
|
const [resizingId, setResizingId] = useState<string | null>(null);
|
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
|
|
// Resize: track mousedown → mousemove → mouseup
|
|
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setResizingId(widgetId);
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
const widget = widgets.find((w) => w.id === widgetId);
|
|
if (!widget || !gridRef.current) return;
|
|
|
|
const startW = widget.position.w;
|
|
const startH = widget.position.h;
|
|
const gridWidth = gridRef.current.offsetWidth;
|
|
const unitSize = gridWidth / 12;
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const dx = Math.round((ev.clientX - startX) / unitSize);
|
|
const dy = Math.round((ev.clientY - startY) / unitSize);
|
|
const newW = Math.max(1, Math.min(12, startW + dx));
|
|
const newH = Math.max(1, startH + dy);
|
|
setWidgets((prev) =>
|
|
prev.map((w) =>
|
|
w.id === widgetId
|
|
? { ...w, position: { ...w.position, w: newW, h: newH } }
|
|
: w
|
|
)
|
|
);
|
|
};
|
|
|
|
const onUp = () => {
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
setResizingId(null);
|
|
|
|
// Resolve overlaps using latest state via functional updater
|
|
setWidgets((current) => {
|
|
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
|
|
|
|
for (let pass = 0; pass < 10; pass++) {
|
|
let hasOverlap = false;
|
|
for (let i = 0; i < resolved.length; i++) {
|
|
for (let j = i + 1; j < resolved.length; j++) {
|
|
const a = resolved[i].position;
|
|
const b = resolved[j].position;
|
|
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
|
|
hasOverlap = true;
|
|
const toMove = widgetId === resolved[i].id ? j : i;
|
|
const fixedW = resolved[widgetId === resolved[i].id ? i : j];
|
|
resolved[toMove] = {
|
|
...resolved[toMove],
|
|
position: { ...resolved[toMove].position, y: fixedW.position.y + fixedW.position.h },
|
|
};
|
|
}
|
|
}
|
|
}
|
|
if (!hasOverlap) break;
|
|
}
|
|
|
|
// Persist changed positions
|
|
for (const w of resolved) {
|
|
const orig = current.find((o) => o.id === w.id);
|
|
if (orig && (orig.position.y !== w.position.y || orig.position.h !== w.position.h)) {
|
|
updateWidget(id, w.id, { position: w.position });
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
});
|
|
};
|
|
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
// Drag: HTML5 DnD
|
|
const handleDragStart = (e: React.DragEvent, widgetId: string) => {
|
|
setDraggingId(widgetId);
|
|
e.dataTransfer.setData("text/plain", widgetId);
|
|
e.dataTransfer.effectAllowed = "move";
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
};
|
|
|
|
const handleDrop = async (e: React.DragEvent, targetId: string) => {
|
|
e.preventDefault();
|
|
const sourceId = e.dataTransfer.getData("text/plain");
|
|
if (!sourceId || sourceId === targetId) { setDraggingId(null); return; }
|
|
|
|
const source = widgets.find((w) => w.id === sourceId);
|
|
const target = widgets.find((w) => w.id === targetId);
|
|
if (!source || !target) { setDraggingId(null); return; }
|
|
|
|
// Swap positions
|
|
const sourcePos = { ...source.position };
|
|
const targetPos = { ...target.position };
|
|
|
|
setWidgets((prev) =>
|
|
prev.map((w) => {
|
|
if (w.id === sourceId) return { ...w, position: targetPos };
|
|
if (w.id === targetId) return { ...w, position: sourcePos };
|
|
return w;
|
|
})
|
|
);
|
|
|
|
await updateWidget(id, sourceId, { position: targetPos });
|
|
await updateWidget(id, targetId, { position: sourcePos });
|
|
setDraggingId(null);
|
|
};
|
|
|
|
const handleDragEnd = () => {
|
|
setDraggingId(null);
|
|
};
|
|
|
|
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>
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<select
|
|
value={dashboard.team_id ?? ""}
|
|
onChange={async (e) => {
|
|
const teamId = e.target.value || null;
|
|
await updateDashboard(dashboard.id, { team_id: teamId });
|
|
setDashboard((prev) => prev ? { ...prev, team_id: teamId } : prev);
|
|
}}
|
|
className="h-7 rounded border border-border bg-card px-2 text-xs text-muted-foreground outline-none"
|
|
>
|
|
<option value="">No team</option>
|
|
{teams.map((t) => (
|
|
<option key={t.id} value={t.id}>{t.name}</option>
|
|
))}
|
|
</select>
|
|
{dashboard.description && (
|
|
<p className="text-sm text-muted-foreground">{dashboard.description}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setEditMode((v) => !v)}
|
|
className={cn("h-8 border-border/80", editMode ? "bg-primary/20 text-primary border-primary/40" : "bg-card/70")}
|
|
>
|
|
<PencilIcon className="h-4 w-4" />
|
|
{editMode ? "Done" : "Edit"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setAutoRefresh((v) => !v)}
|
|
className={cn("h-8 border-border/80", autoRefresh ? "bg-primary/20 text-primary" : "bg-card/70")}
|
|
>
|
|
<RefreshCwIcon className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
|
|
{autoRefresh ? "Live" : "Auto"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={fetchDashboard}
|
|
className="h-8 border-border/80 bg-card/70"
|
|
>
|
|
<RefreshCwIcon className="h-4 w-4" />
|
|
Refresh
|
|
</Button>
|
|
{editMode && (
|
|
<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>
|
|
{editMode ? (
|
|
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
|
<PlusIcon className="h-4 w-4" />
|
|
Add your first widget
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" size="sm" onClick={() => setEditMode(true)}>
|
|
<PencilIcon className="h-4 w-4" />
|
|
Enter edit mode
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={gridRef}
|
|
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
|
|
>
|
|
{widgets.map((widget) => (
|
|
<div
|
|
key={widget.id}
|
|
className={cn(
|
|
"group relative",
|
|
editMode && "cursor-grab",
|
|
draggingId === widget.id && "opacity-50",
|
|
resizingId === widget.id && "select-none",
|
|
)}
|
|
style={widgetGridStyle(widget.position)}
|
|
draggable={editMode}
|
|
onDragStart={(e) => editMode && handleDragStart(e, widget.id)}
|
|
onDragOver={handleDragOver}
|
|
onDrop={(e) => handleDrop(e, widget.id)}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
{/* Drag handle */}
|
|
{editMode && (
|
|
<div className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex">
|
|
<GripIcon className="h-3.5 w-3.5" />
|
|
</div>
|
|
)}
|
|
{renderWidget(widget)}
|
|
{editMode && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteWidget(widget.id)}
|
|
className="absolute right-2 top-2 z-10 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>
|
|
{/* Resize handle */}
|
|
<div
|
|
className="absolute bottom-0 right-0 z-10 hidden h-5 w-5 cursor-se-resize items-center justify-center group-hover:flex"
|
|
onMouseDown={(e) => handleResizeStart(e, widget.id)}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 10 10" className="text-muted-foreground">
|
|
<path d="M0 10 L10 0" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M5 10 L10 5" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M0 10 L10 10" stroke="transparent" />
|
|
</svg>
|
|
</div>
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|