Merge pull request 'feat(ui): interactive decision-block viewer + inline editor on case page' (#57) from feat/decision-blocks-viewer into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 4m15s

feat(ui): interactive decision-block viewer + inline editor on case page (#57)
This commit was merged in pull request #57.
This commit is contained in:
2026-06-06 09:37:13 +00:00
5 changed files with 445 additions and 0 deletions

View File

@@ -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({
<TabsTrigger value="arguments">
טיעונים
</TabsTrigger>
<TabsTrigger value="decision">
ההחלטה
</TabsTrigger>
<TabsTrigger value="drafts">
טיוטות והערות
</TabsTrigger>
@@ -147,6 +151,10 @@ export default function CaseDetailPage({
<LegalArgumentsPanel caseNumber={caseNumber} />
</TabsContent>
<TabsContent value="decision" className="mt-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</TabsContent>
<TabsContent value="drafts" className="mt-5">
<DraftsPanel
caseNumber={caseNumber}

View File

@@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Markdown } from "@/components/ui/markdown";
import {
useDecisionBlocks,
useSaveBlock,
type DecisionBlock,
type BlockStatus,
} from "@/lib/api/decision-blocks";
import { BLOCK_LABELS } from "@/lib/api/feedback";
import { AlertTriangle, Pencil, FileText } from "lucide-react";
/* ── status badge styling ─────────────────────────────── */
const STATUS_LABELS: Record<BlockStatus, string> = {
empty: "ריק",
draft: "טיוטה",
review: "בבדיקה",
final: "סופי",
};
const STATUS_CLASSES: Record<BlockStatus, string> = {
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 <p className="text-sm text-ink-muted">טוען...</p>;
}
if (error) {
return (
<p className="text-sm text-danger">
שגיאה בטעינת תוכן ההחלטה: {error.message}
</p>
);
}
if (!data) return null;
const written = data.blocks.filter((b) => b.word_count > 0).length;
return (
<div className="space-y-4">
{/* ── Source-of-truth warning ── */}
{data.source_of_truth === "docx" && (
<div className="flex items-start gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<AlertTriangle className="w-5 h-5 text-gold-deep shrink-0 mt-0.5" />
<p className="text-sm text-gold-deep leading-relaxed">
קיים קובץ DOCX מתוקן המשמש כמקור האמת לתיק זה. עריכת בלוקים כאן
נשמרת ב-DB אך <strong>לא</strong> תעדכן את ה-DOCX עד הפקת טיוטה
מחדש.
</p>
</div>
)}
{/* ── Header line ── */}
<div className="flex items-center justify-between">
<h3 className="text-navy text-base">תוכן ההחלטה לפי בלוקים</h3>
<span className="text-xs text-ink-muted tabular-nums">
{written}/12 בלוקים נכתבו
</span>
</div>
{!data.has_decision && (
<p className="text-sm text-ink-muted">
טרם נכתבו בלוקים לתיק זה. ניתן להתחיל לכתוב בכל בלוק להלן הכתיבה
תיצור את ההחלטה אוטומטית.
</p>
)}
{/* ── 12 blocks ── */}
<Accordion type="multiple" className="space-y-2">
{data.blocks.map((block) => (
<AccordionItem
key={block.block_id}
value={block.block_id}
className="rounded-lg border border-rule bg-surface px-4"
>
<AccordionTrigger className="hover:no-underline py-3">
<div className="flex items-center gap-2 flex-wrap text-start">
<span className="text-sm font-medium text-navy">
{blockLabel(block)}
</span>
<Badge
className={`text-[0.65rem] border ${STATUS_CLASSES[block.status]}`}
>
{STATUS_LABELS[block.status]}
</Badge>
{block.word_count > 0 && (
<span className="text-[0.7rem] text-ink-muted tabular-nums">
{block.word_count} מילים
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent className="pb-4">
<BlockEditor caseNumber={caseNumber} block={block} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}
/* ── 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<SaveState>({ 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 (
<div className="space-y-3">
{block.content.trim() ? (
<Markdown content={block.content} />
) : (
<p className="text-sm text-ink-muted italic">בלוק ריק.</p>
)}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7"
onClick={() => setEditing(true)}
>
<Pencil className="w-3.5 h-3.5 me-1.5" />
ערוך
</Button>
</div>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[0.72rem] text-ink-muted flex items-center gap-1">
<FileText className="w-3.5 h-3.5" />
עריכת תוכן הבלוק (Markdown) נשמר בעת יציאה מהשדה
</span>
<SaveIndicator state={state} />
</div>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleSave}
rows={12}
dir="rtl"
placeholder="כתוב כאן את תוכן הבלוק. הטקסט נשמר אוטומטית כשעוזבים את השדה."
className="w-full resize-y rounded border border-rule bg-parchment px-3 py-2 text-sm leading-relaxed text-ink shadow-inner focus:border-gold focus:outline-none focus:ring-2 focus:ring-gold/30"
/>
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="h-7 text-ink-muted"
disabled={save.isPending}
onClick={() => setEditing(false)}
>
סיום עריכה
</Button>
</div>
</div>
);
}
function SaveIndicator({ state }: { state: SaveState }) {
if (state.kind === "idle") return null;
if (state.kind === "saving") {
return <span className="text-[0.72rem] text-ink-muted"> שומר</span>;
}
if (state.kind === "saved") {
const time = state.at.toLocaleTimeString("he-IL", {
hour: "2-digit",
minute: "2-digit",
});
return <span className="text-[0.72rem] text-success"> נשמר {time}</span>;
}
return <span className="text-[0.72rem] text-danger"> {state.message}</span>;
}

View File

@@ -0,0 +1,75 @@
/**
* Decision-blocks domain hooks.
*
* The 12-block decision content lives in the `decision_blocks` table and was
* previously only reachable via DOCX export. These hooks back the interactive
* block viewer/editor on the case page:
* GET /api/cases/{n}/decision-blocks → all 12 blocks (empty included)
* PUT /api/cases/{n}/decision-blocks/{block} → save inline-edited content
*
* The endpoints aren't declared with response models on the FastAPI side, so
* we maintain local types here (same convention as cases.ts).
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type BlockStatus = "empty" | "draft" | "review" | "final";
export type DecisionBlock = {
block_id: string;
block_index: number;
title: string;
content: string;
word_count: number;
status: BlockStatus;
generation_type: string;
model_used: string;
/** ISO timestamp; null when the block was never written */
updated_at: string | null;
};
export type DecisionBlocksResponse = {
case_number: string;
has_decision: boolean;
decision_id: string | null;
active_draft_path: string | null;
source_of_truth: "docx" | "blocks";
blocks: DecisionBlock[];
};
export type SaveBlockResponse = {
block: DecisionBlock;
active_draft_warning: boolean;
};
export const decisionBlocksKeys = {
all: ["decision-blocks"] as const,
case: (caseNumber: string) =>
[...decisionBlocksKeys.all, caseNumber] as const,
};
export function useDecisionBlocks(caseNumber: string) {
return useQuery({
queryKey: decisionBlocksKeys.case(caseNumber),
queryFn: ({ signal }) =>
apiRequest<DecisionBlocksResponse>(
`/api/cases/${caseNumber}/decision-blocks`,
{ signal },
),
});
}
export function useSaveBlock(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ blockId, content }: { blockId: string; content: string }) =>
apiRequest<SaveBlockResponse>(
`/api/cases/${caseNumber}/decision-blocks/${blockId}`,
{ method: "PUT", body: { content } },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: decisionBlocksKeys.case(caseNumber) });
},
});
}

View File

@@ -125,6 +125,10 @@ export const CATEGORY_COLORS: Record<FeedbackCategory, string> = {
/** Block ID labels */
export const BLOCK_LABELS: Record<string, string> = {
"block-alef": "א — כותרת מוסדית",
"block-bet": "ב — הרכב הוועדה",
"block-gimel": "ג — צדדים",
"block-dalet": "ד — החלטה",
"block-he": "ה — פתיחה",
"block-vav": "ו — רקע עובדתי",
"block-zayin": "ז — טענות הצדדים",
@@ -132,4 +136,5 @@ export const BLOCK_LABELS: Record<string, string> = {
"block-tet": "ט — תכניות חלות",
"block-yod": "י — דיון והכרעה",
"block-yod-alef": "יא — סיכום",
"block-yod-bet": "יב — חתימות",
};

View File

@@ -2575,6 +2575,123 @@ async def api_run_qa(case_number: str):
return {"passed": all_passed, "checks": checks, "status": new_status}
# ── Decision blocks — interactive view + inline edit ──
class BlockUpdateRequest(BaseModel):
content: str
def _serialize_block(row: dict, cfg: dict) -> dict:
"""Merge a decision_blocks DB row with its BLOCK_CONFIG skeleton."""
updated = row.get("updated_at") if row else None
return {
"block_id": cfg["block_id"],
"block_index": cfg["index"],
# Prefer the DB title (may be hand-edited); fall back to the canonical config title.
"title": (row.get("title") if row and row.get("title") else cfg["title"]),
"content": (row.get("content") if row else "") or "",
"word_count": (row.get("word_count") if row else 0) or 0,
"status": (row.get("status") if row else "empty") or "empty",
"generation_type": (row.get("generation_type") if row else cfg["gen_type"]),
"model_used": (row.get("model_used") if row else cfg["model"]),
"updated_at": updated.isoformat() if updated else None,
}
@app.get("/api/cases/{case_number}/decision-blocks")
async def api_get_decision_blocks(case_number: str):
"""Return all 12 decision blocks as JSON (empty blocks included).
Read path for the interactive block viewer — content lives in
decision_blocks but was previously only reachable via DOCX export.
"""
from legal_mcp.services.block_writer import BLOCK_CONFIG
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
case_id = UUID(case["id"])
# Canonical skeleton, ordered by block_index. Carries block_id into each cfg.
skeleton = [
{**cfg, "block_id": bid}
for bid, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"])
]
decision = await db.get_decision_by_case(case_id)
active_draft_path = await db.get_active_draft_path(case_id)
by_id: dict[str, dict] = {}
decision_id = None
if decision:
decision_id = decision["id"]
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""SELECT block_id, block_index, title, content, word_count, status,
generation_type, model_used, updated_at
FROM decision_blocks WHERE decision_id = $1""",
UUID(decision_id),
)
by_id = {r["block_id"]: dict(r) for r in rows}
blocks = [_serialize_block(by_id.get(cfg["block_id"]), cfg) for cfg in skeleton]
return {
"case_number": case["case_number"],
"has_decision": decision is not None,
"decision_id": decision_id,
"active_draft_path": active_draft_path,
"source_of_truth": "docx" if active_draft_path else "blocks",
"blocks": blocks,
}
@app.put("/api/cases/{case_number}/decision-blocks/{block_id}")
async def api_update_decision_block(
case_number: str, block_id: str, req: BlockUpdateRequest
):
"""Save inline-edited content for a single decision block.
Writes to decision_blocks (upsert, status='draft') and rebuilds the
on-disk decision.md. Creates a decision row if none exists yet.
"""
from legal_mcp.services import block_writer
from legal_mcp.services.block_writer import BLOCK_CONFIG
if block_id not in BLOCK_CONFIG:
raise HTTPException(404, f"בלוק לא ידוע: {block_id}")
case = await db.get_case_by_number(case_number)
if not case:
raise HTTPException(404, f"תיק {case_number} לא נמצא")
case_id = UUID(case["id"])
active_draft_path = await db.get_active_draft_path(case_id)
if active_draft_path:
logger.warning(
"Inline block edit on %s/%s while active_draft_path is set (%s) — "
"DB and DOCX may diverge.",
case_number, block_id, active_draft_path,
)
try:
result = await block_writer.save_block_content(case_id, block_id, req.content)
except ValueError as e:
raise HTTPException(400, str(e))
cfg = {**BLOCK_CONFIG[block_id], "block_id": block_id}
block = _serialize_block(
{
**result,
"status": "draft",
"updated_at": datetime.now(timezone.utc),
},
cfg,
)
return {"block": block, "active_draft_warning": bool(active_draft_path)}
@app.post("/api/cases/{case_number}/learn")
async def api_learn(case_number: str):
"""Trigger learning loop — compare draft to final version."""