Files
legal-ai/web-ui/src/components/cases/drafts-panel.tsx
Chaim 9e7ef4fced
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 31s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
refactor(export): שם קובץ טיוטת-הביניים → טיוטה-טענות_הצדדים_{N} (הנחיית חיים)
במקום "טיוטת-ביניים-v{N}.docx" — שם מדבר יותר: "טיוטה-טענות_הצדדים_{N}.docx".
הפורמט הסופי ("טיוטה-v{N}") לא משתנה.

- docx_exporter._draft_naming: (prefix, sep) פר-מצב; interim=("טיוטה-טענות_הצדדים","_"),
  final=("טיוטה","-v"). glob/parse משתמשים ב-rsplit(sep,1) — נכון גם כשה-prefix
  עצמו מכיל "_".
- app.py _generation_output_ready + frontend pickLatestVersioned: matcher-prefix
  מעודכן; regex-הגרסה עוגן-לסוף /[_v](\d+)$/ (ה-"_" שבתוך הprefix לא מבלבל).
- docstrings + legal-ceo.md + הערות מעודכנים לשם החדש. types.ts (auto-gen)
  יתעדכן ב-api:types.

שינוי-נתון (שם-קובץ), לא layout — בגדר חריג-שער-העיצוב. tsc+eslint+py_compile
ירוקים. הערה: ייצור-השם רץ ב-MCP המקומי (host) → דורש reload; ה-matchers
בקונטיינר (Coolify).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:31:30 +00:00

