feat: dashboard auto-refresh, collapsible sidebar, error retry

- Dashboard: auto-refresh toggle (30s interval, spins when live)
- Dashboard: responsive grid (6 cols mobile, 12 cols desktop)
- Sidebar: Dashboards, Saved views, Queues sections now collapsible
  with chevron toggle
- Error banner: added Retry button next to error message

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 13:14:47 +02:00
parent 6263ce1332
commit f7e34f1690
3 changed files with 93 additions and 36 deletions

View File

@@ -54,6 +54,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
const [views, setViews] = useState<SavedView[]>([]); const [views, setViews] = useState<SavedView[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
// Add widget dialog // Add widget dialog
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
@@ -94,6 +95,23 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
}); });
}, [fetchDashboard]); }, [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 () => { const handleAddWidget = async () => {
if (!addViewId || !addTitle.trim()) return; if (!addViewId || !addTitle.trim()) return;
setAdding(true); setAdding(true);
@@ -194,6 +212,15 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -222,7 +249,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="grid auto-rows-[minmax(120px,auto)] grid-cols-12 gap-4"> <div className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4">
{widgets.map((widget) => ( {widgets.map((widget) => (
<div <div
key={widget.id} key={widget.id}

View File

@@ -864,8 +864,11 @@ function TicketWorkbenchContent() {
</header> </header>
{error && ( {error && (
<div className="shrink-0 border-b border-destructive/20 bg-destructive/10 px-5 py-2 text-sm text-destructive"> <div className="flex items-center justify-between shrink-0 border-b border-destructive/20 bg-destructive/10 px-5 py-2 text-sm text-destructive">
{error} <span>{error}</span>
<button type="button" onClick={() => fetchData()} className="ml-3 text-xs font-medium underline hover:no-underline">
Retry
</button>
</div> </div>
)} )}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
ChevronRightIcon,
LayoutGridIcon, LayoutGridIcon,
UserIcon, UserIcon,
InboxIcon, InboxIcon,
@@ -89,6 +90,11 @@ function SidebarNav() {
const [savedViews, setSavedViews] = useState<SavedView[]>([]); const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]); const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null); const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({
dashboards: true,
queues: true,
views: true,
});
useEffect(() => { useEffect(() => {
// Find current user and compute view counts // Find current user and compute view counts
@@ -190,41 +196,21 @@ function SidebarNav() {
})} })}
</div> </div>
{queues.length > 0 && (
<div>
{!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
Queues
</div>
)}
{queues.map((queue) => {
const active =
pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
);
return (
<SidebarNavItem
key={queue.id}
href={`/?queue=${queue.id}`}
icon={QueueIcon}
label={queue.name}
count={queue.count}
active={active}
/>
);
})}
</div>
)}
{dashboards.length > 0 && ( {dashboards.length > 0 && (
<div className="mt-4"> <div className="mt-4">
{!collapsed && ( {!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase"> <button
type="button"
onClick={() => setExpanded((e) => ({ ...e, dashboards: !e.dashboards }))}
className="flex w-full items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase hover:text-sidebar-foreground/70"
>
<ChevronRightIcon
className={cn("h-3 w-3 transition-transform", expanded.dashboards && "rotate-90")}
/>
Dashboards Dashboards
</div> </button>
)} )}
{dashboards.map((dash) => { {expanded.dashboards && dashboards.map((dash) => {
const active = const active =
pathname.startsWith("/dashboards/") && pathname.endsWith(dash.id); pathname.startsWith("/dashboards/") && pathname.endsWith(dash.id);
return ( return (
@@ -243,11 +229,18 @@ function SidebarNav() {
{savedViews.length > 0 && ( {savedViews.length > 0 && (
<div className="mt-4"> <div className="mt-4">
{!collapsed && ( {!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase"> <button
type="button"
onClick={() => setExpanded((e) => ({ ...e, views: !e.views }))}
className="flex w-full items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase hover:text-sidebar-foreground/70"
>
<ChevronRightIcon
className={cn("h-3 w-3 transition-transform", expanded.views && "rotate-90")}
/>
Saved views Saved views
</div> </button>
)} )}
{savedViews.map((view) => { {expanded.views && savedViews.map((view) => {
const active = const active =
pathname === "/" && searchParams.get("view_id") === view.id; pathname === "/" && searchParams.get("view_id") === view.id;
return ( return (
@@ -262,6 +255,40 @@ function SidebarNav() {
})} })}
</div> </div>
)} )}
{queues.length > 0 && (
<div className="mt-4">
{!collapsed && (
<button
type="button"
onClick={() => setExpanded((e) => ({ ...e, queues: !e.queues }))}
className="flex w-full items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase hover:text-sidebar-foreground/70"
>
<ChevronRightIcon
className={cn("h-3 w-3 transition-transform", expanded.queues && "rotate-90")}
/>
Queues
</button>
)}
{expanded.queues && queues.map((queue) => {
const active =
pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
);
return (
<SidebarNavItem
key={queue.id}
href={`/?queue=${queue.id}`}
icon={QueueIcon}
label={queue.name}
count={queue.count}
active={active}
/>
);
})}
</div>
)}
</> </>
); );
} }