feat(case-ui): merge /compose editor into case tabs (X17 #3)
Folds the standalone /compose decision-editor route into the main case page so there is no more "sub-page" (mockup 18f), with the final 6-tab workflow order: סקירה · טיעונים ועמדות · אימות פסיקה · ההחלטה · טיוטות והערות · סוכנים. - New PositionsPanel — the chair's editing surface (threshold claims + issues SubsectionCards + ChairEditor + background prose + case precedents + finish rail), extracted verbatim from the former /compose "עמדות וטענות" tab. Drops only the doc-strip (the overview DocumentsPanel is the sole owner — no dup). - "טיעונים ועמדות" tab = PositionsPanel above + the aggregated by-party LegalArgumentsPanel in a collapsible below. - "אימות פסיקה" promoted to its own top-level tab (CitationVerificationPanel, was /compose tab 3); placed BEFORE "ההחלטה" — verify citations, then write (INV-AH: the writer cites only verified precedents). - "ההחלטה" stays the single 12-block editor (DecisionBlocksPanel) — no longer duplicated between /compose and the case page. - Tabs are now controlled; the "פתח עורך החלטה" CTA switches to the ההחלטה tab instead of navigating to the deleted route. - /compose route deleted; header-context /compose special-case removed. No duplication remains (G2): blocks editor single, docs single (overview), citation-verify single. Design-gate: mockup 18f approved in Claude Design X17. tsc --noEmit + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
351
web-ui/src/components/cases/positions-panel.tsx
Normal file
351
web-ui/src/components/cases/positions-panel.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
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 { PrecedentsSection } from "@/components/compose/precedents-section";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { useCase } from "@/lib/api/cases";
|
||||
import {
|
||||
useCaseLearningStatus,
|
||||
type CaseLearningStatus,
|
||||
} from "@/lib/api/learning";
|
||||
import { useResearchAnalysis } from "@/lib/api/research";
|
||||
import { useCasePrecedents } from "@/lib/api/precedents";
|
||||
|
||||
/*
|
||||
* PositionsPanel — the chair's editing surface for the analyst's threshold
|
||||
* claims + issues (chair positions feed block י). Extracted verbatim from the
|
||||
* former /compose "עמדות וטענות" tab when the decision editor was merged into
|
||||
* the main case page (X17 #3). Lives inside the "טיעונים ועמדות" tab above the
|
||||
* collapsible by-party aggregated arguments. The 12-block editor + citation
|
||||
* verification moved to their own top-level tabs; /compose was deleted.
|
||||
*/
|
||||
|
||||
// ── Staged-pipeline indicator text — derived from the live learning-status,
|
||||
// same source as the drafts-panel LearningStatusBadges. ──────────────────────
|
||||
function voiceLearningText(s?: CaseLearningStatus): string {
|
||||
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
||||
const v = s.voice_learning;
|
||||
if (v.outcome === "succeeded") {
|
||||
const bits = [`${v.lessons_count} לקחים הופקו`];
|
||||
if (v.lessons_proposed > 0) bits.push(`${v.lessons_proposed} הוצעו לאישור`);
|
||||
return `✓ הושלם · ${bits.join(" · ")}`;
|
||||
}
|
||||
if (v.outcome === "failed") return v.error ? `✗ נכשל — ${v.error}` : "✗ נכשל";
|
||||
return "ממתין להרצה";
|
||||
}
|
||||
|
||||
function halachaExtractionText(s?: CaseLearningStatus): string {
|
||||
if (!s?.final_uploaded) return "ממתין להעלאת הסופי";
|
||||
const h = s.halacha_extraction;
|
||||
if (!h.enrolled_in_corpus)
|
||||
return h.not_enrolled_reason ?? "לא נכנס לקורפוס-הפסיקה";
|
||||
switch (h.status) {
|
||||
case "completed":
|
||||
return `✓ הושלם · חולצו ${h.halachot_count} · ${h.approved} אושרו · ${h.rejected} נדחו`;
|
||||
case "processing":
|
||||
return "רץ עכשיו…";
|
||||
case "pending":
|
||||
case "busy":
|
||||
return "בתור";
|
||||
case "partial":
|
||||
return `חלקי · חולצו ${h.halachot_count}`;
|
||||
case "failed":
|
||||
case "extraction_failed":
|
||||
return "✗ נכשל";
|
||||
case "no_chunks":
|
||||
return "אין טקסט לחילוץ";
|
||||
default:
|
||||
return "ממתין להרצה";
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<Markdown content={content.trim()} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── "השלמה והעברה" rail card — DOCX export, analysis upload/download (real) ──
|
||||
function FinishRail({
|
||||
caseNumber,
|
||||
hasAnalysis,
|
||||
onUploaded,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
hasAnalysis: boolean;
|
||||
onUploaded: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadMsg, setUploadMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const learning = useCaseLearningStatus(caseNumber);
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setUploading(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`/api/cases/${caseNumber}/research/analysis/upload`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setUploadMsg({ ok: false, text: data.detail || "שגיאה בהעלאה" });
|
||||
return;
|
||||
}
|
||||
setUploadMsg({
|
||||
ok: true,
|
||||
text: `הקובץ הועלה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
|
||||
});
|
||||
onUploaded();
|
||||
} catch {
|
||||
setUploadMsg({ ok: false, text: "שגיאת רשת" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-4 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".md"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleUpload(f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
ייצוא DOCX
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full justify-center bg-gold text-white hover:bg-gold-deep"
|
||||
disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{uploading ? "מעלה…" : "העלאת ניתוח מעודכן"}
|
||||
</Button>
|
||||
{hasAnalysis && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
|
||||
a.download = `analysis-${caseNumber}.md`;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
הורד ניתוח (MD)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadMsg && (
|
||||
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
|
||||
{uploadMsg.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* stage indicators — informational pointers, not actions */}
|
||||
<div className="mt-3 space-y-0">
|
||||
<div className="text-[0.78rem] text-ink-muted pt-2 border-t border-rule-soft">
|
||||
<b className="text-navy">הרץ למידת-קול</b> — {voiceLearningText(learning.data)}
|
||||
</div>
|
||||
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
|
||||
<b className="text-navy">הרץ אימות-הלכות</b> — {halachaExtractionText(learning.data)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function PositionsPanel({ caseNumber }: { caseNumber: string }) {
|
||||
const caseQuery = useCase(caseNumber);
|
||||
const analysis = useResearchAnalysis(caseNumber);
|
||||
const precedentsQuery = useCasePrecedents(caseNumber);
|
||||
|
||||
const allPrecedents = precedentsQuery.data ?? [];
|
||||
const caseLevelPrecedents = allPrecedents.filter((p) => p.section_id === null);
|
||||
const precedentsBySection = new Map<string, typeof allPrecedents>();
|
||||
for (const p of allPrecedents) {
|
||||
if (p.section_id) {
|
||||
const existing = precedentsBySection.get(p.section_id) ?? [];
|
||||
existing.push(p);
|
||||
precedentsBySection.set(p.section_id, existing);
|
||||
}
|
||||
}
|
||||
const practiceArea = caseQuery.data?.practice_area ?? null;
|
||||
|
||||
const isNotFound =
|
||||
analysis.error instanceof Error &&
|
||||
/404|לא נמצא|טרם בוצע/.test(analysis.error.message);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-ink-muted text-[0.78rem]">
|
||||
סוגיות-המחלוקת ועמדות הצדדים. עורך עמדת-היו״ר נשמר אוטומטית ומזין את בלוק י׳ (דיון והכרעה).
|
||||
</p>
|
||||
|
||||
{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-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="space-y-6">
|
||||
{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-lg font-semibold 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="grid gap-3 lg:grid-cols-2 items-start">
|
||||
{analysis.data.threshold_claims.map((tc) => (
|
||||
<SubsectionCard
|
||||
key={tc.id}
|
||||
caseNumber={caseNumber}
|
||||
item={tc}
|
||||
precedents={precedentsBySection.get(tc.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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-lg font-semibold 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="grid gap-3 lg:grid-cols-2 items-start">
|
||||
{analysis.data.issues.map((iss) => (
|
||||
<SubsectionCard
|
||||
key={iss.id}
|
||||
caseNumber={caseNumber}
|
||||
item={iss}
|
||||
precedents={precedentsBySection.get(iss.id) ?? []}
|
||||
practiceArea={practiceArea}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-6 py-5 space-y-5">
|
||||
<h2 className="text-navy text-lg font-semibold 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-6 py-5 space-y-3">
|
||||
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
|
||||
<Markdown content={analysis.data.conclusions.trim()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* case-level פסיקה מצורפת + finish/export gates (INV-IA3: not removed) */}
|
||||
<div className="grid gap-4 lg:grid-cols-2 items-start pt-2">
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
<CardContent className="px-5 py-4">
|
||||
<h3 className="text-navy text-[0.9rem] font-semibold mb-1">פסיקה מצורפת</h3>
|
||||
<p className="text-[0.72rem] text-ink-muted mb-3">
|
||||
ציטוטים התומכים בעמדה באופן רוחבי — ישולבו בפתיחת בלוק י (דיון).
|
||||
</p>
|
||||
<PrecedentsSection
|
||||
caseNumber={caseNumber}
|
||||
sectionId={null}
|
||||
precedents={caseLevelPrecedents}
|
||||
practiceArea={practiceArea}
|
||||
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FinishRail
|
||||
caseNumber={caseNumber}
|
||||
hasAnalysis={!!analysis.data}
|
||||
onUploaded={() => analysis.refetch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user