1027 lines
40 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 Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
useExports,
useExportDocx,
useUploadDraft,
useMarkFinal,
useDeleteDraft,
useActiveDraft,
useUploadFinalDecision,
useRunFinalLearning,
useRunFinalHalacha,
type ExportFile,
} from "@/lib/api/exports";
import {
usePartyClaimsSummary,
useGeneratePartyClaimsSummary,
useGenerateInterimDraft,
useGenerateStatus,
} from "@/lib/api/generate";
import { GenerateStatusChip } from "@/components/cases/generate-status-chip";
import {
useCaseFeedback,
useCreateFeedback,
useResolveFeedback,
CATEGORY_LABELS,
CATEGORY_COLORS,
BLOCK_LABELS,
type FeedbackCategory,
} from "@/lib/api/feedback";
import { useCaseCitations } from "@/lib/api/citations";
import { learningKeys } from "@/lib/api/learning";
import { LearningStatusBadges } from "@/components/cases/learning-status-badges";
import { formatDateTime, formatDateShort } from "@/lib/format-date";
import type { CaseStatus } from "@/lib/api/cases";
import { toast } from "sonner";
import {
FileText,
Download,
Upload,
Award,
Loader2,
FileOutput,
Plus,
Trash2,
Brain,
Scale,
Stamp,
Link2,
AlertTriangle,
Zap,
ClipboardList,
Files,
ExternalLink,
} from "lucide-react";
/* Statuses at which a draft is considered ready */
const DRAFT_READY: CaseStatus[] = [
"drafted",
"exported",
"reviewed",
"final",
];
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
function formatDate(epoch: number): string {
return formatDateTime(epoch * 1000);
}
/**
* Pick the newest export file whose name starts with `prefix`, preferring the
* highest v-number and falling back to the latest created_at. Used by the
* "הפקת מסמכים" card to surface the most recent טיוטה-טענות_הצדדים_* file (#214).
*/
function pickLatestVersioned(
files: ExportFile[] | undefined,
prefix: string,
): (ExportFile & { version: number | null }) | null {
const matches = (files ?? []).filter((f) => f.filename.startsWith(prefix));
if (!matches.length) return null;
const withVer = matches.map((f) => {
// Version is the trailing integer, after either "-v" (final) or "_"
// (interim: טיוטה-טענות_הצדדים_{N}). Anchor to the end so the "_" inside
// the interim prefix isn't mistaken for the version separator.
const m = f.filename.match(/[_v](\d+)(?:\.\w+)?$/);
return { ...f, version: m ? parseInt(m[1], 10) : null };
});
withVer.sort((a, b) => {
if (a.version != null && b.version != null) return b.version - a.version;
return b.created_at - a.created_at;
});
return withVer[0];
}
/* ── Main component ─────────────────────────────────── */
export function DraftsPanel({
caseNumber,
status,
}: {
caseNumber: string;
status?: CaseStatus;
}) {
const { data: exports, isLoading: exportsLoading } = useExports(caseNumber);
const { data: feedbacks, isLoading: feedbackLoading } =
useCaseFeedback(caseNumber);
const { data: activeDraft } = useActiveDraft(caseNumber);
const exportDocx = useExportDocx(caseNumber);
const uploadDraft = useUploadDraft(caseNumber);
const markFinal = useMarkFinal(caseNumber);
const deleteDraft = useDeleteDraft(caseNumber);
const resolveMutation = useResolveFeedback();
const uploadFinal = useUploadFinalDecision(caseNumber);
const runLearning = useRunFinalLearning(caseNumber);
const runHalacha = useRunFinalHalacha(caseNumber);
// ── Document generation (#214) — the "הפקת מסמכים" card ──
const { data: summary } = usePartyClaimsSummary(caseNumber);
const genSummary = useGeneratePartyClaimsSummary(caseNumber);
const genInterim = useGenerateInterimDraft(caseNumber);
// Server-derived background-run status (#227 ג) — survives navigation/reload.
const summaryStatus = useGenerateStatus(caseNumber, "party_claims_summary");
const interimStatus = useGenerateStatus(caseNumber, "interim_draft");
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const finalFileRef = useRef<HTMLInputElement>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const isDraftReady = status && DRAFT_READY.includes(status);
const openFeedbacks = feedbacks?.filter((f) => !f.resolved) ?? [];
// The chair's signed final exists once a "סופי-" file is present (or any is_final).
const hasFinal = Boolean(
exports?.some((f) => f.is_final || f.filename.startsWith("סופי-")),
);
// Determine draft label based on *actual* v-numbers in filenames (not counts).
// "(מתוקנת)" suffix appears when there's at least one עריכה-* file.
const draftLabel = (() => {
if (!exports?.length) return "טיוטה מוכנה לעיון";
const drafts = exports.filter((f) => f.filename.startsWith("טיוטה-"));
const revisions = exports.filter((f) => f.filename.startsWith("עריכה-"));
if (!drafts.length) return "טיוטה מוכנה לעיון";
const versions = drafts
.map((f) => {
const m = f.filename.match(/v(\d+)/);
return m ? parseInt(m[1], 10) : 0;
})
.filter((n) => n > 0);
const maxVer = versions.length ? Math.max(...versions) : drafts.length;
const suffix = revisions.length > 0 ? " (מתוקנת)" : "";
return `טיוטה v${maxVer}${suffix} מוכנה לעיון`;
})();
// ── "הפקת מסמכים" derived state (#214) ──
// Latest interim ("party-claims") partial draft in the exports table — the poll
// target for button 2. The CEO writes it as טיוטה-טענות_הצדדים_{N}.docx.
const latestInterim = pickLatestVersioned(exports, "טיוטה-טענות_הצדדים_");
const summaryReady = Boolean(summary?.markdown);
function handleGenerateSummary() {
genSummary.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת סיכום-המנהלים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת סיכום-המנהלים"),
});
}
function handleGenerateInterim() {
genInterim.mutate(undefined, {
onSuccess: (d) => {
if (d.status === "ok") {
toast.success("הופעלה הפקת טיוטת טענות-הצדדים — רצה ברקע (אופוס)");
} else {
toast.warning(`לא הופעלה ההפקה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת הפקת הטיוטה החלקית"),
});
}
function handleUpload(file: File) {
uploadDraft.mutate(file, {
onSuccess: (data) => {
const added = data.bookmarks_added?.length ?? 0;
const missing = data.missing_blocks?.length ?? 0;
const fallback = data.structural_fallback?.length ?? 0;
const realDetected = added - fallback;
if (data.apply_status === "completed" || data.apply_status === "ok") {
if (realDetected > 0) {
toast.success(`הועלה: ${data.filename} — זוהו ${realDetected} בלוקי תוכן`);
} else {
toast.success(`הועלה: ${data.filename}`);
}
if (missing > 0) {
toast.warning(
`שימו לב: ${missing} בלוקי תוכן לא זוהו — בדוק את הכותרות`,
);
}
} else {
toast.error(`הועלה אך השילוב נכשל: ${data.apply_status ?? "שגיאה"}`);
}
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),
});
}
function handleExport() {
exportDocx.mutate(undefined, {
onSuccess: () => toast.success("הטיוטה יוצאה בהצלחה"),
onError: () => toast.error("שגיאה בייצוא"),
});
}
function handleUploadFinal(file: File) {
uploadFinal.mutate(file, {
onSuccess: (data) => {
toast.success(
`ההחלטה הסופית נקלטה — ${data.final_words} מילים (לעומת ${data.draft_words} בטיוטה)`,
);
const lib = data.library;
if (lib?.enrolled) {
toast.success(
`נוספה לספריית-הפסיקה · ${lib.linked ?? 0} ציטוטים קושרו · ` +
`${lib.missing_flagged ?? 0} חסרים סומנו להעלאה`,
);
} else if (lib?.error) {
toast.warning(`לא נוספה לספריית-הפסיקה: ${lib.error}`);
}
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בהעלאה"),
});
}
function handleRunLearning() {
runLearning.mutate(undefined, {
onSuccess: (d) => {
// Background run — refresh the indicator (the poll picks up later transitions).
qc.invalidateQueries({ queryKey: learningKeys.caseStatus(caseNumber) });
if (d.status === "ok") {
toast.success("למידת-הקול הופעלה — רצה ברקע (אופוס + פאנל דיפסיק/גמיני)");
} else {
toast.warning(`לא הופעלה למידה: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת למידת-הקול"),
});
}
function handleRunHalacha() {
runHalacha.mutate(undefined, {
onSuccess: (d) => {
qc.invalidateQueries({ queryKey: learningKeys.caseStatus(caseNumber) });
if (d.status === "ok") {
toast.success("אימות-ההלכות הופעל — רץ ברקע (פאנל אופוס/דיפסיק/גמיני)");
} else {
toast.warning(`לא הופעל אימות: ${d.reason ?? d.error ?? d.status}`);
}
},
onError: () => toast.error("שגיאה בהפעלת אימות-ההלכות"),
});
}
function handleMarkFinal(filename: string) {
markFinal.mutate(filename, {
onSuccess: () => toast.success("סומן כסופי"),
onError: () => toast.error("שגיאה בסימון"),
});
}
function handleResolve(id: string) {
// fold=false: resolving from the per-case panel is bookkeeping only.
// Folding the lesson into the knowledge files is driven from /feedback.
resolveMutation.mutate(
{ feedbackId: id, applied_to: [], fold: false },
{
onSuccess: () => toast.success("ההערה סומנה כמטופלת"),
onError: () => toast.error("שגיאה בעדכון"),
},
);
}
return (
// flex + per-card `order` so the visual order matches mockup 26:
// generate-card → banner → active-draft → drafts → final+learning → feedback.
<div className="flex flex-col gap-6">
{/* ── Card: document generation (mockup 26) — 3 deterministic "הפק" buttons ── */}
<section className="order-0 rounded-lg border border-rule bg-surface overflow-hidden">
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-rule-soft bg-parchment">
<Zap className="w-[18px] h-[18px] text-gold-deep shrink-0" />
<h3 className="text-navy text-base font-semibold">הפקת מסמכים</h3>
</div>
<div className="p-4">
<p className="text-xs text-ink-muted leading-relaxed mb-1">
הפקה דטרמיניסטית בלחיצה בלי בקשת-טקסט. כל כפתור מריץ פעולה מדויקת,
והקובץ מופיע בטבלת-הטיוטות / לקריאה מקומית.
</p>
{/* 1 — executive summary of party claims */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft first-of-type:border-t-0">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<ClipboardList className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">הפק סיכום מנהלים</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
תמצית מזוקקת של טענות הצדדים לקראת הדיון מסמך נפרד מהטיוטה.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateSummary}
disabled={genSummary.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genSummary.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
<GenerateStatusChip
status={summaryStatus.data}
onRetry={handleGenerateSummary}
retrying={genSummary.isPending}
/>
{summaryReady && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/download`,
"_blank",
)
}
>
<ExternalLink className="w-3.5 h-3.5 me-1" />
פתח
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-gold-deep"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/research/party-claims-summary/export-docx`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
DOCX
</Button>
</>
)}
</div>
</div>
</div>
{/* 2 — partial draft (parties' claims, blocks ה–ט) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<FileText className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטה של טענות הצדדים{" "}
<span className="font-medium text-ink-muted text-xs">
(טיוטה חלקית)
</span>
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
בלוקים הט (רקע, טענות, הליכים, תכניות) עד הדיון, בלי דיון
והכרעה. נשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleGenerateInterim}
disabled={genInterim.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{genInterim.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
"הפק"
)}
</Button>
<GenerateStatusChip
status={interimStatus.data}
onRetry={handleGenerateInterim}
retrying={genInterim.isPending}
/>
{latestInterim && (
<span className="text-[0.7rem] text-ink-muted">
{latestInterim.version
? `v${latestInterim.version} · `
: ""}
{formatDate(latestInterim.created_at)}
</span>
)}
</div>
</div>
</div>
{/* 3 — full decision draft (REUSES useExportDocx; moved from the banner) */}
<div className="flex items-start gap-3 py-3 border-t border-rule-soft">
<span className="flex w-9 h-9 shrink-0 items-center justify-center rounded-lg border border-rule bg-gold-wash text-gold-deep">
<Files className="w-[18px] h-[18px]" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-navy">
הפק טיוטת החלטה מלאה
</div>
<p className="text-xs text-ink-muted mt-0.5 leading-relaxed">
ההחלטה המלאה כל 12 הבלוקים, כולל דיון והכרעה. נקבעת
כמקור-האמת ונשמרת בטבלת-הטיוטות.
</p>
<div className="flex flex-wrap items-center gap-2.5 mt-2">
<Button
size="sm"
onClick={handleExport}
disabled={exportDocx.isPending || !isDraftReady}
title={!isDraftReady ? "זמין כשיש בלוקים כתובים" : undefined}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{exportDocx.isPending ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin me-1.5" />
מייצר
</>
) : (
<>
<FileOutput className="w-3.5 h-3.5 me-1.5" />
הפק
</>
)}
</Button>
{!isDraftReady && (
<span className="rounded bg-warn-bg px-2 py-0.5 text-[0.65rem] text-warn">
זמין כשיש בלוקים כתובים
</span>
)}
</div>
</div>
</div>
</div>
</section>
{/* ── Banner — draft-ready indicator (export button moved to "הפקת מסמכים") ── */}
{isDraftReady && (
<div className="order-1 flex items-center gap-3 rounded-lg border border-gold/40 bg-gold-wash px-4 py-3">
<FileText className="w-5 h-5 text-gold-deep shrink-0" />
<span className="text-sm font-medium text-gold-deep">
{draftLabel}
</span>
</div>
)}
{/* ── Active-draft badge — the DOCX that is the current source of truth ── */}
{activeDraft?.filename && (
<div className="order-2 flex items-center gap-2 text-xs text-ink-muted">
<span>מקור האמת:</span>
<Badge variant="outline" className="bg-surface">
{activeDraft.filename}
</Badge>
</div>
)}
{/* ── Chair's signed final decision — clean upload + staged learning pipeline ── */}
<section className="order-4 rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-3">
<input
ref={finalFileRef}
type="file"
accept=".docx"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUploadFinal(f);
if (finalFileRef.current) finalFileRef.current.value = "";
}}
/>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Stamp className="w-5 h-5 text-gold-deep shrink-0" />
<h3 className="text-navy text-base">החלטה סופית של היו״ר</h3>
{hasFinal && (
<Badge className="bg-success-bg text-success border-success/40 text-[0.65rem]">
נקלטה
</Badge>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => finalFileRef.current?.click()}
disabled={uploadFinal.isPending}
>
{uploadFinal.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Upload className="w-4 h-4 me-1.5" />
)}
{hasFinal ? "החלף החלטה סופית" : "העלאת החלטה סופית של היו״ר"}
</Button>
</div>
<p className="text-xs text-ink-muted leading-relaxed">
העלאת ההחלטה החתומה של דפנה (נבדל מ״העלה גרסה מתוקנת״). הקליטה פותחת השוואת
טיוטהסופי; לאחר מכן הפעל את שני השלבים האוטומטיים הכל רץ ברקע, ורק מחלוקות
בין הסוכנים מוסלמות אליך.
</p>
{hasFinal && (
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button
size="sm"
onClick={handleRunLearning}
disabled={runLearning.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{runLearning.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Brain className="w-4 h-4 me-1.5" />
)}
הרץ למידת-קול
</Button>
<Button
size="sm"
onClick={handleRunHalacha}
disabled={runHalacha.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{runHalacha.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Scale className="w-4 h-4 me-1.5" />
)}
הרץ אימות-הלכות
</Button>
<span className="text-[0.7rem] text-ink-muted">
סטטוס הריצה בדף{" "}
<Link href="/operations" className="underline">
התפעול
</Link>
</span>
</div>
)}
{hasFinal && <LearningStatusBadges caseNumber={caseNumber} />}
{/* precedents cited inside the signed decision — sub-section of the
final card (mockup 18h), collapsible inside CitationsSection itself */}
<CitationsSection caseNumber={caseNumber} />
</section>
{/* ── Card: draft files (mockup 18h) ── */}
<section className="order-3 rounded-lg border border-rule bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">קבצי טיוטה</h3>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".docx"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={uploadDraft.isPending}
>
{uploadDraft.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Upload className="w-4 h-4 me-1.5" />
)}
העלה גרסה מתוקנת
</Button>
</div>
</div>
{exportsLoading ? (
<p className="text-sm text-ink-muted">טוען...</p>
) : !exports?.length ? (
<p className="text-sm text-ink-muted">
אין טיוטות עדיין.{" "}
{!isDraftReady && "הטיוטה תופיע כאן כשתהיה מוכנה."}
</p>
) : (
<div className="rounded-lg border border-rule overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-rule-soft/40 text-ink-muted text-[0.75rem]">
<th className="text-start px-4 py-2 font-medium">קובץ</th>
<th className="text-start px-4 py-2 font-medium">גודל</th>
<th className="text-start px-4 py-2 font-medium">תאריך</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{exports.map((file) => (
<tr
key={file.filename}
className="border-t border-rule hover:bg-rule-soft/20"
>
<td className="px-4 py-2.5 flex items-center gap-2">
<span>{file.filename}</span>
{file.is_final && (
<Badge className="bg-success-bg text-success border-success/40 text-[0.65rem]">
<Award className="w-3 h-3 me-0.5" />
סופי
</Badge>
)}
</td>
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
{formatSize(file.size)}
</td>
<td className="px-4 py-2.5 text-ink-muted tabular-nums">
{formatDate(file.created_at)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 justify-end">
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() =>
window.open(
`/api/cases/${caseNumber}/exports/${file.filename}/download`,
"_blank",
)
}
>
<Download className="w-3.5 h-3.5 me-1" />
הורד
</Button>
{!file.is_final && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-ink-muted"
onClick={() => handleMarkFinal(file.filename)}
disabled={markFinal.isPending}
>
<Award className="w-3.5 h-3.5 me-1" />
סמן כסופי
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => setDeleteTarget(file.filename)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Delete confirmation dialog */}
<Dialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<DialogContent className="sm:max-w-sm" dir="rtl">
<DialogHeader>
<DialogTitle>מחיקת טיוטה</DialogTitle>
<DialogDescription className="sr-only">אישור מחיקת קובץ הטיוטה</DialogDescription>
</DialogHeader>
<p className="text-sm text-ink-muted">
למחוק את הקובץ{" "}
<span className="font-medium text-ink">{deleteTarget}</span>?
<br />
פעולה זו לא ניתנת לביטול.
</p>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(null)}
>
ביטול
</Button>
<Button
variant="destructive"
size="sm"
disabled={deleteDraft.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteDraft.mutate(deleteTarget, {
onSuccess: () => {
toast.success("הקובץ נמחק");
setDeleteTarget(null);
},
onError: () => toast.error("שגיאה במחיקה"),
});
}}
>
{deleteDraft.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Trash2 className="w-4 h-4 me-1" />
)}
מחק
</Button>
</div>
</DialogContent>
</Dialog>
</section>
{/* ── Card: chair feedback (mockup 18h) ── */}
<section className="order-5 rounded-lg border border-rule bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-navy text-base">
הערות יו״ר
{openFeedbacks.length > 0 && (
<Badge className="ms-2 bg-red-100 text-red-700 border-red-200 text-[0.65rem]">
{openFeedbacks.length} פתוחות
</Badge>
)}
</h3>
<NewCaseFeedbackDialog caseNumber={caseNumber} />
</div>
{feedbackLoading ? (
<p className="text-sm text-ink-muted">טוען...</p>
) : !feedbacks?.length ? (
<p className="text-sm text-ink-muted">אין הערות לתיק זה.</p>
) : (
<div className="space-y-2">
{feedbacks.map((fb) => (
<div
key={fb.id}
className={`rounded-lg border border-rule bg-surface px-4 py-3 space-y-2 ${
fb.resolved ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={`text-[0.65rem] border ${CATEGORY_COLORS[fb.category]}`}
>
{CATEGORY_LABELS[fb.category]}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
</Badge>
{fb.resolved && (
<Badge className="bg-emerald-100 text-emerald-700 text-[0.65rem] border border-emerald-200">
טופל
</Badge>
)}
<span className="text-[0.65rem] text-ink-muted me-auto">
{fb.created_at ? formatDateShort(fb.created_at) : ""}
</span>
</div>
<p className="text-sm leading-relaxed">{fb.feedback_text}</p>
{fb.lesson_extracted && (
<div className="bg-gold/5 border border-gold/20 rounded-md px-3 py-2">
<p className="text-[0.65rem] font-semibold text-gold-deep mb-0.5">
לקח שהופק:
</p>
<p className="text-sm text-ink-muted leading-relaxed">
{fb.lesson_extracted}
</p>
</div>
)}
{!fb.resolved && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-[0.75rem]"
onClick={() => handleResolve(fb.id)}
disabled={resolveMutation.isPending}
>
סמן כמטופל
</Button>
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
);
}
/* ── Precedents cited inside the decision ──────────── */
function CitationsSection({ caseNumber }: { caseNumber: string }) {
const { data, isLoading } = useCaseCitations(caseNumber);
// Nothing to show until the signed decision is in the precedent library.
if (isLoading || !data?.in_library) return null;
if (!data.linked.length && !data.missing.length) return null;
return (
<section>
<div className="flex items-center gap-2 mb-3">
<Scale className="w-4 h-4 text-navy" />
<h3 className="text-navy text-base">פסיקה שצוטטה בהחלטה</h3>
<Badge variant="outline" className="text-[0.65rem]">
{data.linked.length} בספרייה · {data.missing.length} חסרות
</Badge>
</div>
<div className="rounded-lg border border-rule overflow-hidden divide-y divide-rule">
{data.linked.map((c) => (
<Link
key={`l-${c.citation}`}
href={`/precedents/${c.cited_id}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-rule-soft/20"
>
<Link2 className="w-3.5 h-3.5 text-success shrink-0" />
<span className="font-medium text-ink">{c.citation}</span>
{c.case_name && (
<span className="text-ink-muted truncate"> {c.case_name}</span>
)}
<Badge className="ms-auto bg-success-bg text-success border-success/40 text-[0.65rem] shrink-0">
בספרייה
</Badge>
</Link>
))}
{data.missing.map((c) => (
<div
key={`m-${c.citation}`}
className="flex items-center gap-2 px-4 py-2.5 text-sm"
>
<AlertTriangle className="w-3.5 h-3.5 text-gold-deep shrink-0" />
<span className="font-medium text-ink">{c.citation}</span>
<Badge
className={`ms-auto text-[0.65rem] shrink-0 border ${
c.flagged
? "bg-gold-wash text-gold-deep border-gold/40"
: "bg-rule-soft text-ink-soft border-rule"
}`}
>
{c.flagged ? "חסרה — סומנה להעלאה" : "חסרה בספרייה"}
</Badge>
</div>
))}
</div>
<p className="text-[0.7rem] text-ink-muted mt-2">
פסיקה מקושרת מחזקת את ההלכות שלה (corroboration); חסרות מסומנות אוטומטית
להעלאה לספרייה.
</p>
</section>
);
}
/* ── New feedback dialog (case-scoped) ─────────────── */
function NewCaseFeedbackDialog({ caseNumber }: { caseNumber: string }) {
const [open, setOpen] = useState(false);
const createMutation = useCreateFeedback();
const [blockId, setBlockId] = useState("block-yod");
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
const [feedbackText, setFeedbackText] = useState("");
const [lesson, setLesson] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!feedbackText.trim()) return;
createMutation.mutate(
{
case_number: caseNumber,
block_id: blockId,
feedback_text: feedbackText,
category,
lesson_extracted: lesson || undefined,
},
{
onSuccess: () => {
toast.success("ההערה נרשמה בהצלחה");
setOpen(false);
setFeedbackText("");
setLesson("");
},
onError: () => toast.error("שגיאה ברישום ההערה"),
},
);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="w-3.5 h-3.5 me-1" />
הערה חדשה
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle>הערת יו״ר תיק {caseNumber}</DialogTitle>
<DialogDescription className="sr-only">הוספת הערת יו״ר על בלוק בהחלטה</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="fb-block">בלוק</Label>
<select
id="fb-block"
value={blockId}
onChange={(e) => setBlockId(e.target.value)}
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
>
{Object.entries(BLOCK_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="fb-category">קטגוריה</Label>
<select
id="fb-category"
value={category}
onChange={(e) =>
setCategory(e.target.value as FeedbackCategory)
}
className="w-full rounded-md border border-rule bg-surface px-3 py-2 text-sm"
>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
</div>
<div>
<Label htmlFor="fb-text">ההערה</Label>
<Textarea
id="fb-text"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
placeholder="מה דפנה אמרה? מה חסר, מה לא נכון, מה צריך לשנות..."
rows={4}
required
/>
</div>
<div>
<Label htmlFor="fb-lesson">לקח שהופק (אופציונלי)</Label>
<Textarea
id="fb-lesson"
value={lesson}
onChange={(e) => setLesson(e.target.value)}
placeholder="מה למדנו מההערה? מה צריך לשנות במערכת?"
rows={2}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
ביטול
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "שומר..." : "שמור הערה"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}