From c35e0e50ed172ba124462556c1874d1c05289298 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 6 Jun 2026 09:36:51 +0000 Subject: [PATCH] feat(ui): interactive decision-block viewer + inline editor on case page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "ההחלטה" tab to the case detail page showing all 12 decision blocks with rendered markdown content and inline editing that saves back to the DB via two new FastAPI endpoints. Backend (web/app.py): - GET /api/cases/{n}/decision-blocks — returns all 12 blocks (empty ones included) merged from BLOCK_CONFIG + decision_blocks table. Exposes source_of_truth ("docx"|"blocks") and active_draft_path. - PUT /api/cases/{n}/decision-blocks/{block_id} — inline save via block_writer.save_block_content; warns (does not block) when an active DOCX draft exists. Frontend: - src/lib/api/decision-blocks.ts — typed hooks (useDecisionBlocks, useSaveBlock) following the cases.ts hand-written-module pattern. - src/components/cases/decision-blocks-panel.tsx — accordion of 12 blocks; view mode renders Markdown component; edit mode is a textarea with on-blur save (derived from ChairEditor pattern, setState-during- render for re-sync to avoid effect cascade). - BLOCK_LABELS in feedback.ts extended from 7 → 12 blocks. - cases/[caseNumber]/page.tsx — new "ההחלטה" tab wired to the panel. No DB migration required — decision_blocks + active_draft_path exist. Co-Authored-By: Claude Sonnet 4.6 --- web-ui/src/app/cases/[caseNumber]/page.tsx | 8 + .../cases/decision-blocks-panel.tsx | 240 ++++++++++++++++++ web-ui/src/lib/api/decision-blocks.ts | 75 ++++++ web-ui/src/lib/api/feedback.ts | 5 + web/app.py | 117 +++++++++ 5 files changed, 445 insertions(+) create mode 100644 web-ui/src/components/cases/decision-blocks-panel.tsx create mode 100644 web-ui/src/lib/api/decision-blocks.ts diff --git a/web-ui/src/app/cases/[caseNumber]/page.tsx b/web-ui/src/app/cases/[caseNumber]/page.tsx index da1f46f..f252362 100644 --- a/web-ui/src/app/cases/[caseNumber]/page.tsx +++ b/web-ui/src/app/cases/[caseNumber]/page.tsx @@ -14,6 +14,7 @@ import { StatusGuide } from "@/components/cases/status-guide"; import { StatusChanger } from "@/components/cases/status-changer"; import { DocumentsPanel } from "@/components/cases/documents-panel"; import { DraftsPanel } from "@/components/cases/drafts-panel"; +import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel"; import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel"; import { AgentActivityFeed } from "@/components/cases/agent-activity-feed"; import { AgentStatusWidget } from "@/components/cases/agent-status-widget"; @@ -81,6 +82,9 @@ export default function CaseDetailPage({ טיעונים + + ההחלטה + טיוטות והערות @@ -147,6 +151,10 @@ export default function CaseDetailPage({ + + + + = { + empty: "ריק", + draft: "טיוטה", + review: "בבדיקה", + final: "סופי", +}; + +const STATUS_CLASSES: Record = { + empty: "bg-rule-soft text-ink-muted border-rule", + draft: "bg-gold/10 text-gold-deep border-gold/30", + review: "bg-blue-50 text-blue-700 border-blue-200", + final: "bg-success-bg text-success border-success/40", +}; + +function blockLabel(b: DecisionBlock): string { + return BLOCK_LABELS[b.block_id] ?? b.title ?? b.block_id; +} + +/* ── Main panel ───────────────────────────────────────── */ + +export function DecisionBlocksPanel({ caseNumber }: { caseNumber: string }) { + const { data, isLoading, error } = useDecisionBlocks(caseNumber); + + if (isLoading) { + return

טוען...

; + } + if (error) { + return ( +

+ שגיאה בטעינת תוכן ההחלטה: {error.message} +

+ ); + } + if (!data) return null; + + const written = data.blocks.filter((b) => b.word_count > 0).length; + + return ( +
+ {/* ── Source-of-truth warning ── */} + {data.source_of_truth === "docx" && ( +
+ +

+ קיים קובץ DOCX מתוקן המשמש כמקור האמת לתיק זה. עריכת בלוקים כאן + נשמרת ב-DB אך לא תעדכן את ה-DOCX עד הפקת טיוטה + מחדש. +

+
+ )} + + {/* ── Header line ── */} +
+

תוכן ההחלטה לפי בלוקים

+ + {written}/12 בלוקים נכתבו + +
+ + {!data.has_decision && ( +

+ טרם נכתבו בלוקים לתיק זה. ניתן להתחיל לכתוב בכל בלוק להלן — הכתיבה + תיצור את ההחלטה אוטומטית. +

+ )} + + {/* ── 12 blocks ── */} + + {data.blocks.map((block) => ( + + +
+ + {blockLabel(block)} + + + {STATUS_LABELS[block.status]} + + {block.word_count > 0 && ( + + {block.word_count} מילים + + )} +
+
+ + + +
+ ))} +
+
+ ); +} + +/* ── Per-block view / edit ────────────────────────────── */ + +type SaveState = + | { kind: "idle" } + | { kind: "saving" } + | { kind: "saved"; at: Date } + | { kind: "error"; message: string }; + +function BlockEditor({ + caseNumber, + block, +}: { + caseNumber: string; + block: DecisionBlock; +}) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(block.content); + const [state, setState] = useState({ kind: "idle" }); + /* The last content known to be persisted — used to skip no-op saves. */ + const [baseline, setBaseline] = useState(block.content); + const save = useSaveBlock(caseNumber); + + /* Re-sync when the upstream query refetches (e.g. after another save) while + * not actively editing. Adjusting state during render — the documented React + * pattern for derived-from-props — avoids a setState-in-effect cascade. */ + if (!editing && block.content !== baseline) { + setBaseline(block.content); + setValue(block.content); + } + + async function handleSave() { + const next = value; + if (next === baseline) return; + setState({ kind: "saving" }); + try { + await save.mutateAsync({ blockId: block.block_id, content: next }); + setBaseline(next); + setState({ kind: "saved", at: new Date() }); + } catch (e) { + setState({ + kind: "error", + message: e instanceof Error ? e.message : "שגיאה בשמירה", + }); + } + } + + if (!editing) { + return ( +
+ {block.content.trim() ? ( + + ) : ( +

בלוק ריק.

+ )} +
+ +
+
+ ); + } + + return ( +
+
+ + + עריכת תוכן הבלוק (Markdown) — נשמר בעת יציאה מהשדה + + +
+