#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>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|