Files
legal-ai/web-ui/src/components/training/lessons-tab.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

323 lines
13 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";
/*
* Per-decision lessons editor — lives inside CorpusDetailDrawer's
* "מה למדנו" tab. Lessons are persisted in the decision_lessons table
* (one-to-many on style_corpus) and consumed by hermes-curator and
* future style_analyzer runs as context.
*
* The chair can:
* - Add a lesson typed manually (category = "general" by default)
* - Edit / delete existing lessons
* - Approve / reject a lesson (review_status — INV-LRN1/G10): only an
* approved lesson flows to the writer. This is the SINGLE writer gate;
* the old informative-only applied_to_skill flag was removed (LRN-1/INV-IA3).
*
* Lessons from the curator arrive with source="curator" and are visually
* distinguished by a badge so the chair can audit auto-suggestions.
*/
import { useState } from "react";
import {
Plus, Save, Trash2, Loader2, Check, X, Undo2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useAddLesson,
useCorpusLessons,
useDeleteLesson,
usePatchLesson,
type DecisionLesson,
} from "@/lib/api/training";
import { formatDateShort } from "@/lib/format-date";
const CATEGORIES = [
{ value: "general", label: "כללי" },
{ value: "style", label: "סגנון" },
{ value: "structure", label: "מבנה" },
{ value: "lexicon", label: "לקסיקון" },
{ value: "tabular", label: "טבלאי" },
] as const;
// All labels in Hebrew. Keyed loosely (string) so new sources — e.g. the
// two-judge style panel — render correctly with a graceful fallback.
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
manual: { label: "ידני", cls: "bg-rule-soft text-ink-soft" },
chair: { label: "יו״ר", cls: "bg-gold-wash text-gold-deep" },
curator: { label: "הרמס (סקירה)", cls: "bg-info-bg text-info" },
style_analyzer: { label: "מנתח-סגנון", cls: "bg-success-bg text-success" },
"panel:deepseek+gemini": { label: "פאנל: דיפסיק+גמיני", cls: "bg-gold-wash text-gold-deep" },
// #158/INV-LRN8 — a super-lesson merging overlapping lessons; navy-filled so it
// reads as the consolidated/elevated one.
synthesis: { label: "סינתזה", cls: "bg-navy text-parchment" },
};
const SOURCE_BADGE_FALLBACK = { label: "פאנל", cls: "bg-rule-soft text-ink-soft" };
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
const REVIEW_BADGE: Record<string, { label: string; cls: string }> = {
proposed: { label: "ממתין לאישור", cls: "bg-gold-wash text-gold-deep border-gold/40" },
approved: { label: "מאושר · זורם לכותב", cls: "bg-success-bg text-success border-success/40" },
rejected: { label: "נדחה", cls: "bg-rule-soft text-ink-muted line-through" },
// #158/INV-LRN8 — merged into a synthesis super-lesson; kept as provenance, no
// longer fed to the writer. Muted (not struck — it wasn't rejected).
superseded: { label: "מוזג", cls: "bg-rule-soft text-ink-muted" },
};
export function LessonsTab({ corpusId }: { corpusId: string }) {
const { data, isPending } = useCorpusLessons(corpusId);
const add = useAddLesson(corpusId);
const [draftText, setDraftText] = useState("");
const [draftCategory, setDraftCategory] = useState<DecisionLesson["category"]>("general");
const onAdd = async () => {
const text = draftText.trim();
if (!text) return;
try {
await add.mutateAsync({ lesson_text: text, category: draftCategory });
setDraftText("");
setDraftCategory("general");
toast.success("הלקח נוסף");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בשמירה");
}
};
return (
<div className="space-y-4">
{/* Composer */}
<Card className="bg-surface border-rule">
<CardContent className="px-4 py-3 space-y-2">
<h4 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold">
הוסף לקח להחלטה
</h4>
<Textarea
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder="מה למדנו מההחלטה הזו? למשל: 'דפנה מעדיפה הוצאות מתונות (5K-10K ₪) גם בערר שהתקבל במלואו'"
rows={3}
dir="rtl"
disabled={add.isPending}
/>
<div className="flex items-center gap-2">
<Select
value={draftCategory}
onValueChange={(v) => setDraftCategory(v as DecisionLesson["category"])}
disabled={add.isPending}
dir="rtl"
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="grow" />
<Button onClick={onAdd} disabled={add.isPending || !draftText.trim()}
className="bg-navy text-parchment hover:bg-navy-soft">
{add.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Plus className="w-4 h-4 me-1" />
)}
שמור לקח
</Button>
</div>
</CardContent>
</Card>
{/* List */}
{isPending ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !data || data.length === 0 ? (
<p className="text-center text-ink-muted text-sm py-6">
אין עדיין לקחים להחלטה זו. הוסף לקח ראשון מלמעלה.
</p>
) : (
<div className="space-y-2">
{data.map((lesson) => (
<LessonItem key={lesson.id} lesson={lesson} corpusId={corpusId} />
))}
</div>
)}
</div>
);
}
function LessonItem({
lesson, corpusId,
}: { lesson: DecisionLesson; corpusId: string }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(lesson.lesson_text);
const [category, setCategory] = useState<DecisionLesson["category"]>(lesson.category);
const patch = usePatchLesson(corpusId);
const del = useDeleteLesson(corpusId);
const sourceBadge = SOURCE_BADGE[lesson.source] ?? SOURCE_BADGE_FALLBACK;
const reviewBadge = REVIEW_BADGE[lesson.review_status] ?? REVIEW_BADGE.proposed;
const dirty = text !== lesson.lesson_text || category !== lesson.category;
const onSetReview = async (review_status: DecisionLesson["review_status"]) => {
try {
await patch.mutateAsync({ id: lesson.id, patch: { review_status } });
toast.success(
review_status === "approved" ? "אושר — הלקח יזרום לכותב"
: review_status === "rejected" ? "נדחה — לא יזרום לכותב"
: "הוחזר להמתנה",
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בעדכון");
}
};
const onSave = async () => {
try {
await patch.mutateAsync({
id: lesson.id,
patch: dirty ? { lesson_text: text, category } : {},
});
setEditing(false);
toast.success("הלקח עודכן");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בעדכון");
}
};
const onDelete = async () => {
if (!window.confirm("למחוק את הלקח?")) return;
try {
await del.mutateAsync(lesson.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל במחיקה");
}
};
const mergedCount = lesson.source === "synthesis" ? (lesson.synthesized_from?.length ?? 0) : 0;
return (
<Card className={`bg-surface border-rule ${lesson.review_status === "superseded" ? "opacity-70" : ""}`}>
<CardContent className="px-4 py-3 space-y-2">
<div className="flex items-center gap-2 text-[0.72rem]">
<Badge variant="outline"
className="bg-rule-soft text-ink-soft">
{CATEGORIES.find((c) => c.value === lesson.category)?.label || lesson.category}
</Badge>
<Badge variant="outline" className={sourceBadge.cls}>
{sourceBadge.label}
</Badge>
<Badge variant="outline" className={reviewBadge.cls}>
{reviewBadge.label}
</Badge>
<span className="grow text-ink-muted tabular-nums">
{formatDateShort(lesson.created_at)}
</span>
</div>
{mergedCount > 0 && (
<div className="text-[0.72rem] text-ink-muted">
מוזג מ-<b className="text-navy font-semibold">{mergedCount} לקחים</b> · דחייה תשחזר אותם
</div>
)}
{editing ? (
<>
<Textarea value={text} onChange={(e) => setText(e.target.value)}
rows={3} dir="rtl" />
<div className="flex items-center gap-2">
<Select value={category}
onValueChange={(v) => setCategory(v as DecisionLesson["category"])}
dir="rtl">
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="grow" />
<Button variant="ghost" size="sm"
onClick={() => { setEditing(false); setText(lesson.lesson_text); setCategory(lesson.category); }}>
ביטול
</Button>
<Button size="sm" onClick={onSave} disabled={patch.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
<Save className="w-3 h-3 me-1" />
שמור
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-ink leading-relaxed whitespace-pre-wrap"
onClick={() => setEditing(true)}
style={{ cursor: "text" }}>
{lesson.lesson_text}
</p>
<div className="flex items-center gap-2">
{/* review gate — only an approved lesson flows to the writer */}
{lesson.review_status === "approved" ? (
<Button variant="ghost" size="sm" onClick={() => onSetReview("proposed")}
disabled={patch.isPending}
className="text-ink-muted hover:text-ink">
<Undo2 className="w-3 h-3 me-1" />
בטל אישור
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={() => onSetReview("approved")}
disabled={patch.isPending}
className="text-success hover:text-success hover:bg-success-bg">
<Check className="w-3 h-3 me-1" />
אשר
</Button>
{lesson.review_status === "rejected" ? (
<Button variant="ghost" size="sm" onClick={() => onSetReview("proposed")}
disabled={patch.isPending} className="text-ink-muted hover:text-ink">
<Undo2 className="w-3 h-3 me-1" />
שחזר
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => onSetReview("rejected")}
disabled={patch.isPending}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3 h-3 me-1" />
דחה
</Button>
)}
</>
)}
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
ערוך
</Button>
<div className="grow" />
<Button variant="ghost" size="sm" onClick={onDelete}
disabled={del.isPending}
className="text-danger hover:text-danger hover:bg-danger-bg">
<Trash2 className="w-3 h-3" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
}