From 310568690b40b98572d82ba645e1784d9edc995e Mon Sep 17 00:00:00 2001 From: Leon Serfaty <80597822+silkoserfo@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:11:34 -0400 Subject: [PATCH] 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 --- apps/web/src/components/admin/AdminLayout.tsx | 9 +- .../web/src/components/admin/AdminSidebar.tsx | 90 +++++++-- apps/web/src/components/app/GlobalSearch.tsx | 190 ++++++++++++++++++ apps/web/src/components/app/Sidebar.tsx | 80 +++++++- apps/web/src/components/app/Topbar.tsx | 16 +- apps/web/src/components/ui/NavDrawer.tsx | 45 +++++ apps/web/src/pages/app/DashboardPage.tsx | 43 +++- 7 files changed, 435 insertions(+), 38 deletions(-) create mode 100644 apps/web/src/components/app/GlobalSearch.tsx create mode 100644 apps/web/src/components/ui/NavDrawer.tsx diff --git a/apps/web/src/components/admin/AdminLayout.tsx b/apps/web/src/components/admin/AdminLayout.tsx index ffd666b..5d948f3 100644 --- a/apps/web/src/components/admin/AdminLayout.tsx +++ b/apps/web/src/components/admin/AdminLayout.tsx @@ -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() {
-
-

Signed in as {me.data.email}

+
+
+ +

Signed in as {me.data.email}

+
- - - Superadmin - +
- - - Back to app - +
); } -function NavItem({ item }: { item: Item }) { +export function AdminMobileNav() { + const [open, setOpen] = useState(false); + const close = () => setOpen(false); + + return ( + <> + + + +
+ + Legal Software + + +
+ +
+ +
+ + + +
+ +
+
+ + ); +} + +function BackToApp({ onNavigate }: { onNavigate?: () => void }) { + return ( + + + Back to app + + ); +} + +function NavItem({ item, onNavigate }: { item: Item; onNavigate?: () => void }) { return ( cn( 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition', diff --git a/apps/web/src/components/app/GlobalSearch.tsx b/apps/web/src/components/app/GlobalSearch.tsx new file mode 100644 index 0000000..a47e759 --- /dev/null +++ b/apps/web/src/components/app/GlobalSearch.tsx @@ -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(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(null); + const inputRef = useRef(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(() => { + 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) => { + 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 ( +
+ + { + 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 && ( +
+ {searching ? ( +
+ + Searching… +
+ ) : clients.isError || cases.isError ? ( +

Search failed. Try again.

+ ) : !enabled ? ( +

Keep typing to search…

+ ) : !hits.length ? ( +

+ No clients or cases match “{debouncedQ}”. +

+ ) : ( +
    + {hits.map((hit, i) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/app/Sidebar.tsx b/apps/web/src/components/app/Sidebar.tsx index fd9a770..23def48 100644 --- a/apps/web/src/components/app/Sidebar.tsx +++ b/apps/web/src/components/app/Sidebar.tsx @@ -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 ( + <> + + + +
+ + +
+ + + +
+ {NAV_FOOT.map((item) => ( + + ))} +
+
+ + ); +} + +function NavItem({ item, onNavigate }: { item: Item; onNavigate?: () => void }) { + const badge = item.badge && ( + + {item.badge} + + ); + + if (item.disabled) { + return ( +
+ + {item.label} + {badge} +
+ ); + } + return ( 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.label} - {item.badge && ( - - {item.badge} - - )} + {badge} ); } diff --git a/apps/web/src/components/app/Topbar.tsx b/apps/web/src/components/app/Topbar.tsx index f5d68db..81cb01d 100644 --- a/apps/web/src/components/app/Topbar.tsx +++ b/apps/web/src/components/app/Topbar.tsx @@ -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 ( -
-
- - -
+
+ +
diff --git a/apps/web/src/components/ui/NavDrawer.tsx b/apps/web/src/components/ui/NavDrawer.tsx new file mode 100644 index 0000000..890ff2b --- /dev/null +++ b/apps/web/src/components/ui/NavDrawer.tsx @@ -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 ( +
+
+ +
+ ); +} diff --git a/apps/web/src/pages/app/DashboardPage.tsx b/apps/web/src/pages/app/DashboardPage.tsx index 6158e9f..c94bf39 100644 --- a/apps/web/src/pages/app/DashboardPage.tsx +++ b/apps/web/src/pages/app/DashboardPage.tsx @@ -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 (
+ {failed.length > 0 && ( +
+

+ + Some dashboard data couldn't be loaded. +

+ +
+ )} +
} label="Active cases" - value={String(openCases.length)} + value={cases.isError ? '—' : String(openCases.length)} to="/app/cases" /> } label="Clients" - value={String(clients.data?.total ?? 0)} + value={clients.isError ? '—' : String(clients.data?.total ?? 0)} to="/app/clients" /> } label="Tracked hours" - value={formatHours(totalMinutes)} + value={cases.isError ? '—' : formatHours(totalMinutes)} to="/app/time" /> } label="Outstanding" - value={formatMoney(summary.data?.outstanding ?? 0)} + value={summary.isError ? '—' : formatMoney(summary.data?.outstanding ?? 0)} to="/app/invoices" />
@@ -69,7 +89,18 @@ export default function DashboardPage() { } /> - {cases.data?.items.length ? ( + {cases.isError ? ( + } + title="Couldn't load cases" + description="Check your connection and try again." + action={ + + } + /> + ) : cases.data?.items.length ? (
{cases.data.items.slice(0, 6).map((c) => (