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>
244 lines
8.2 KiB
TypeScript
244 lines
8.2 KiB
TypeScript
"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>;
|
||
}
|