Files
legal-ai/web-ui/src/app/approvals/page.tsx
Chaim 67c2c43777
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(web-ui): display all human-facing timestamps in Asia/Jerusalem (deterministic)
Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is
localized, and now deterministically: every timestamp renders pinned to
Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and
the browser agree on any runtime. No layout/visible-format change — only the tz.

- New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate /
  formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull /
  formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey /
  israelMidnightMs / israelParts + formatRelative (long/short/tight wording).
- Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString +
  hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call
  sites (30 files) through it. Number toLocaleString left untouched.
- Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display).

Display-only; no layout/design/IA change → Claude Design gate N/A.
Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9.

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

211 lines
8.1 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 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";
import { formatDateLong } from "@/lib/format-date";
/**
* מרכז אישורים — דפנה (INV-G10).
*
* עמוד אחד שמרכז את כל השערים האנושיים הממתינים להכרעת היו"ר: אישור הלכות,
* פסיקה חסרה, הערות שטרם יושמו, ותיקים שנכשלו ב-QA. המטרה:
* שאף פריט הדורש את אישורך לא יישכח. הנתונים נשלפים חי מ-/api/chair/pending.
*/
// Severity expressed as a colored dot next to the title (matches the approved
// IA-redesign mockup): high=danger, medium=warn, low=info, ok=success.
const SEVERITY_DOT: Record<ApprovalSeverity, string> = {
high: "bg-danger",
medium: "bg-warn",
low: "bg-info",
ok: "bg-success",
};
// #2 refresh — a thin severity rail on the card's inline-start edge replaces the
// old small count-pill; the big count number stays. high=danger…ok=success.
const SEVERITY_RAIL: Record<ApprovalSeverity, string> = {
high: "border-s-danger",
medium: "border-s-warn",
low: "border-s-info",
ok: "border-s-success",
};
const formatDate = (iso?: string | null) => formatDateLong(iso);
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;
return (
<Card
className={`flex flex-col overflow-hidden rounded-[10px] border-rule border-s-[3px] ${SEVERITY_RAIL[cat.severity]} ${
cleared ? "bg-parchment shadow-none" : "bg-surface shadow-sm"
}`}
>
<CardContent className="p-0 flex flex-col grow">
{/* top row — severity dot · title+age · big count number (mockup 01) */}
<div className="flex items-start gap-3 px-5 pt-5 pb-2">
<span
className={`mt-2 h-2.5 w-2.5 shrink-0 rounded-full ${SEVERITY_DOT[cat.severity]}`}
aria-hidden
/>
<div className="grow min-w-0">
<h2 className="text-navy text-base font-semibold mb-0 leading-snug">{cat.label}</h2>
<div className="text-[0.78rem] text-ink-muted mt-0.5">
{cleared ? (
<span className="inline-block rounded-full bg-success-bg text-success text-[0.72rem] px-2.5 py-0.5 font-semibold">
תור נקי
</span>
) : cat.oldest_at ? (
<>הוותיק ביותר {formatDate(cat.oldest_at)}</>
) : (
cat.description
)}
</div>
</div>
<span
className={`text-3xl font-bold leading-none tabular-nums shrink-0 ${
cleared ? "text-ink-muted" : "text-navy"
}`}
aria-label={`${cat.count} פריטים ממתינים`}
>
{cat.count}
</span>
</div>
{/* description kept (subtle) when the age line took the title slot */}
{!cleared && cat.oldest_at ? (
<p className="px-5 text-[0.8rem] text-ink-muted leading-relaxed mb-0">{cat.description}</p>
) : null}
{cat.extra ? (
<p className="px-5 mt-1 text-[0.78rem] text-ink-muted mb-0">
סך {cat.extra.total} שאילתות · {cat.extra.reviewed} אושרו על־ידך
</p>
) : null}
{!cleared && cat.sample && cat.sample.length > 0 ? (
<ul className="mt-3 px-5 border-t border-rule-soft">
{cat.sample.map((s, i) => {
const row = (
<div className="flex items-start gap-2">
<span className="line-clamp-2 text-ink-soft">{s.text || "—"}</span>
{s.source ? (
<span className="text-ink-muted text-[0.72rem] ms-auto shrink-0 whitespace-nowrap">
{s.source}
</span>
) : null}
</div>
);
return (
<li
key={i}
className="text-[0.82rem] py-2.5 border-b border-rule-soft last:border-b-0"
>
{s.href ? (
<Link href={s.href} className="block hover:text-gold-deep">
{row}
</Link>
) : (
row
)}
</li>
);
})}
</ul>
) : cleared ? (
<p className="px-5 mt-2 text-[0.85rem] text-ink-muted mb-0">
אין פריטים הממתינים להתייחסות. כל התיקים עברו בדיקת-איכות.
</p>
) : null}
{/* foot — gold CTA when actionable, quiet outline when cleared */}
<div className="mt-auto px-5 pt-4 pb-5">
{cat.href ? (
cleared ? (
<Button asChild variant="outline" size="sm" className="border-rule text-ink-muted">
<Link href={cat.href}>צפייה </Link>
</Button>
) : (
<Button
asChild
size="sm"
className="bg-gold text-white hover:bg-gold-deep border-transparent"
>
<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="inline-flex items-baseline gap-2 rounded-lg border border-rule bg-gold-wash px-4 py-2.5">
<span className="text-2xl font-semibold text-gold-deep leading-none tabular-nums">
{data.total_pending}
</span>
<span className="text-[0.85rem] text-ink-soft">פריטים ממתינים</span>
</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>
);
}