שורת הניווט הצטמצמה מ-11 קישורים ישירים ל-4 קישורי-עבודה (בית · מרכז אישורים · הערות יו״ר · ארכיון) + 2 תפריטים נפתחים: - "פסיקה ▾": ספריית פסיקה · יומונים · פסיקה חסרה · —ניתוח וכיול— · מפת הקורפוס · מדגם-זהב - "סגנון ▾": אימון סגנון · מתודולוגיה מפת-הקורפוס, מדגם-זהב ומתודולוגיה הורדו-בדרגה מהשורה הראשית לתוך התפריטים (לפי בקשת היו"ר) — אך כל ה-routes נשמרים, אין שינוי URL. trigger התפריט מקבל הדגשה + קו-זהב תחתון כשאחד מילדיו פעיל; badge "פסיקה חסרה" מוצג גם על trigger "פסיקה" וגם בתוך הפריט. Invariants: מקיים G2 (איחוד מסלולי-ניווט, ללא יצירת מסלול מקביל — כל הדפים נותרים נגישים, deep-links נשמרים). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
363 lines
14 KiB
TypeScript
363 lines
14 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";
|
||
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
|
||
import { usePendingApprovals } from "@/lib/api/chair";
|
||
|
||
/**
|
||
* 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.
|
||
const WORK_LINKS: NavItem[] = [
|
||
{ href: "/", label: "בית" },
|
||
{ href: "/approvals", label: "מרכז אישורים" },
|
||
{ href: "/feedback", 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: "פסיקה",
|
||
items: [
|
||
{ href: "/precedents", label: "ספריית פסיקה" },
|
||
{ href: "/digests", label: "יומונים" },
|
||
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||
{ href: "/graph", label: "מפת הקורפוס", groupLabel: "ניתוח וכיול" },
|
||
{ href: "/goldset", label: "מדגם-זהב" },
|
||
],
|
||
},
|
||
{
|
||
id: "style",
|
||
label: "סגנון",
|
||
items: [
|
||
{ href: "/training", label: "אימון סגנון" },
|
||
{ href: "/methodology", label: "מתודולוגיה" },
|
||
],
|
||
},
|
||
];
|
||
|
||
const ADMIN_ITEMS: NavItem[] = [
|
||
{ href: "/skills", label: "מיומנויות" },
|
||
{ href: "/diagnostics", 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>
|
||
{item.href === "/approvals" ? <ApprovalsBadge /> : null}
|
||
{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));
|
||
const hasMissingPrecedents = menu.items.some((i) => i.href === "/missing-precedents");
|
||
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>
|
||
{hasMissingPrecedents ? <MissingPrecedentsBadge /> : null}
|
||
<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>
|
||
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
/* Total pending-approvals badge next to "מרכז אישורים" — Dafna's outstanding
|
||
* human-gate items (halachot + missing precedents + feedback + qa_failed).
|
||
* Renders only when >0 so the nav stays quiet when everything is cleared. */
|
||
function ApprovalsBadge() {
|
||
const { data } = usePendingApprovals();
|
||
const total = data?.total_pending ?? 0;
|
||
if (!total) return null;
|
||
return (
|
||
<span
|
||
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
|
||
aria-label={`${total} פריטים ממתינים לאישורך`}
|
||
>
|
||
{total}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* Small open-count badge next to "פסיקה חסרה" — only renders when >0
|
||
* so the nav stays quiet in normal operation. */
|
||
function MissingPrecedentsBadge() {
|
||
const { data: openCount } = useMissingPrecedentsOpenCount();
|
||
if (!openCount) return null;
|
||
return (
|
||
<span
|
||
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
|
||
aria-label={`${openCount} פסיקות חסרות פתוחות`}
|
||
>
|
||
{openCount}
|
||
</span>
|
||
);
|
||
}
|