במקום "טיוטת-ביניים-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>
1027 lines
40 KiB
TypeScript
1027 lines
40 KiB
TypeScript
"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>
|
||
);
|
||
}
|