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:
198
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal file
198
web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
web-ui/src/components/compose/chair-editor.tsx
Normal file
96
web-ui/src/components/compose/chair-editor.tsx
Normal 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>;
|
||||||
|
}
|
||||||
88
web-ui/src/components/compose/subsection-card.tsx
Normal file
88
web-ui/src/components/compose/subsection-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
web-ui/src/lib/api/research.ts
Normal file
95
web-ui/src/lib/api/research.ts
Normal 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),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user