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>
198 lines
8.9 KiB
TypeScript
198 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Loader2, ChevronLeft, Check } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
|
||
type DraftFinalPair,
|
||
} from "@/lib/api/learning";
|
||
import { formatDateShort as fmtDate } from "@/lib/format-date";
|
||
|
||
/**
|
||
* /training "למידה" tab (T6/T13) — the reconciliation ledger (INV-LRN4): every
|
||
* decision and its draft↔final comparison status, plus per-case style-distance
|
||
* (T7). The list is the chair's "have we closed each decision against Dafna's
|
||
* final?" surface.
|
||
*/
|
||
|
||
const STATUS_LABEL: Record<string, string> = {
|
||
final_received: "התקבל סופי — ממתין לניתוח",
|
||
analyzed: "נותח (הצעות ממתינות)",
|
||
lessons_folded: "לקחים הוטמעו",
|
||
};
|
||
const STATUS_CLASS: Record<string, string> = {
|
||
final_received: "bg-gold-wash text-gold-deep border-gold/40",
|
||
analyzed: "bg-navy-soft/20 text-navy",
|
||
lessons_folded: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
|
||
};
|
||
|
||
function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) {
|
||
const { data, isPending, error } = useStyleDistance(caseNumber);
|
||
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
||
if (error || data?.error) return <p className="text-danger text-[0.75rem]">{data?.error || "שגיאה"}</p>;
|
||
if (!data) return null;
|
||
const s = data.summary;
|
||
return (
|
||
<div className="rounded-md bg-rule-soft/30 border border-rule p-3 space-y-2 text-[0.8rem]">
|
||
<div className="flex gap-4 flex-wrap">
|
||
<span>שינוי draft→final: <b className="tabular-nums">{s.change_percent ?? "—"}%</b></span>
|
||
<span>סטיית יחסי-זהב מקס׳: <b className="tabular-nums">{s.ratio_max_deviation_pp ?? "—"} נק׳</b></span>
|
||
<span>אנטי-דפוסים: <b className="tabular-nums">{s.anti_pattern_total}</b></span>
|
||
</div>
|
||
{Object.keys(data.golden_ratio_adherence.sections).length > 0 && (
|
||
<div className="flex gap-3 flex-wrap text-[0.72rem] text-ink-muted">
|
||
{Object.entries(data.golden_ratio_adherence.sections).map(([sec, v]) => (
|
||
<span key={sec} className={v.deviation_pp > 0 ? "text-danger" : ""}>
|
||
{sec}: {v.actual_pct}% (יעד {v.target[0]}-{v.target[1]})
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{data.anti_pattern_hits.total > 0 && (
|
||
<div className="text-[0.72rem] text-danger">
|
||
{Object.entries(data.anti_pattern_hits.by_pattern).map(([n, v]) => (
|
||
<span key={n} className="me-3">{n}: {v.count}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SelectableItem({ text, selected, onToggle }: {
|
||
text: string; selected: boolean; onToggle: () => void;
|
||
}) {
|
||
return (
|
||
<button type="button" onClick={onToggle}
|
||
className={`w-full flex items-start gap-2 rounded-md border p-2 text-right text-[0.78rem] transition-colors ${
|
||
selected ? "border-navy bg-navy-soft/15" : "border-rule bg-surface hover:bg-rule-soft/30"
|
||
}`}>
|
||
<span className={`mt-0.5 w-4 h-4 shrink-0 rounded border flex items-center justify-center ${
|
||
selected ? "bg-navy border-navy text-parchment" : "border-rule"
|
||
}`}>
|
||
{selected && <Check className="w-3 h-3" />}
|
||
</span>
|
||
<span className="flex-1">{text}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/** T14 — chair approval gate (INV-G10/LRN1): review the curator's style_method
|
||
* proposal and fold approved lessons/phrases into writer-consumed channels. */
|
||
function ProposalReview({ pairId }: { pairId: string }) {
|
||
const { data, isPending, error } = usePairDetail(pairId);
|
||
const promote = usePromoteLearning(pairId);
|
||
const [lessons, setLessons] = useState<Set<string>>(new Set());
|
||
const [phrases, setPhrases] = useState<Set<string>>(new Set());
|
||
|
||
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
||
if (error || !data) return <p className="text-danger text-[0.75rem]">שגיאה בטעינת ההצעה.</p>;
|
||
|
||
const lessonTexts = data.changes.map((c) => c.lesson).filter((l): l is string => Boolean(l));
|
||
const toggle = (set: Set<string>, setFn: (s: Set<string>) => void, v: string) => {
|
||
const next = new Set(set);
|
||
next.has(v) ? next.delete(v) : next.add(v);
|
||
setFn(next);
|
||
};
|
||
const total = lessons.size + phrases.size;
|
||
|
||
return (
|
||
<div className="rounded-md bg-gold-wash/30 border border-gold/40 p-3 space-y-3 text-[0.8rem]">
|
||
<p className="text-ink-muted text-[0.75rem]">
|
||
הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר).
|
||
</p>
|
||
{data.overall_assessment && (
|
||
<p className="italic text-ink-muted">{data.overall_assessment}</p>
|
||
)}
|
||
{lessonTexts.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<div className="font-semibold text-navy text-[0.75rem]">לקחי-סגנון → כללי-דיון</div>
|
||
{lessonTexts.map((l, i) => (
|
||
<SelectableItem key={`l${i}`} text={l} selected={lessons.has(l)}
|
||
onToggle={() => toggle(lessons, setLessons, l)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{data.new_expressions.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<div className="font-semibold text-navy text-[0.75rem]">ביטויי-מעבר חדשים</div>
|
||
{data.new_expressions.map((p, i) => (
|
||
<SelectableItem key={`p${i}`} text={p} selected={phrases.has(p)}
|
||
onToggle={() => toggle(phrases, setPhrases, p)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
{lessonTexts.length === 0 && data.new_expressions.length === 0 && (
|
||
<p className="text-ink-muted">אין הצעות style_method.</p>
|
||
)}
|
||
<div className="flex justify-end">
|
||
<Button size="sm" disabled={total === 0 || promote.isPending}
|
||
className="bg-navy text-parchment hover:bg-navy-soft"
|
||
onClick={() => promote.mutate(
|
||
{ lessons: [...lessons], phrases: [...phrases] },
|
||
{
|
||
onSuccess: (r) => toast.success(`הוטמעו ${r.folded_lessons} לקחים + ${r.folded_phrases} ביטויים`),
|
||
onError: (e) => toast.error(e instanceof Error ? e.message : "שגיאה"),
|
||
},
|
||
)}>
|
||
{promote.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
|
||
: <Check className="w-3.5 h-3.5 me-1" />}
|
||
אשר והטמע {total > 0 ? `(${total})` : ""}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ pair }: { pair: DraftFinalPair }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<div className="rounded-lg border border-rule bg-surface overflow-hidden">
|
||
<button type="button" onClick={() => setOpen((o) => !o)}
|
||
className="w-full flex items-center gap-3 px-4 py-3 text-right hover:bg-gold-wash/30 transition-colors">
|
||
<ChevronLeft className={`w-4 h-4 text-ink-muted shrink-0 transition-transform ${open ? "-rotate-90" : ""}`} />
|
||
<div className="flex-1 min-w-0 text-right">
|
||
<div className="font-semibold text-navy truncate">{pair.case_number || "—"}{pair.title ? ` — ${pair.title}` : ""}</div>
|
||
<div className="text-[0.72rem] text-ink-muted">עודכן {fmtDate(pair.updated_at)}</div>
|
||
</div>
|
||
{pair.change_percent != null && (
|
||
<Badge variant="outline" className="tabular-nums text-[0.65rem]">Δ {pair.change_percent}%</Badge>
|
||
)}
|
||
<Badge variant="outline" className={`text-[0.65rem] ${STATUS_CLASS[pair.status] ?? ""}`}>
|
||
{STATUS_LABEL[pair.status] ?? pair.status}
|
||
</Badge>
|
||
</button>
|
||
{open && (
|
||
<div className="px-4 pb-3 space-y-3">
|
||
{pair.status === "analyzed" && <ProposalReview pairId={pair.id} />}
|
||
{pair.case_number && <StyleDistanceDetail caseNumber={pair.case_number} />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LearningPanel() {
|
||
const { data, isPending, error } = useReconciliationLedger();
|
||
if (error) return <p className="text-danger text-sm">שגיאה בטעינת הפנקס.</p>;
|
||
if (isPending) return <Loader2 className="w-5 h-5 animate-spin text-ink-muted" />;
|
||
|
||
const items = data?.items ?? [];
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-[0.8rem] text-ink-muted">
|
||
פנקס-ההתאמה (INV-LRN4): כל החלטה נסגרת מול הסופי של דפנה; כל סופי מנותח מול הטיוטה.
|
||
לחיצה על תיק → מדד מרחק-הסגנון שלו.
|
||
</p>
|
||
{items.length === 0 ? (
|
||
<p className="text-ink-muted text-sm py-8 text-center">אין עדיין השוואות. הן נוצרות כשמסמנים החלטה כסופית.</p>
|
||
) : (
|
||
items.map((p) => <Row key={p.id} pair={p} />)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|