Files
legal-ai/web-ui/src/components/app-shell.tsx
Chaim 37e881bf8c
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(nav): הסרת פילי-טאבים ותגי-ניווט + הורדת "פסיקה חסרה" מהתפריט (#3/#4)
#3 — הסרת תגי-המספר הקטנים:
- app-shell: הוסרו ApprovalsBadge ("מרכז אישורים") ו-MissingPrecedentsBadge.
- precedents: הוסרו CountPill/PendingPill/PlansPendingPill/IncomingPill מהטאבים;
  הקו-תחתון-זהב לבדו נושא את מצב-הטאב-הפעיל.

#4 — "פסיקה חסרה" ירדה מתפריט-הניווט "פסיקה" (כפולה לטאב "פסיקה נכנסת"
ב-/precedents). הדף /missing-precedents נשאר נגיש מתוך אותו טאב.

מאושר דרך שער-העיצוב (Claude Design 07-precedents).

Invariants: G2 — הסרת תגים-מקבילים; INV-IA1 — מרכז-האישורים נשאר שער-יחיד
(התג ירד, הדף לא).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:49:56 +00:00

332 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Fragment, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Settings } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search";
import { headerSubtitle } from "@/components/header-context";
/**
* Ezer Mishpati navigation shell — two-row header.
*
* Row 1 (brand): logo + dynamic context subtitle · global search · agent boards
* Row 2 (nav): work links · knowledge dropdowns (פסיקה/סגנון) · admin dropdown
*
* Editorial/judicial aesthetic preserved:
* - Navy background with a gold hairline rule (border-b-3)
* - Parchment text, gold accents on hover/active
* - Hebrew RTL throughout (set on <html> in layout.tsx)
* - Active item gets `aria-current="page"` and a gold underline anchored
* to the bottom border, so screen readers announce the section and
* sighted users see where they are.
*/
// `groupLabel`, when present, renders a separator + sub-heading inside a
// dropdown *before* this item — used to set off secondary tools.
type NavItem = { href: string; label: string; groupLabel?: string };
type NavMenuDef = { id: string; label: string; items: NavItem[] };
// Work cluster — direct links, the daily hubs.
// INV-IA4 (#132): "הערות יו״ר" (/feedback) dropped from the primary nav — it's a
// task surfaced *inside* מרכז אישורים (the chair_feedback card in /api/chair/pending
// deep-links to /feedback), not a top-level format-based destination. The page
// still exists and is reached from the approvals box.
const WORK_LINKS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/approvals", label: "מרכז אישורים" },
{ href: "/archive", label: "ארכיון" },
];
// Knowledge cluster — grouped under dropdowns so the bar stays scannable.
// All routes are preserved; only their placement in the nav changes.
const KNOWLEDGE_MENUS: NavMenuDef[] = [
{
id: "precedents",
label: "פסיקה",
// #4: "פסיקה חסרה" (/missing-precedents) ירד מהתפריט — הוא כפול לטאב
// "פסיקה נכנסת" בתוך /precedents. הדף עצמו נשאר נגיש מתוך אותו טאב.
items: [
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/digests", label: "יומונים" },
{ href: "/graph", label: "מפת הקורפוס" },
],
},
{
id: "style",
label: "סגנון",
items: [
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
],
},
];
const ADMIN_ITEMS: NavItem[] = [
{ href: "/skills", label: "מיומנויות" },
// INV-IA4: /diagnostics folded into /operations (one monitoring surface);
// /diagnostics now redirects there, so it's dropped from the nav.
{ href: "/operations", label: "תפעול" },
{ href: "/scripts", label: "סקריפטים" },
{ href: "/settings", label: "הגדרות" },
];
type AgentBoard = { prefix: string; label: string; hint: string };
const AGENT_BOARDS: AgentBoard[] = [
{ prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" },
{ prefix: "CMPA", label: "היטלי השבחה", hint: "תיקי 8xxx / 9xxx" },
];
const PAPERCLIP_BASE = "https://pc.nautilus.marcusgroup.org";
function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const subtitle = headerSubtitle(pathname);
const adminActive = ADMIN_ITEMS.some((i) => isActive(pathname, i.href));
return (
<>
<header
className="
relative z-10 flex flex-col
bg-navy text-parchment
border-b-[3px] border-gold
shadow-md
"
>
{/* ─── Row 1 — brand bar (3-column grid) ─── */}
{/* Side columns flex 1fr each so the search column stays centered on
the viewport regardless of how wide the brand or agent labels grow. */}
<div className="grid grid-cols-[minmax(0,1fr)_minmax(280px,460px)_minmax(0,1fr)] items-center gap-4 px-10 pt-[14px] pb-2">
<Link
href="/"
className="flex items-baseline gap-3 hover:text-parchment min-w-0 justify-self-start"
>
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment whitespace-nowrap">
עוזר משפטי
</span>
<span
className="text-gold-soft text-sm font-medium truncate"
aria-live="polite"
>
{subtitle}
</span>
</Link>
<div className="w-full justify-self-center">
<GlobalSearch />
</div>
<DropdownMenu>
<DropdownMenuTrigger
className="
justify-self-end flex items-baseline gap-2 px-3 py-1.5 rounded
transition-colors outline-none
text-parchment/80 hover:text-parchment hover:bg-navy-soft/60
focus-visible:ring-2 focus-visible:ring-gold/60
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
"
aria-label="ניהול סוכנים — בחר ועדה"
>
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment whitespace-nowrap">
ניהול סוכנים
</span>
<ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[240px]">
<DropdownMenuLabel className="text-xs text-muted-foreground text-center">
Paperclip פתח דאשבורד
</DropdownMenuLabel>
<DropdownMenuSeparator />
{AGENT_BOARDS.map((board) => (
<DropdownMenuItem key={board.prefix} asChild>
<a
href={`${PAPERCLIP_BASE}/${board.prefix}/dashboard`}
target="_blank"
rel="noreferrer noopener"
className="flex flex-col gap-0.5 cursor-pointer py-1.5"
>
<span className="font-medium whitespace-nowrap">{board.label}</span>
<span className="text-xs text-muted-foreground tracking-wide whitespace-nowrap">
<span className="font-mono">{board.prefix}</span> · {board.hint}
</span>
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* ─── Row 2 — section nav ─── */}
<div className="flex items-center gap-3 px-10 pt-1 pb-[18px]">
<nav className="flex items-center gap-3" aria-label="ניווט ראשי">
<div className="flex items-center gap-1">
{WORK_LINKS.map((item) => (
<NavLink key={item.href} item={item} active={isActive(pathname, item.href)} />
))}
</div>
<span className="mx-2 h-4 w-px bg-parchment/20" aria-hidden="true" />
<div className="flex items-center gap-1">
{KNOWLEDGE_MENUS.map((menu) => (
<NavMenu key={menu.id} menu={menu} pathname={pathname} />
))}
</div>
</nav>
<DropdownMenu>
<DropdownMenuTrigger
className={`
relative ms-auto shrink-0 flex items-center gap-1.5
px-3 py-1.5 rounded text-sm transition-colors outline-none
focus-visible:ring-2 focus-visible:ring-gold/60
${adminActive
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
`}
aria-label="הגדרות מערכת"
>
<Settings className="size-4" aria-hidden="true" />
<ChevronDown className="size-3" aria-hidden="true" />
{adminActive && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[180px]">
<DropdownMenuLabel className="text-xs text-muted-foreground">
מערכת
</DropdownMenuLabel>
<DropdownMenuSeparator />
{ADMIN_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<DropdownMenuItem key={item.href} asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`cursor-pointer ${active ? "font-semibold" : ""}`}
>
{item.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main
id="main"
className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10"
>
{children}
</main>
</>
);
}
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`
relative px-3 py-1.5 rounded text-sm transition-colors
${active
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
`}
>
<span>{item.label}</span>
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</Link>
);
}
/* Knowledge-cluster dropdown (פסיקה / סגנון). The trigger mirrors the admin
* dropdown styling and lights up — gold underline included — when any child
* route is active, so the active section reads even while the menu is closed.
* The "פסיקה חסרה" open-count badge surfaces on the trigger too, so the
* pending count stays visible without opening the menu. */
function NavMenu({ menu, pathname }: { menu: NavMenuDef; pathname: string }) {
const menuActive = menu.items.some((i) => isActive(pathname, i.href));
return (
<DropdownMenu>
<DropdownMenuTrigger
className={`
relative flex items-center gap-1 px-3 py-1.5 rounded text-sm
transition-colors outline-none focus-visible:ring-2 focus-visible:ring-gold/60
${menuActive
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
`}
aria-label={`${menu.label} — תפריט`}
>
<span>{menu.label}</span>
<ChevronDown className="size-3" aria-hidden="true" />
{menuActive && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[200px]">
{menu.items.map((item) => {
const active = isActive(pathname, item.href);
return (
<Fragment key={item.href}>
{item.groupLabel && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
{item.groupLabel}
</DropdownMenuLabel>
</>
)}
<DropdownMenuItem asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`flex items-center cursor-pointer ${active ? "font-semibold" : ""}`}
>
<span>{item.label}</span>
</Link>
</DropdownMenuItem>
</Fragment>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}