Files
legal-ai/web-ui/src/components/cases/decision-blocks-panel.tsx
Chaim 2b591f5018
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
fix(case-page): resolve full-page review findings (bugs + RTL + resilience)
Full code review of the case-detail page (14 components) surfaced these,
all fixed here:

Logic bugs
- case-edit-dialog: form.reset ran on the 5s useCase refetch while the dialog
  was open, clobbering in-progress edits. Now resets only on open→true.
- status-changer: `selected` never synced to async/external `currentStatus`
  (stale dropdown). Reworked to track currentStatus until an explicit pick;
  resets to tracking after save.
- decision-blocks-panel: `block.content`/`word_count` accessed without null
  guards (endpoint has no response model) → potential render crash. Coerced
  with `?? ""` / `?? 0`. `STATUS_LABELS[status]` now falls back to the raw
  status instead of rendering literal "undefined".
- document-type-editor: `await mutateAsync()` in async click handlers without
  try/catch → unhandled promise rejection. Wrapped (errors still surface via
  isError).

Resilience / hygiene
- page.tsx: a transient 5xx on the background poll flipped the WHOLE page to
  the error card and discarded loaded data. Now gated on `!data`, plus a
  "נסה שוב" retry.
- cases.ts useUpdateCase: invalidated casesKeys.all, which re-invalidated the
  detail it had just optimistically patched. Scoped to the list prefix.

RTL correctness (logical properties)
- agent-status-widget `mr-auto`→`ms-auto`; agent-activity-feed `mr-auto`→
  `me-auto`, icon `ml-1/ml-2`→`me-1/me-2`, required `*` `mr-1`→`ms-1`;
  document-type-editor list `pr-4`→`ps-4`.

Minors
- drafts-panel: `<a href>`→`next/link` (operations + citation links) for SPA
  nav. agent-activity-feed: issueMap memoized; comment Textarea aria-label.
  upload-sheet: `unknown` status no longer shown as green success (neutral
  icon + "רענן לאישור"). citations.ts: case_name typed `string | null`.

Design gate: visual-touching items (RTL gap side, retry button, neutral
upload icon) were chair-authorized via the reviewed-findings approval ("fix
all"); none alter an approved page layout — they are correctness fixes.
tsc clean; eslint clean (1 pre-existing form.watch warning, untouched line).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:57:43 +00:00

244 lines
8.2 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";
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) > 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] ?? block.status}
</Badge>
{(block.word_count ?? 0) > 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;
}) {
// The endpoint has no FastAPI response model, so `content` may arrive null
// for an empty block — coerce to "" to keep `.trim()` / state safe.
const content = block.content ?? "";
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(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(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 && content !== baseline) {
setBaseline(content);
setValue(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">
{content.trim() ? (
<Markdown content={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>;
}