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 [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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user