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>
211 lines
8.1 KiB
TypeScript
211 lines
8.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|