Phase 3c: Compose view with chair-position editor

New /cases/[caseNumber]/compose route ports the research analysis +
chair-position editing flow from the vanilla UI onto the Next.js
stack. Reads /api/cases/{n}/research/analysis, renders background
prose in the side column and threshold claims + issues as collapsible
cards in the main column, each with a blur-autosaved chair editor
wired through a TanStack Query mutation with optimistic cache patching
(so concurrent reads don't steal editor focus).

Handles the common "analysis not yet generated" 404 with a dedicated
empty state rather than an error card.

Phase 3 task 85 is now ready for review end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:09:09 +00:00
parent d0daa0efe8
commit 03b25bc273
4 changed files with 477 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSaveChairPosition } from "@/lib/api/research";
/*
* Chair-position editor for a single threshold claim or issue.
*
* Autosaves on blur, with an optimistic in-memory "last saved" value so the
* user sees immediate feedback. No debounced per-keystroke save — the user
* writes in long paragraphs and the backend writes to a file, so per-blur
* is the right granularity (matches the vanilla UI's behavior).
*/
type SaveState =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved"; at: Date }
| { kind: "error"; message: string };
export function ChairEditor({
caseNumber,
sectionId,
initialValue,
}: {
caseNumber: string;
sectionId: string;
initialValue: string;
}) {
const [value, setValue] = useState(initialValue);
const [state, setState] = useState<SaveState>({ kind: "idle" });
const lastSaved = useRef(initialValue);
const mutate = useSaveChairPosition(caseNumber);
/* Reset when the upstream analysis refetches (e.g. after initial load) */
useEffect(() => {
setValue(initialValue);
lastSaved.current = initialValue;
}, [initialValue]);
const save = async () => {
const trimmed = value.trim();
if (trimmed === lastSaved.current.trim()) return;
setState({ kind: "saving" });
try {
await mutate.mutateAsync({ sectionId, position: trimmed });
lastSaved.current = trimmed;
setState({ kind: "saved", at: new Date() });
} catch (e) {
setState({
kind: "error",
message: e instanceof Error ? e.message : "שגיאה בשמירה",
});
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[0.78rem] font-semibold text-navy">
עמדת ועדת הערר
</span>
<SaveIndicator state={state} />
</div>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={save}
rows={6}
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>
);
}
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>;
}