feat(ui): chair approval center — one page for every pending human-gate (#63 follow-up)

Dafna asked for a single page under the prod site listing everything she needs
to approve, so nothing is forgotten — the visible embodiment of INV-G10 (human
gates) and INV-QA1 (halacha backlog must be visible).

Backend — GET /api/chair/pending aggregates every pending chair gate, each as a
direct source query (count + sample + action link):
- halachot review backlog (review_status='pending_review') + oldest
- open missing precedents
- unresolved chair_feedback
- qa_failed cases
- gold-set review (FU-5, file-based, best-effort: total vs source='chair')

Frontend — /approvals page ("מרכז אישורים"):
- src/lib/api/chair.ts — usePendingApprovals() (hand-typed until next api:types)
- src/app/approvals/page.tsx — card per category, severity-coloured count, sample
  rows, oldest-pending date, link to where each is handled; live (60s refetch)
- app-shell nav: "מרכז אישורים" in the work group + total-pending badge (quiet at 0)

Live counts at build time surfaced the value immediately: 226 open missing
precedents, 178 pending halachot, 20 unapplied feedback notes, 1 qa_failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 15:36:29 +00:00
parent aee2140b0b
commit 19d3dc81d0
4 changed files with 320 additions and 3 deletions

View File

@@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
usePendingApprovals,
type ApprovalCategory,
type ApprovalSeverity,
} from "@/lib/api/chair";
/**
* מרכז אישורים — דפנה (INV-G10).
*
* עמוד אחד שמרכז את כל השערים האנושיים הממתינים להכרעת היו"ר: אישור הלכות,
* פסיקה חסרה, הערות שטרם יושמו, תיקים שנכשלו ב-QA, וסקירת gold-set. המטרה:
* שאף פריט הדורש את אישורך לא יישכח. הנתונים נשלפים חי מ-/api/chair/pending.
*/
const SEVERITY_BADGE: Record<ApprovalSeverity, string> = {
high: "bg-gold text-navy border-transparent",
medium: "bg-gold-wash text-gold-deep border-gold/40",
low: "bg-rule-soft text-ink-muted border-rule",
ok: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
};
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;
return (
<Card className="bg-surface border-rule shadow-sm flex flex-col">
<CardContent className="px-6 py-5 flex flex-col gap-3 grow">
<div className="flex items-start justify-between gap-3">
<h2 className="text-navy text-lg mb-0 leading-snug">{cat.label}</h2>
<span
className={`inline-flex items-center justify-center min-w-[2.25rem] h-7 px-2 rounded-full border text-sm font-semibold ${SEVERITY_BADGE[cat.severity]}`}
aria-label={`${cat.count} פריטים ממתינים`}
>
{cat.count}
</span>
</div>
<p className="text-ink-muted text-[0.85rem] leading-relaxed mb-0">
{cat.description}
</p>
{cat.oldest_at && cat.count > 0 ? (
<p className="text-[0.78rem] text-gold-deep mb-0">
הישן ביותר ממתין מ־{formatDate(cat.oldest_at)}
</p>
) : null}
{cat.extra ? (
<p className="text-[0.78rem] text-ink-muted mb-0">
סך {cat.extra.total} שאילתות · {cat.extra.reviewed} אושרו על־ידך
</p>
) : null}
{cleared ? (
<p className="text-[0.85rem] text-emerald-700 mb-0">אין פריטים ממתינים </p>
) : cat.sample && cat.sample.length > 0 ? (
<ul className="space-y-1.5 mt-1">
{cat.sample.map((s, i) => (
<li key={i} className="text-[0.82rem] text-ink leading-snug border-s-2 border-rule ps-2.5">
<span className="line-clamp-2">{s.text || "—"}</span>
{s.source ? (
<span className="text-ink-muted text-[0.72rem]"> · {s.source}</span>
) : null}
</li>
))}
</ul>
) : null}
<div className="mt-auto pt-2">
{cat.href ? (
<Button asChild variant="outline" size="sm" className="border-rule">
<Link href={cat.href}>לטיפול </Link>
</Button>
) : (
<span className="text-[0.75rem] text-ink-muted">סקירה ידנית (ראה דוח FU-5)</span>
)}
</div>
</CardContent>
</Card>
);
}
export default function ApprovalsPage() {
const { data, isPending, error } = usePendingApprovals();
return (
<AppShell>
<section className="space-y-6">
<header className="space-y-1.5">
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">מרכז אישורים</span>
</nav>
<div className="flex items-end justify-between gap-4 flex-wrap">
<div className="space-y-1">
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
שערים אנושיים · יו״ר הוועדה
</div>
<h1 className="text-navy mb-0">מרכז אישורים</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl leading-relaxed">
כל מה שממתין להכרעתך במקום אחד כדי שאף פריט לא יישכח. מתעדכן חי.
</p>
</div>
{typeof data?.total_pending === "number" ? (
<div className="text-end">
<div className="text-3xl font-semibold text-navy leading-none">
{data.total_pending}
</div>
<div className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted mt-1">
פריטים ממתינים
</div>
</div>
) : null}
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-ink-muted text-sm">
שגיאה בטעינת מרכז האישורים. נסה לרענן.
</CardContent>
</Card>
) : isPending ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{[0, 1, 2, 3, 4].map((i) => (
<Card key={i} className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 h-44 animate-pulse" />
</Card>
))}
</div>
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{(data?.categories ?? []).map((cat) => (
<ApprovalCard key={cat.key} cat={cat} />
))}
</div>
)}
</section>
</AppShell>
);
}

View File

@@ -16,6 +16,7 @@ import {
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.
@@ -39,8 +40,9 @@ const NAV_GROUPS: NavGroup[] = [
{
id: "work",
items: [
{ href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" },
{ href: "/", label: "בית" },
{ href: "/approvals", label: "מרכז אישורים" },
{ href: "/archive", label: "ארכיון" },
],
},
{
@@ -244,6 +246,7 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
>
<span>{item.label}</span>
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
{item.href === "/approvals" ? <ApprovalsBadge /> : null}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
@@ -254,6 +257,23 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
);
}
/* 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() {

View File

@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "./client";
/**
* Chair approval center (INV-G10) — aggregates every pending human-gate item
* (halacha approvals, missing precedents, unapplied feedback, QA-failed cases,
* gold-set review) so nothing Dafna must approve is forgotten.
*
* Hand-typed (not from the generated types.ts) because /api/chair/pending is a
* new endpoint; switch to the generated type after the next `npm run api:types`.
*/
export type ApprovalSeverity = "high" | "medium" | "low" | "ok";
export type ApprovalSample = { text: string; source: string };
export type ApprovalCategory = {
key: string;
label: string;
description: string;
count: number;
severity: ApprovalSeverity;
href: string | null;
oldest_at?: string | null;
sample?: ApprovalSample[];
extra?: { total: number; reviewed: number };
};
export type PendingApprovals = {
total_pending: number;
generated_at: string;
categories: ApprovalCategory[];
};
export function usePendingApprovals() {
return useQuery({
queryKey: ["chair", "pending"],
queryFn: () => apiRequest<PendingApprovals>("/api/chair/pending"),
refetchInterval: 60_000,
staleTime: 30_000,
});
}