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>
242 lines
8.2 KiB
TypeScript
242 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 { formatTime } from "@/lib/format-date";
|
||
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 = formatTime(state.at);
|
||
return <span className="text-[0.72rem] text-success">✓ נשמר {time}</span>;
|
||
}
|
||
return <span className="text-[0.72rem] text-danger">⚠ {state.message}</span>;
|
||
}
|