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,198 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { SubsectionCard } from "@/components/compose/subsection-card";
import { useCase } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research";
function ProseSection({ title, content }: { title: string; content?: string }) {
if (!content?.trim()) return null;
return (
<section className="space-y-2">
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
{title}
</h3>
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose">
{content.trim()}
</p>
</section>
);
}
export default function ComposePage({
params,
}: {
params: Promise<{ caseNumber: string }>;
}) {
const { caseNumber } = use(params);
const caseQuery = useCase(caseNumber);
const analysis = useResearchAnalysis(caseNumber);
const isNotFound =
analysis.error instanceof Error &&
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
return (
<AppShell>
<section className="space-y-6">
{/* Header strip */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
<Link
href={`/cases/${caseNumber}`}
className="hover:text-gold-deep"
>
ערר {caseNumber}
</Link>
<span aria-hidden>·</span>
<span className="text-navy">עורך החלטה</span>
</nav>
<h1 className="text-navy mb-0">ניתוח משפטי וכתיבת עמדה</h1>
{caseQuery.data?.title && (
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
{caseQuery.data.title}
</p>
)}
</div>
<Button asChild variant="outline">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</div>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{analysis.isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
) : isNotFound ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-12 text-center space-y-3">
<div className="text-gold text-3xl" aria-hidden></div>
<h2 className="text-navy text-lg mb-0">
טרם בוצע ניתוח משפטי לתיק זה
</h2>
<p className="text-ink-muted text-sm max-w-md mx-auto">
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
</p>
</CardContent>
</Card>
) : analysis.error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-5 text-center">
<p className="text-danger">{analysis.error.message}</p>
</CardContent>
</Card>
) : analysis.data ? (
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Main editable column */}
<div className="space-y-6">
{/* Threshold claims */}
{analysis.data.threshold_claims &&
analysis.data.threshold_claims.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-navy text-xl mb-0">טענות סף</h2>
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
{analysis.data.threshold_claims.length}
</span>
</div>
<div className="space-y-3">
{analysis.data.threshold_claims.map((tc, i) => (
<SubsectionCard
key={tc.id}
caseNumber={caseNumber}
item={tc}
defaultOpen={i === 0}
/>
))}
</div>
</div>
)}
{/* Issues */}
{analysis.data.issues && analysis.data.issues.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-navy text-xl mb-0">סוגיות להכרעה</h2>
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
{analysis.data.issues.length}
</span>
</div>
<div className="space-y-3">
{analysis.data.issues.map((iss) => (
<SubsectionCard
key={iss.id}
caseNumber={caseNumber}
item={iss}
/>
))}
</div>
</div>
)}
{(!analysis.data.threshold_claims?.length &&
!analysis.data.issues?.length) && (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-10 text-center text-ink-muted">
לא נמצאו טענות סף או סוגיות בניתוח זה.
</CardContent>
</Card>
)}
</div>
{/* Side column: background prose + conclusions */}
<aside className="space-y-5">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-5">
<h2 className="text-navy text-base mb-0">רקע לניתוח</h2>
<ProseSection
title="צד מיוצג"
content={analysis.data.represented_party}
/>
<ProseSection
title="רקע דיוני"
content={analysis.data.procedural_background}
/>
<ProseSection
title="עובדות מוסכמות"
content={analysis.data.agreed_facts}
/>
<ProseSection
title="עובדות במחלוקת"
content={analysis.data.disputed_facts}
/>
</CardContent>
</Card>
{analysis.data.conclusions?.trim() && (
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-5 py-4 space-y-2">
<h2 className="text-gold-deep text-base mb-0">מסקנות</h2>
<p className="text-sm text-ink leading-relaxed whitespace-pre-line prose">
{analysis.data.conclusions.trim()}
</p>
</CardContent>
</Card>
)}
</aside>
</div>
) : null}
</section>
</AppShell>
);
}

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>;
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { ChairEditor } from "@/components/compose/chair-editor";
import type { ResearchSubsection } from "@/lib/api/research";
export function SubsectionCard({
caseNumber,
item,
defaultOpen = false,
}: {
caseNumber: string;
item: ResearchSubsection;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const isFilled = Boolean(item.chair_position?.trim());
return (
<article className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="
w-full flex items-center gap-3 px-4 py-3 text-right
hover:bg-gold-wash/30 transition-colors
focus:outline-none focus-visible:bg-gold-wash/40
"
aria-expanded={open}
>
<span
className="
inline-flex items-center justify-center shrink-0
w-7 h-7 rounded-full
bg-navy text-parchment font-display font-bold text-sm
tabular-nums
"
>
{item.number}
</span>
<span className="flex-1 text-navy font-semibold text-base leading-snug">
{item.title}
</span>
<span
className={`
text-[0.72rem] rounded-full px-2.5 py-0.5 border shrink-0
${
isFilled
? "bg-success-bg text-success border-success/40"
: "bg-rule-soft text-ink-muted border-rule"
}
`}
>
{isFilled ? "✓ עמדה נקבעה" : "ממתין לעמדה"}
</span>
<ChevronDown
className={`w-4 h-4 text-ink-muted transition-transform ${open ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
{open && (
<div className="border-t border-rule px-5 py-4 space-y-4 bg-parchment/40">
{item.fields.length > 0 && (
<dl className="space-y-3">
{item.fields.map((f, i) => (
<div key={i}>
<dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1">
{f.label}
</dt>
<dd className="text-sm text-ink-soft leading-relaxed whitespace-pre-line">
{f.content}
</dd>
</div>
))}
</dl>
)}
<ChairEditor
caseNumber={caseNumber}
sectionId={item.id}
initialValue={item.chair_position ?? ""}
/>
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,95 @@
/**
* Research analysis hooks — reads and mutates the
* `analysis-and-research.md` file that backs each case's compose screen.
*
* Schema mirrors research_md.parse() in the FastAPI backend. Kept as
* hand-typed interfaces because the endpoint does not declare a
* response_model in the OpenAPI schema.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type ResearchField = {
label: string;
content: string;
};
export type ResearchSubsection = {
id: string; // e.g. "threshold_1" or "issue_3"
number: string;
title: string;
fields: ResearchField[];
chair_position?: string;
};
export type ResearchAnalysis = {
header?: {
date?: string;
modified_at?: string;
[k: string]: unknown;
};
represented_party?: string;
procedural_background?: string;
agreed_facts?: string;
disputed_facts?: string;
threshold_claims?: ResearchSubsection[];
issues?: ResearchSubsection[];
conclusions?: string;
other_sections?: Array<{ title: string; body: string }>;
};
export const researchKeys = {
all: ["research"] as const,
analysis: (caseNumber: string) =>
[...researchKeys.all, "analysis", caseNumber] as const,
};
export function useResearchAnalysis(caseNumber: string | undefined) {
return useQuery({
queryKey: researchKeys.analysis(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<ResearchAnalysis>(
`/api/cases/${caseNumber}/research/analysis`,
{ signal },
),
enabled: Boolean(caseNumber),
/* No polling — the user is editing; refetching would clobber focus */
staleTime: 60_000,
});
}
export function useSaveChairPosition(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (vars: { sectionId: string; position: string }) =>
apiRequest<unknown>(
`/api/cases/${caseNumber}/research/analysis/chair-position`,
{
method: "PATCH",
body: { section_id: vars.sectionId, position: vars.position },
},
),
onSuccess: (_res, vars) => {
/* Locally patch the cached analysis so other consumers stay in sync
without an immediate refetch that would steal focus from the editor. */
qc.setQueryData<ResearchAnalysis | undefined>(
researchKeys.analysis(caseNumber ?? ""),
(prev) => {
if (!prev) return prev;
const patch = (arr?: ResearchSubsection[]) =>
arr?.map((s) =>
s.id === vars.sectionId
? { ...s, chair_position: vars.position }
: s,
);
return {
...prev,
threshold_claims: patch(prev.threshold_claims),
issues: patch(prev.issues),
};
},
);
},
});
}