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:
161
web-ui/src/app/approvals/page.tsx
Normal file
161
web-ui/src/app/approvals/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user