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:
@@ -54,6 +54,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
||||
const [views, setViews] = useState<SavedView[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
// Add widget dialog
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -94,6 +95,23 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
||||
});
|
||||
}, [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);
|
||||
@@ -194,6 +212,15 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -222,7 +249,7 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
|
||||
</Button>
|
||||
</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) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
|
||||
@@ -864,8 +864,11 @@ function TicketWorkbenchContent() {
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="shrink-0 border-b border-destructive/20 bg-destructive/10 px-5 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
<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">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => fetchData()} className="ml-3 text-xs font-medium underline hover:no-underline">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LayoutGridIcon,
|
||||
UserIcon,
|
||||
InboxIcon,
|
||||
@@ -89,6 +90,11 @@ function SidebarNav() {
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({
|
||||
dashboards: true,
|
||||
queues: true,
|
||||
views: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Find current user and compute view counts
|
||||
@@ -190,41 +196,21 @@ function SidebarNav() {
|
||||
})}
|
||||
</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 && (
|
||||
<div className="mt-4">
|
||||
{!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
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{dashboards.map((dash) => {
|
||||
{expanded.dashboards && dashboards.map((dash) => {
|
||||
const active =
|
||||
pathname.startsWith("/dashboards/") && pathname.endsWith(dash.id);
|
||||
return (
|
||||
@@ -243,11 +229,18 @@ function SidebarNav() {
|
||||
{savedViews.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{!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
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{savedViews.map((view) => {
|
||||
{expanded.views && savedViews.map((view) => {
|
||||
const active =
|
||||
pathname === "/" && searchParams.get("view_id") === view.id;
|
||||
return (
|
||||
@@ -262,6 +255,40 @@ function SidebarNav() {
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user