Files
legal-ai/web-ui/src/app/cases/[caseNumber]/compose/page.tsx
Chaim 28daff58be Pre-existing agent updates + analysis DOCX export
Updates accumulated from prior sessions:
- HEARTBEAT: company-based filtering (CMP/CMPA) rules
- legal-qa, legal-researcher: routine updates
- analysis_docx_exporter: new service for analysis DOCX export
- compose page: "הורד כ-DOCX" button for analysis
- decision_template.docx: template for exporter

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:49:10 +00:00

325 lines
12 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 { use, useRef, useState } 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 { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown";
import { useCase } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents";
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>
);
}
function AnalysisActions({
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);
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 (
<div className="flex items-center gap-2 flex-wrap">
{uploadMsg && (
<span className={`text-xs ${uploadMsg.ok ? "text-green-700" : "text-red-600"}`}>
{uploadMsg.text}
</span>
)}
<input
ref={fileRef}
type="file"
accept=".md"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
}}
/>
<Button
variant="outline"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "מעלה..." : "העלה ניתוח מעודכן"}
</Button>
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
a.download = `analysis-${caseNumber}.md`;
a.click();
}}
>
הורד ניתוח
</Button>
)}
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
a.click();
}}
>
הורד כ-DOCX
</Button>
)}
<Button asChild variant="outline">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</div>
);
}
export default function ComposePage({
params,
}: {
params: Promise<{ caseNumber: string }>;
}) {
const { caseNumber } = use(params);
const caseQuery = useCase(caseNumber);
const analysis = useResearchAnalysis(caseNumber);
const precedentsQuery = useCasePrecedents(caseNumber);
/* Partition the flat list into scopes so each child renders its own slice
* without re-fetching. Done once at the page level. */
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 (
<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>
<AnalysisActions caseNumber={caseNumber} hasAnalysis={!!analysis.data} onUploaded={() => analysis.refetch()} />
</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="space-y-6">
{/* Case-level general precedents */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-xl mb-1">פסיקה כללית לדיון</h2>
<p className="text-[0.78rem] text-ink-muted mb-4">
ציטוטים התומכים בעמדה באופן רוחבי ישולבו בפתיחת בלוק י (דיון).
</p>
<PrecedentsSection
caseNumber={caseNumber}
sectionId={null}
precedents={caseLevelPrecedents}
practiceArea={practiceArea}
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
/>
</CardContent>
</Card>
{/* 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) => (
<SubsectionCard
key={tc.id}
caseNumber={caseNumber}
item={tc}
precedents={precedentsBySection.get(tc.id) ?? []}
practiceArea={practiceArea}
/>
))}
</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}
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>
)}
{/* Background prose — moved below the issues so it reads as
supporting context after the chair has seen the main
decision points, not as a wall of text beside them. */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-5">
<h2 className="text-navy text-xl 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-xl mb-0">מסקנות</h2>
<Markdown content={analysis.data.conclusions.trim()} />
</CardContent>
</Card>
)}
</div>
) : null}
</section>
</AppShell>
);
}