Files
legal-ai/web-ui/src/components/cases/positions-panel.tsx
Chaim 4d7e623607
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
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>
2026-06-30 18:52:07 +00:00

352 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}