Add mobile nav, working global search, and dashboard error states
- New NavDrawer + hamburger menus for the app and admin layouts (below md there was previously no navigation at all) - Disable the dead Documents nav link that routed to the marketing landing page via the catch-all route - Replace the decorative topbar search with a debounced clients+cases search dropdown (keyboard navigable, loading/error/empty states) - Surface dashboard API failures: retry banner, em-dash KPIs, and a retryable error state on the recent-cases card Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
8656e6e470
commit
310568690b
@@ -1,7 +1,7 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useLogout, useMe } from '@/hooks/useAuth';
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
import { AdminMobileNav, AdminSidebar } from './AdminSidebar';
|
||||
|
||||
export function AdminLayout() {
|
||||
const me = useMe();
|
||||
@@ -17,8 +17,11 @@ export function AdminLayout() {
|
||||
<div className="min-h-screen flex bg-ink-50">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="flex h-16 items-center justify-between gap-4 border-b border-ink-100 bg-white px-6">
|
||||
<p className="text-sm text-ink-500">Signed in as <span className="font-medium text-ink-800">{me.data.email}</span></p>
|
||||
<header className="flex h-16 items-center justify-between gap-3 border-b border-ink-100 bg-white px-4 md:gap-4 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AdminMobileNav />
|
||||
<p className="truncate text-sm text-ink-500">Signed in as <span className="font-medium text-ink-800">{me.data.email}</span></p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout.mutate()}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -7,8 +8,11 @@ import {
|
||||
ShieldAlert,
|
||||
ArrowLeft,
|
||||
ScrollText,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { NavDrawer } from '@/components/ui/NavDrawer';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Item {
|
||||
@@ -25,6 +29,15 @@ const NAV: Item[] = [
|
||||
{ to: '/admin/audit', label: 'Audit log', icon: ScrollText },
|
||||
];
|
||||
|
||||
function SuperadminBadge() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/20 text-rose-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Superadmin
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSidebar() {
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-60 lg:w-64 flex-col border-r border-ink-100 bg-ink-950 text-ink-100">
|
||||
@@ -35,10 +48,7 @@ export function AdminSidebar() {
|
||||
</div>
|
||||
|
||||
<div className="px-5 mb-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/20 text-rose-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Superadmin
|
||||
</span>
|
||||
<SuperadminBadge />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 mt-2 space-y-0.5">
|
||||
@@ -48,23 +58,79 @@ export function AdminSidebar() {
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-ink-900">
|
||||
<NavLink
|
||||
to="/app"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-ink-300 hover:bg-ink-900/60 hover:text-white transition"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to app
|
||||
</NavLink>
|
||||
<BackToApp />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item }: { item: Item }) {
|
||||
export function AdminMobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-ink-600 hover:bg-ink-100 md:hidden"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<NavDrawer open={open} onClose={close} panelClassName="bg-ink-950 text-ink-100">
|
||||
<div className="flex items-center justify-between px-5 py-5">
|
||||
<a href="/admin" className="inline-flex" aria-label="Admin home">
|
||||
<img src="/logo-light.png" alt="Legal Software" className="h-7 w-auto" width={450} height={45} />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="grid h-9 w-9 place-items-center rounded-lg text-ink-400 hover:bg-ink-900/60 hover:text-white"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 mb-2">
|
||||
<SuperadminBadge />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 mt-2 space-y-0.5">
|
||||
{NAV.map((item) => (
|
||||
<NavItem key={item.to} item={item} onNavigate={close} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-ink-900">
|
||||
<BackToApp onNavigate={close} />
|
||||
</div>
|
||||
</NavDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BackToApp({ onNavigate }: { onNavigate?: () => void }) {
|
||||
return (
|
||||
<NavLink
|
||||
to="/app"
|
||||
onClick={onNavigate}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-ink-300 hover:bg-ink-900/60 hover:text-white transition"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to app
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, onNavigate }: { item: Item; onNavigate?: () => void }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/admin'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Briefcase, Loader2, Search, Users } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface CaseHit {
|
||||
id: string;
|
||||
title: string;
|
||||
clientName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ClientHit {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
interface Hit {
|
||||
key: string;
|
||||
to: string;
|
||||
primary: string;
|
||||
secondary: string | null;
|
||||
icon: 'client' | 'case';
|
||||
}
|
||||
|
||||
function useDebouncedValue<T>(value: T, delay = 250): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function GlobalSearch() {
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const debouncedQ = useDebouncedValue(q.trim());
|
||||
const enabled = debouncedQ.length >= 2;
|
||||
|
||||
const clients = useQuery<{ items: ClientHit[] }>({
|
||||
queryKey: ['search', 'clients', debouncedQ],
|
||||
queryFn: () => api.get(`/api/clients?q=${encodeURIComponent(debouncedQ)}&limit=5`),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const cases = useQuery<{ items: CaseHit[] }>({
|
||||
queryKey: ['search', 'cases', debouncedQ],
|
||||
queryFn: () => api.get(`/api/cases?q=${encodeURIComponent(debouncedQ)}&limit=5`),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const hits = useMemo<Hit[]>(() => {
|
||||
if (!enabled) return [];
|
||||
const clientHits: Hit[] = (clients.data?.items ?? []).map((c) => ({
|
||||
key: `client-${c.id}`,
|
||||
to: `/app/clients/${c.id}`,
|
||||
primary: c.name,
|
||||
secondary: c.email,
|
||||
icon: 'client',
|
||||
}));
|
||||
const caseHits: Hit[] = (cases.data?.items ?? []).map((c) => ({
|
||||
key: `case-${c.id}`,
|
||||
to: `/app/cases/${c.id}`,
|
||||
primary: c.title,
|
||||
secondary: c.clientName,
|
||||
icon: 'case',
|
||||
}));
|
||||
return [...clientHits, ...caseHits];
|
||||
}, [enabled, clients.data, cases.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [debouncedQ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
return () => document.removeEventListener('mousedown', onMouseDown);
|
||||
}, [open]);
|
||||
|
||||
const go = (hit: Hit) => {
|
||||
setQ('');
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
navigate(hit.to);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
return;
|
||||
}
|
||||
if (!open || !hits.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i + 1) % hits.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => (i - 1 + hits.length) % hits.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const hit = hits[Math.min(activeIndex, hits.length - 1)];
|
||||
if (hit) go(hit);
|
||||
}
|
||||
};
|
||||
|
||||
const searching = enabled && (clients.isFetching || cases.isFetching) && !hits.length;
|
||||
const showPanel = open && q.trim().length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative min-w-0 max-w-sm flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={showPanel}
|
||||
aria-label="Search clients and cases"
|
||||
placeholder="Search clients and cases…"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="w-full rounded-xl border border-ink-200 bg-ink-50/40 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
|
||||
{showPanel && (
|
||||
<div className="absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-xl border border-ink-100 bg-white shadow-lg">
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-sm text-ink-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Searching…
|
||||
</div>
|
||||
) : clients.isError || cases.isError ? (
|
||||
<p className="px-4 py-3 text-sm text-rose-700">Search failed. Try again.</p>
|
||||
) : !enabled ? (
|
||||
<p className="px-4 py-3 text-sm text-ink-500">Keep typing to search…</p>
|
||||
) : !hits.length ? (
|
||||
<p className="px-4 py-3 text-sm text-ink-500">
|
||||
No clients or cases match “{debouncedQ}”.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-80 overflow-y-auto py-1">
|
||||
{hits.map((hit, i) => (
|
||||
<li key={hit.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(hit)}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm',
|
||||
i === activeIndex ? 'bg-brand-50/60' : 'hover:bg-ink-50',
|
||||
)}
|
||||
>
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg bg-ink-100 text-ink-500">
|
||||
{hit.icon === 'client' ? <Users className="h-3.5 w-3.5" /> : <Briefcase className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-ink-900">{hit.primary}</span>
|
||||
{hit.secondary && <span className="block truncate text-xs text-ink-500">{hit.secondary}</span>}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -7,9 +8,12 @@ import {
|
||||
FileText,
|
||||
Receipt,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Logo } from '@/components/marketing/Logo';
|
||||
import { NavDrawer } from '@/components/ui/NavDrawer';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Item {
|
||||
@@ -17,6 +21,7 @@ interface Item {
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
badge?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const NAV: Item[] = [
|
||||
@@ -24,7 +29,7 @@ const NAV: Item[] = [
|
||||
{ to: '/app/clients', label: 'Clients', icon: Users },
|
||||
{ to: '/app/cases', label: 'Cases', icon: Briefcase },
|
||||
{ to: '/app/time', label: 'Time', icon: Clock },
|
||||
{ to: '/app/documents', label: 'Documents', icon: FileText, badge: 'Soon' },
|
||||
{ to: '/app/documents', label: 'Documents', icon: FileText, badge: 'Soon', disabled: true },
|
||||
{ to: '/app/invoices', label: 'Invoices', icon: Receipt },
|
||||
];
|
||||
|
||||
@@ -52,11 +57,76 @@ export function Sidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item }: { item: Item }) {
|
||||
export function MobileNav() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-ink-600 hover:bg-ink-100 md:hidden"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<NavDrawer open={open} onClose={close}>
|
||||
<div className="flex items-center justify-between px-5 py-5">
|
||||
<Logo />
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="grid h-9 w-9 place-items-center rounded-lg text-ink-500 hover:bg-ink-100"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{NAV.map((item) => (
|
||||
<NavItem key={item.to} item={item} onNavigate={close} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-ink-100">
|
||||
{NAV_FOOT.map((item) => (
|
||||
<NavItem key={item.to} item={item} onNavigate={close} />
|
||||
))}
|
||||
</div>
|
||||
</NavDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, onNavigate }: { item: Item; onNavigate?: () => void }) {
|
||||
const badge = item.badge && (
|
||||
<span className="rounded-full bg-ink-100 text-ink-500 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5">
|
||||
{item.badge}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (item.disabled) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-ink-400 cursor-not-allowed select-none"
|
||||
aria-disabled="true"
|
||||
title="Coming soon"
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/app'}
|
||||
onClick={onNavigate}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
|
||||
@@ -66,11 +136,7 @@ function NavItem({ item }: { item: Item }) {
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="rounded-full bg-ink-100 text-ink-500 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
{badge}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LogOut, Search, ChevronDown, ShieldAlert } from 'lucide-react';
|
||||
import { LogOut, ChevronDown, ShieldAlert } from 'lucide-react';
|
||||
import { useLogout, useMe } from '@/hooks/useAuth';
|
||||
import { GlobalSearch } from './GlobalSearch';
|
||||
import { MobileNav } from './Sidebar';
|
||||
import { TimerWidget } from './TimerWidget';
|
||||
|
||||
export function Topbar() {
|
||||
@@ -17,15 +19,9 @@ export function Topbar() {
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between gap-4 border-b border-ink-100 bg-white px-6">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
className="w-full rounded-xl border border-ink-200 bg-ink-50/40 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
<header className="flex h-16 items-center justify-between gap-3 border-b border-ink-100 bg-white px-4 md:gap-4 md:px-6">
|
||||
<MobileNav />
|
||||
<GlobalSearch />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<TimerWidget />
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
panelClassName?: string;
|
||||
}
|
||||
|
||||
export function NavDrawer({ open, onClose, children, panelClassName }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 transition-opacity md:hidden',
|
||||
open ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" onClick={onClose} />
|
||||
<aside
|
||||
className={cn(
|
||||
'absolute left-0 top-0 h-full w-72 max-w-[85vw] shadow-2xl flex flex-col transition-transform',
|
||||
open ? 'translate-x-0' : '-translate-x-full',
|
||||
panelClassName ?? 'bg-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Briefcase, Clock, Receipt, Users, ArrowUpRight } from 'lucide-react';
|
||||
import { AlertTriangle, Briefcase, Clock, Receipt, Users, ArrowUpRight } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -19,6 +19,8 @@ export default function DashboardPage() {
|
||||
const openCases = cases.data?.items.filter((c) => c.status === 'open') ?? [];
|
||||
const totalMinutes = cases.data?.items.reduce((acc, c) => acc + (c.billedMinutes ?? 0), 0) ?? 0;
|
||||
|
||||
const failed = [cases, clients, summary].filter((q) => q.isError);
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<PageHeader
|
||||
@@ -31,29 +33,47 @@ export default function DashboardPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{failed.length > 0 && (
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="flex items-center gap-2 text-sm text-rose-800">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Some dashboard data couldn't be loaded.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
for (const q of failed) void q.refetch();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={<Briefcase className="h-4 w-4" />}
|
||||
label="Active cases"
|
||||
value={String(openCases.length)}
|
||||
value={cases.isError ? '—' : String(openCases.length)}
|
||||
to="/app/cases"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Clients"
|
||||
value={String(clients.data?.total ?? 0)}
|
||||
value={clients.isError ? '—' : String(clients.data?.total ?? 0)}
|
||||
to="/app/clients"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Tracked hours"
|
||||
value={formatHours(totalMinutes)}
|
||||
value={cases.isError ? '—' : formatHours(totalMinutes)}
|
||||
to="/app/time"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Receipt className="h-4 w-4" />}
|
||||
label="Outstanding"
|
||||
value={formatMoney(summary.data?.outstanding ?? 0)}
|
||||
value={summary.isError ? '—' : formatMoney(summary.data?.outstanding ?? 0)}
|
||||
to="/app/invoices"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,7 +89,18 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{cases.data?.items.length ? (
|
||||
{cases.isError ? (
|
||||
<EmptyState
|
||||
icon={<AlertTriangle className="h-5 w-5" />}
|
||||
title="Couldn't load cases"
|
||||
description="Check your connection and try again."
|
||||
action={
|
||||
<Button size="sm" variant="secondary" onClick={() => void cases.refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : cases.data?.items.length ? (
|
||||
<div className="divide-y divide-ink-100">
|
||||
{cases.data.items.slice(0, 6).map((c) => (
|
||||
<Link
|
||||
|
||||
Reference in New Issue
Block a user