296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { NavLink } from "react-router-dom";
|
|
import {
|
|
Activity,
|
|
CheckCircle2,
|
|
GitCompareArrows,
|
|
GitMerge,
|
|
Inbox as InboxIcon,
|
|
Layers,
|
|
LayoutDashboard,
|
|
LogOut,
|
|
Receipt,
|
|
Stethoscope,
|
|
Upload as UploadIcon,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { useReconciliation } from "@/hooks/useReconciliation";
|
|
import { useAuth } from "@/auth/useAuth";
|
|
|
|
const nav = [
|
|
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
|
{ to: "/claims", label: "Claims", icon: Receipt },
|
|
{ to: "/remittances", label: "Remittances", icon: Stethoscope },
|
|
{ to: "/providers", label: "Providers", icon: Users },
|
|
{ to: "/upload", label: "Upload", icon: UploadIcon },
|
|
{ to: "/activity", label: "Activity Log", icon: Activity },
|
|
];
|
|
|
|
/**
|
|
* Sidebar. Three sections under the brand mark:
|
|
* - WORKSPACE — the day-to-day surface
|
|
* - WORKFLOWS — triage & reconciliation lanes
|
|
* - REFERENCE — long-tail batches & acks
|
|
*
|
|
* The active state uses a 2px left accent + an inset tint + a
|
|
* subtle inner ring so the selected item reads instantly. Counts
|
|
* (e.g. unmatched on Reconciliation) live as mono digits aligned to
|
|
* the right edge so the eye can scan state at a glance.
|
|
*/
|
|
export function Sidebar() {
|
|
const { unmatched } = useReconciliation();
|
|
const unmatchedCount =
|
|
(unmatched.data?.claims.length ?? 0) +
|
|
(unmatched.data?.remittances.length ?? 0);
|
|
|
|
return (
|
|
<aside
|
|
className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 z-30 border-r border-border/60 bg-sidebar/80 backdrop-blur-xl"
|
|
data-print="hide"
|
|
>
|
|
{/* Brand mark — the editorial display face carries the wordmark
|
|
while a small monospaced slug below keeps the brand
|
|
unmistakably "machine". */}
|
|
<div className="flex h-16 items-center px-5 border-b border-sidebar-border/80">
|
|
<NavLink
|
|
to="/"
|
|
end
|
|
className="flex items-center gap-2.5 group"
|
|
aria-label="Cyclone — go to dashboard"
|
|
>
|
|
<div className="relative h-7 w-7 rounded-md bg-accent flex items-center justify-center shadow-[0_0_0_1px_hsl(var(--accent)/0.4),0_0_18px_-2px_hsl(var(--accent)/0.5)]">
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5 text-white"
|
|
fill="none"
|
|
aria-hidden
|
|
>
|
|
<path
|
|
d="M6 8h8a3 3 0 0 1 0 6h-5a3 3 0 0 0 0 6h9"
|
|
stroke="currentColor"
|
|
strokeWidth="2.4"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="leading-none">
|
|
<div className="display text-[19px] tracking-tight text-foreground -mt-px">
|
|
Cyclone
|
|
</div>
|
|
<div className="mono text-[9.5px] uppercase tracking-[0.18em] text-muted-foreground mt-1">
|
|
CuNtx · Claims
|
|
</div>
|
|
</div>
|
|
</NavLink>
|
|
</div>
|
|
|
|
<nav className="flex-1 overflow-y-auto px-3 py-5">
|
|
<SectionLabel>Workspace</SectionLabel>
|
|
<ul className="space-y-0.5 mb-5">
|
|
{nav.map((item) => (
|
|
<li key={item.to}>
|
|
<NavItem item={item} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<SectionLabel>Workflows</SectionLabel>
|
|
<ul className="space-y-0.5 mb-5">
|
|
<li>
|
|
<NavLink
|
|
to="/reconciliation"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "nav-active"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
)
|
|
}
|
|
>
|
|
<GitMerge className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>Reconciliation</span>
|
|
{unmatchedCount > 0 ? (
|
|
<span className="ml-auto mono text-[10px] text-signal">
|
|
{unmatchedCount > 99 ? "99+" : unmatchedCount}
|
|
</span>
|
|
) : null}
|
|
</NavLink>
|
|
</li>
|
|
<li>
|
|
<NavLink
|
|
to="/inbox"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "nav-active"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
)
|
|
}
|
|
>
|
|
<InboxIcon className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>Inbox</span>
|
|
</NavLink>
|
|
</li>
|
|
</ul>
|
|
|
|
<SectionLabel>Reference</SectionLabel>
|
|
<ul className="space-y-0.5">
|
|
<li>
|
|
<NavLink
|
|
to="/acks"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "nav-active"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
)
|
|
}
|
|
>
|
|
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>999 ACKs</span>
|
|
</NavLink>
|
|
</li>
|
|
<li>
|
|
<NavLink
|
|
to="/batches"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "nav-active"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
)
|
|
}
|
|
>
|
|
<Layers className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>Batches</span>
|
|
</NavLink>
|
|
</li>
|
|
<li>
|
|
<NavLink
|
|
to="/batch-diff"
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "nav-active"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
)
|
|
}
|
|
>
|
|
<GitCompareArrows className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>Batch diff</span>
|
|
</NavLink>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* Operator card — always at the bottom. Ringed, mono id, the
|
|
account role under the name. The avatar initial + username +
|
|
role all come from `useAuth()` now (replaced the hardcoded
|
|
"Jordan K. / Administrator"); the Sign out button next to it
|
|
hits `authApi.logout` and drops the operator back at /login. */}
|
|
<div className="px-3 py-4 border-t border-sidebar-border/80">
|
|
<CurrentUserCard />
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bottom-of-sidebar identity card. Driven by `useAuth()` so the
|
|
* username + role update whenever the operator's session changes
|
|
* (e.g. an admin re-grants a role via the admin pages, or the
|
|
* session expires and a fresh login lands them back with a
|
|
* different username).
|
|
*/
|
|
function CurrentUserCard() {
|
|
const { user, logout } = useAuth();
|
|
if (!user) {
|
|
// Shouldn't happen — the route guard ensures the sidebar only
|
|
// mounts after auth is established. Render a harmless placeholder
|
|
// so the layout doesn't reflow if the context ever lags.
|
|
return (
|
|
<div
|
|
className="flex items-center gap-3 rounded-md p-2 text-[12px] text-muted-foreground"
|
|
aria-hidden
|
|
>
|
|
<div className="h-8 w-8 rounded-full bg-muted/40" />
|
|
<div className="leading-tight">Signed out</div>
|
|
</div>
|
|
);
|
|
}
|
|
// Use the first letter of the username for the avatar monogram;
|
|
// fall back to "?" if the username is somehow empty.
|
|
const initial = user.username?.[0]?.toUpperCase() ?? "?";
|
|
return (
|
|
<div className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/40 group">
|
|
<div className="relative h-8 w-8 rounded-full bg-accent/20 ring-1 ring-inset ring-accent/40 flex items-center justify-center text-[10.5px] font-semibold text-accent mono shrink-0">
|
|
{initial}
|
|
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-[hsl(var(--success))] ring-2 ring-sidebar" />
|
|
</div>
|
|
<div className="leading-tight min-w-0 flex-1">
|
|
<div className="text-[13px] font-medium truncate">{user.username}</div>
|
|
<div className="mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
|
{user.role}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void logout();
|
|
}}
|
|
aria-label="Sign out"
|
|
title="Sign out"
|
|
className="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring opacity-60 group-hover:opacity-100"
|
|
>
|
|
<LogOut className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="px-3 mb-1.5 eyebrow text-muted-foreground/60">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavItem({
|
|
item,
|
|
}: {
|
|
item: {
|
|
to: string;
|
|
label: string;
|
|
// Lucide icons accept `strokeWidth` as a wider union (string | number | null | undefined);
|
|
// we declare it as `number | string` so any Lucide icon can be assigned
|
|
// here without the prop-types assignability complaint from TS.
|
|
icon: React.ComponentType<{ className?: string; strokeWidth?: number | string }>;
|
|
end?: boolean;
|
|
};
|
|
}) {
|
|
return (
|
|
<NavLink
|
|
to={item.to}
|
|
end={item.end}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
"group relative flex items-center gap-3 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors",
|
|
isActive
|
|
? "nav-active"
|
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
|
)
|
|
}
|
|
>
|
|
<item.icon className="h-3.5 w-3.5" strokeWidth={1.5} />
|
|
<span>{item.label}</span>
|
|
</NavLink>
|
|
);
|
|
}
|