feat(ui): IA redesign → production · יישום נאמן של כל הדפים למוקאפים #213

Merged
chaim merged 2 commits from worktree-ia-redesign-faithful into main 2026-06-11 23:05:14 +00:00
33 changed files with 2970 additions and 1834 deletions

View File

@@ -17,13 +17,6 @@ import {
* פסיקה חסרה, הערות שטרם יושמו, ותיקים שנכשלו ב-QA. המטרה:
* שאף פריט הדורש את אישורך לא יישכח. הנתונים נשלפים חי מ-/api/chair/pending.
*/
const SEVERITY_BADGE: Record<ApprovalSeverity, string> = {
high: "bg-gold text-navy border-transparent",
medium: "bg-gold-wash text-gold-deep border-gold/40",
low: "bg-rule-soft text-ink-muted border-rule",
ok: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
};
// Severity expressed as a colored dot next to the title (matches the approved
// IA-redesign mockup): high=danger, medium=warn, low=info, ok=success.
const SEVERITY_DOT: Record<ApprovalSeverity, string> = {
@@ -49,67 +42,84 @@ function formatDate(iso?: string | null): string {
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;
return (
<Card className="bg-surface border-rule shadow-sm flex flex-col">
<CardContent className="px-6 py-5 flex flex-col gap-3 grow">
<div className="flex items-start gap-3">
<Card className="bg-surface border-rule shadow-sm flex flex-col overflow-hidden">
<CardContent className="p-0 flex flex-col grow">
{/* top row — severity dot · title+age · big count number (mockup 01) */}
<div className="flex items-start gap-3 px-5 pt-5 pb-2">
<span
className={`mt-2 h-2.5 w-2.5 shrink-0 rounded-full ${SEVERITY_DOT[cat.severity]}`}
aria-hidden
/>
<h2 className="text-navy text-lg mb-0 leading-snug grow">{cat.label}</h2>
<div className="grow min-w-0">
<h2 className="text-navy text-base font-semibold mb-0 leading-snug">{cat.label}</h2>
<div className="text-[0.78rem] text-ink-muted mt-0.5">
{cleared ? (
<span className="inline-block rounded-full bg-success-bg text-success text-[0.72rem] px-2.5 py-0.5 font-medium">
תור נקי
</span>
) : cat.oldest_at ? (
<>הוותיק ביותר {formatDate(cat.oldest_at)}</>
) : (
cat.description
)}
</div>
</div>
<span
className={`inline-flex items-center justify-center min-w-[2.25rem] h-7 px-2 rounded-full border text-sm font-semibold tabular-nums ${SEVERITY_BADGE[cat.severity]}`}
className="text-3xl font-bold text-navy leading-none tabular-nums shrink-0"
aria-label={`${cat.count} פריטים ממתינים`}
>
{cat.count}
</span>
</div>
<p className="text-ink-muted text-[0.85rem] leading-relaxed mb-0">
{cat.description}
</p>
{cat.oldest_at && cat.count > 0 ? (
<p className="text-[0.78rem] text-gold-deep mb-0">
הישן ביותר ממתין מ־{formatDate(cat.oldest_at)}
</p>
{/* description kept (subtle) when the age line took the title slot */}
{!cleared && cat.oldest_at ? (
<p className="px-5 text-[0.8rem] text-ink-muted leading-relaxed mb-0">{cat.description}</p>
) : null}
{cat.extra ? (
<p className="text-[0.78rem] text-ink-muted mb-0">
<p className="px-5 mt-1 text-[0.78rem] text-ink-muted mb-0">
סך {cat.extra.total} שאילתות · {cat.extra.reviewed} אושרו על־ידך
</p>
) : null}
{cleared ? (
<p className="text-[0.85rem] text-emerald-700 mb-0">אין פריטים ממתינים </p>
) : cat.sample && cat.sample.length > 0 ? (
<ul className="space-y-1.5 mt-1">
{!cleared && cat.sample && cat.sample.length > 0 ? (
<ul className="mt-3 px-5 border-t border-rule-soft">
{cat.sample.map((s, i) => {
const body = (
<>
<span className="line-clamp-2">{s.text || "—"}</span>
const row = (
<div className="flex items-start gap-2">
<span className="line-clamp-2 text-ink-soft">{s.text || "—"}</span>
{s.source ? (
<span className="text-ink-muted text-[0.72rem]"> · {s.source}</span>
<span className="text-ink-muted text-[0.72rem] ms-auto shrink-0 whitespace-nowrap">
{s.source}
</span>
) : null}
</>
</div>
);
return (
<li key={i} className="text-[0.82rem] text-ink leading-snug border-s-2 border-rule ps-2.5">
<li
key={i}
className="text-[0.82rem] py-2.5 border-b border-rule-soft last:border-b-0"
>
{s.href ? (
<Link href={s.href} className="hover:text-gold-deep hover:underline block">
{body}
<Link href={s.href} className="block hover:text-gold-deep">
{row}
</Link>
) : (
body
row
)}
</li>
);
})}
</ul>
) : cleared ? (
<p className="px-5 mt-2 text-[0.85rem] text-ink-muted mb-0">
אין פריטים הממתינים להתייחסות. כל התיקים עברו בדיקת-איכות.
</p>
) : null}
<div className="mt-auto pt-2">
{/* foot — gold CTA when actionable, quiet outline when cleared */}
<div className="mt-auto px-5 pt-4 pb-5">
{cat.href ? (
cleared ? (
<Button asChild variant="outline" size="sm" className="border-rule text-ink-muted">

View File

@@ -26,7 +26,8 @@ import {
TableRow,
} from "@/components/ui/table";
import { useCases, useRestoreCase, type Case } from "@/lib/api/cases";
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
import { subtypeOf } from "@/components/cases/appeal-type-bars";
import { APPEAL_SUBTYPE_LABELS, type AppealSubtype } from "@/lib/practice-area";
function formatDate(iso?: string | null) {
if (!iso) return "—";
@@ -41,6 +42,20 @@ function formatDate(iso?: string | null) {
}
}
// type chip styling per mockup 05 (.t-lic / .t-bet / .t-comp)
const TYPE_CHIP: Record<string, string> = {
building_permit: "bg-info-bg text-info",
betterment_levy: "bg-gold-wash text-gold-deep border border-rule",
compensation_197: "bg-rule-soft text-ink-soft",
};
// outcome chip styling per mockup 05 (.r-acc / .r-rej / .r-part)
const OUTCOME_CHIP: Record<string, { label: string; cls: string }> = {
full_acceptance: { label: "התקבל", cls: "bg-success-bg text-success" },
partial_acceptance: { label: "חלקי", cls: "bg-warn-bg text-warn" },
rejection: { label: "נדחה", cls: "bg-danger-bg text-danger" },
};
function RestoreButton({ caseNumber }: { caseNumber: string }) {
const restore = useRestoreCase(caseNumber);
return (
@@ -77,7 +92,7 @@ function RestoreButton({ caseNumber }: { caseNumber: string }) {
const columns: ColumnDef<Case>[] = [
{
accessorKey: "case_number",
header: "מס׳ ערר",
header: "מספר ערר",
cell: ({ row }) => (
<Link
href={`/cases/${row.original.case_number}`}
@@ -91,20 +106,42 @@ const columns: ColumnDef<Case>[] = [
accessorKey: "title",
header: "כותרת",
cell: ({ row }) => (
<div className="text-ink max-w-[420px] truncate" title={row.original.title}>
<div className="text-ink-soft max-w-[420px] truncate" title={row.original.title}>
{row.original.title}
</div>
),
},
{
accessorKey: "appeal_subtype",
header: "תחום",
header: "סוג",
cell: ({ row }) => {
const s = row.original.appeal_subtype;
const s = subtypeOf(row.original);
if (!s || s === "unknown")
return <span className="text-ink-muted"></span>;
return (
<span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${
TYPE_CHIP[s] ?? "bg-rule-soft text-ink-soft"
}`}
>
{APPEAL_SUBTYPE_LABELS[s as AppealSubtype]}
</span>
);
},
},
{
accessorKey: "expected_outcome",
header: "תוצאה",
cell: ({ row }) => {
const o = row.original.expected_outcome;
const chip = o ? OUTCOME_CHIP[o] : undefined;
if (!chip) return <span className="text-ink-muted"></span>;
return (
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${chip.cls}`}
>
{chip.label}
</span>
);
},
},
@@ -112,7 +149,7 @@ const columns: ColumnDef<Case>[] = [
accessorKey: "archived_at",
header: "תאריך ארכוב",
cell: ({ row }) => (
<span className="text-ink-muted text-sm tabular-nums">
<span className="text-ink-muted text-sm tabular-nums whitespace-nowrap">
{formatDate(row.original.archived_at)}
</span>
),
@@ -130,6 +167,7 @@ export default function ArchivePage() {
{ id: "archived_at", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const rows = useMemo(() => data ?? [], [data]);
@@ -152,59 +190,75 @@ export default function ArchivePage() {
},
});
// domain filter applied client-side (subtypeOf collapses בל"מ variants)
const filteredRows = useMemo(() => {
const all = table.getFilteredRowModel().rows;
if (typeFilter === "all") return all;
return all.filter((r) => subtypeOf(r.original) === typeFilter);
}, [table, typeFilter, globalFilter, sorting, rows]);
const total = rows.length;
const shown = filteredRows.length;
return (
<AppShell>
<section className="space-y-8">
<section className="space-y-6">
<header className="space-y-1.5">
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">ארכיון</span>
</nav>
<div className="flex items-end justify-between gap-4 flex-wrap">
<div className="space-y-1">
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
ארכיון תיקי ערר
</div>
<h1 className="text-navy mb-0">תיקים סגורים</h1>
<h1 className="text-navy mb-0">ארכיון</h1>
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
תיקים שסגרו את הטיפול בהם. שחזור מחזיר את התיק לרשימה הראשית
ופותח מחדש את הפרויקט המקביל ב-Paperclip.
כל ההחלטות שהושלמו לחיפוש, סינון ועיון. {total} תיקים סגורים.
שחזור מחזיר את התיק לרשימה הראשית ופותח מחדש את הפרויקט המקביל ב-Paperclip.
</p>
</div>
<div className="inline-flex items-baseline gap-2 rounded-lg border border-rule bg-gold-wash px-4 py-2.5">
<span className="text-2xl font-semibold text-gold-deep leading-none tabular-nums">
{table.getFilteredRowModel().rows.length}
</span>
<span className="text-[0.85rem] text-ink-soft">תיקים בארכיון</span>
</div>
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<div className="flex items-center gap-3">
{/* filter bar — search + domain select + count aligned to end (mockup 05 .filters) */}
<div className="flex items-center gap-3 flex-wrap">
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
className="max-w-sm bg-surface"
placeholder="חיפוש לפי מספר ערר, כותרת או צד…"
className="flex-1 min-w-[220px] bg-surface"
dir="rtl"
/>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="cursor-pointer text-[0.84rem] text-ink-soft bg-surface border border-rule rounded-lg px-3.5 py-2"
>
<option value="all">כל סוגי הערר</option>
<option value="building_permit">רישוי ובנייה</option>
<option value="betterment_levy">היטל השבחה</option>
<option value="compensation_197">פיצויים (ס׳ 197)</option>
</select>
<span className="ms-auto text-[0.82rem] text-ink-muted tabular-nums">
מציג {shown} מתוך {total}
</span>
</div>
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
{/* clean bordered table — parchment header, gold-wash hover (mockup 05 .card table) */}
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<CardContent className="p-0">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableHeader className="bg-parchment">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="border-rule">
{hg.headers.map((header) => (
<TableHead
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="text-navy font-semibold cursor-pointer select-none text-right"
className="text-ink-muted font-medium text-[0.78rem] cursor-pointer select-none text-start"
>
{flexRender(
header.column.columnDef.header,
@@ -221,7 +275,7 @@ export default function ArchivePage() {
<TableBody>
{isPending ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i} className="border-rule">
<TableRow key={i} className="border-rule-soft">
{columns.map((_c, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
@@ -238,7 +292,7 @@ export default function ArchivePage() {
שגיאה בטעינת ארכיון: {error.message}
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
@@ -247,16 +301,16 @@ export default function ArchivePage() {
<div className="text-gold text-2xl mb-2" aria-hidden>
</div>
{globalFilter
{globalFilter || typeFilter !== "all"
? "אין תיקים תואמים לחיפוש"
: "אין תיקים בארכיון"}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
filteredRows.map((row) => (
<TableRow
key={row.id}
className="border-rule hover:bg-gold-wash/40 transition-colors"
className="border-rule-soft hover:bg-gold-wash transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
@@ -271,7 +325,6 @@ export default function ArchivePage() {
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</section>

View File

@@ -2,6 +2,7 @@
import { use, useRef, useState } from "react";
import Link from "next/link";
import { FileText } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -9,9 +10,51 @@ 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 { useCase, type CaseStatus } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents";
import { APPEAL_SUBTYPES } from "@/lib/practice-area";
import { DOC_TYPE_LABELS, type DocType } from "@/lib/doc-types";
// ── Case-status → Hebrew label + tone (mockup 03 status chip) ────────────────
const STATUS_CHIP: Record<string, { label: string; cls: string }> = {
new: { label: "חדש", cls: "bg-rule-soft text-ink-muted border-rule" },
uploading: { label: "בהעלאה", cls: "bg-info-bg text-info border-info/30" },
processing: { label: "בעיבוד", cls: "bg-info-bg text-info border-info/30" },
documents_ready: { label: "מסמכים מוכנים", cls: "bg-info-bg text-info border-info/30" },
analyst_verified: { label: "אומת ע״י אנליסט", cls: "bg-info-bg text-info border-info/30" },
research_complete: { label: "מחקר הושלם", cls: "bg-info-bg text-info border-info/30" },
outcome_set: { label: "תוצאה נקבעה", cls: "bg-info-bg text-info border-info/30" },
brainstorming: { label: "סיעור-מוחות", cls: "bg-info-bg text-info border-info/30" },
direction_approved: { label: "כיוון אושר", cls: "bg-info-bg text-info border-info/30" },
analysis_enriched: { label: "ניתוח הועשר", cls: "bg-info-bg text-info border-info/30" },
ready_for_writing: { label: "מוכן לכתיבה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
drafting: { label: "בעריכה", cls: "bg-info-bg text-info border-info/30" },
qa_review: { label: "בדיקת-איכות", cls: "bg-gold-wash text-gold-deep border-gold/40" },
drafted: { label: "טיוטה", cls: "bg-gold-wash text-gold-deep border-gold/40" },
exported: { label: "יוצא", cls: "bg-success-bg text-success border-success/40" },
reviewed: { label: "נסקר", cls: "bg-success-bg text-success border-success/40" },
final: { label: "סופי", cls: "bg-success-bg text-success border-success/40" },
};
function StatusChip({ status }: { status?: CaseStatus }) {
const c = (status && STATUS_CHIP[status]) || {
label: "בעריכה",
cls: "bg-info-bg text-info border-info/30",
};
return (
<span
className={`rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border ${c.cls}`}
>
{c.label}
</span>
);
}
function subtypeLabel(subtype?: string | null): string | null {
if (!subtype) return null;
return APPEAL_SUBTYPES.find((s) => s.value === subtype)?.label ?? null;
}
function ProseSection({ title, content }: { title: string; content?: string }) {
if (!content?.trim()) return null;
@@ -25,7 +68,8 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
);
}
function AnalysisActions({
// ── "השלמה והעברה" rail card — DOCX export, upload, download (all real) ──────
function FinishRail({
caseNumber,
hasAnalysis,
onUploaded,
@@ -55,7 +99,7 @@ function AnalysisActions({
}
setUploadMsg({
ok: true,
text: `הקובץ הועלה בהצלחה${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
text: `הקובץ הועלה — ${data.sections.threshold_claims} טענות סף, ${data.sections.issues} סוגיות`,
});
onUploaded();
} catch {
@@ -67,12 +111,10 @@ function AnalysisActions({
}
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>
)}
<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"
@@ -83,16 +125,32 @@ function AnalysisActions({
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 ? "מעלה..." : "העלה ניתוח מעודכן"}
{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`;
@@ -100,25 +158,32 @@ function AnalysisActions({
a.click();
}}
>
הורד ניתוח
הורד ניתוח (MD)
</Button>
)}
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
a.click();
}}
>
הורד כ-DOCX
</Button>
</div>
{uploadMsg && (
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
{uploadMsg.text}
</p>
)}
<Button asChild variant="outline">
{/* mockup 03: 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> ממתין להעלאת הסופי
</div>
<div className="text-[0.78rem] text-ink-muted pt-2 mt-2 border-t border-rule-soft">
<b className="text-navy">הרץ אימות-הלכות</b> ממתין להעלאת הסופי
</div>
</div>
<Button asChild variant="ghost" className="w-full justify-center mt-3 text-ink-muted">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -145,6 +210,18 @@ export default function ComposePage({
}
}
const practiceArea = caseQuery.data?.practice_area ?? null;
const subtype = subtypeLabel(caseQuery.data?.appeal_subtype);
const parties = (() => {
const c = caseQuery.data;
if (!c) return null;
const app = c.appellants?.length ? c.appellants.join(", ") : null;
const resp = c.respondents?.length ? c.respondents.join(", ") : null;
const out: string[] = [];
if (app) out.push(`עוררים: ${app}`);
if (resp) out.push(`משיבה: ${resp}`);
return out.length ? out.join(" · ") : c.title || null;
})();
const documents = caseQuery.data?.documents ?? [];
const isNotFound =
analysis.error instanceof Error &&
@@ -152,34 +229,34 @@ export default function ComposePage({
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">
{/* ── Case header band (mockup 03) — parchment strip, full-bleed to the
AppShell <main> edges (which pads px-10 py-10) ── */}
<div className="-mx-10 -mt-10 mb-6 border-b border-rule bg-parchment px-10 py-5">
<nav className="text-[0.78rem] text-ink-muted flex items-center gap-2 mb-2">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
<Link
href={`/cases/${caseNumber}`}
className="hover:text-gold-deep"
>
<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 className="flex items-center gap-3 flex-wrap">
<h1 className="text-navy text-2xl font-bold mb-0">ערר {caseNumber}</h1>
<StatusChip status={caseQuery.data?.status} />
{subtype && (
<span className="rounded-full text-[0.78rem] font-semibold px-3 py-0.5 border border-rule bg-gold-wash text-gold-deep">
{subtype}
</span>
)}
{/* INV-G10: source-of-truth pill — the blocks are the canonical text */}
<span className="ms-auto rounded-lg text-[0.8rem] font-semibold px-3.5 py-1.5 border border-gold bg-gold-wash text-gold-deep">
מקור-אמת: בלוקים
</span>
</div>
<AnalysisActions caseNumber={caseNumber} hasAnalysis={!!analysis.data} onUploaded={() => analysis.refetch()} />
{parties && <p className="text-ink-soft text-sm mt-2">{parties}</p>}
</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">
@@ -209,35 +286,21 @@ export default function ComposePage({
</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>
/* ── Two-column workspace: main editor list + 320px side rail ──────── */
<div className="grid gap-6 lg:grid-cols-[1fr_320px] items-start">
{/* MAIN — the block/subsection editor list */}
<div className="space-y-6 min-w-0">
{/* 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>
<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="space-y-3">
<div className="space-y-2.5">
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={tc.id}
@@ -255,12 +318,12 @@ export default function ComposePage({
{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>
<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="space-y-3">
<div className="space-y-2.5">
{analysis.data.issues.map((iss) => (
<SubsectionCard
key={iss.id}
@@ -274,8 +337,8 @@ export default function ComposePage({
</div>
)}
{(!analysis.data.threshold_claims?.length &&
!analysis.data.issues?.length) && (
{!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">
לא נמצאו טענות סף או סוגיות בניתוח זה.
@@ -283,42 +346,82 @@ export default function ComposePage({
</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. */}
{/* Background prose — supporting context after the decision points */}
<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}
/>
<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-xl mb-0">מסקנות</h2>
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
<Markdown content={analysis.data.conclusions.trim()} />
</CardContent>
</Card>
)}
</div>
{/* SIDE RAIL — documents · attached precedents · finish-and-transfer */}
<aside className="space-y-4 lg:sticky lg:top-4">
{/* מסמכי התיק */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-4 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-2">מסמכי התיק</h3>
{documents.length === 0 ? (
<p className="text-[0.78rem] text-ink-muted">אין מסמכים מצורפים</p>
) : (
<ul>
{documents.map((d) => (
<li
key={d.id}
className="flex items-center gap-2 text-[0.82rem] text-ink-soft py-1.5 border-b border-rule-soft last:border-0"
>
<FileText className="w-3.5 h-3.5 text-ink-muted shrink-0" aria-hidden />
<span className="truncate flex-1" title={d.title}>
{d.title || "מסמך"}
</span>
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-1.5 py-0.5 shrink-0 whitespace-nowrap">
{DOC_TYPE_LABELS[d.doc_type as DocType] ?? d.doc_type}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* פסיקה מצורפת (case-level) */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-4 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()}
/>
</aside>
</div>
) : null}
</section>
</AppShell>
);
}

View File

@@ -45,10 +45,10 @@ export default function CaseDetailPage({
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
if (error) {
return (
<AppShell>
<section className="space-y-6">
{error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת התיק</p>
@@ -58,60 +58,71 @@ export default function CaseDetailPage({
</Button>
</CardContent>
</Card>
) : (
</section>
</AppShell>
);
}
const tabsList = (
<TabsList
variant="line"
className="gap-6 h-auto p-0 rounded-none -mb-px"
>
{[
["overview", "סקירה"],
["arguments", "טיעונים"],
["decision", "ההחלטה"],
["drafts", "טיוטות והערות"],
["agents", "סוכנים"],
].map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className="flex-none rounded-none px-0 pb-3.5 pt-0 text-[0.92rem] font-medium text-ink-muted data-active:text-navy data-active:font-semibold data-active:after:bg-gold data-active:after:bottom-0"
>
{label}
</TabsTrigger>
))}
</TabsList>
);
const bandActions = (
<>
{data && <CaseEditDialog data={data} />}
<UploadSheet caseNumber={caseNumber} />
</>
);
return (
<AppShell>
<Tabs defaultValue="overview" dir="rtl">
{/* parchment band — header (title/chips/parties/actions) + tab strip */}
{isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6 pb-4 space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-6 w-96" />
</CardContent>
</Card>
</div>
) : (
<CaseHeader data={data} />
<CaseHeader data={data} actions={bandActions} tabs={tabsList} />
)}
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<Tabs defaultValue="overview" dir="rtl">
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="overview">סקירה</TabsTrigger>
<TabsTrigger value="arguments">
טיעונים
</TabsTrigger>
<TabsTrigger value="decision">
ההחלטה
</TabsTrigger>
<TabsTrigger value="drafts">
טיוטות והערות
</TabsTrigger>
<TabsTrigger value="agents">
סוכנים
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 flex-wrap">
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href={`/cases/${caseNumber}/compose`}>
פתח בעורך ההחלטה
</Link>
</Button>
{data && <CaseEditDialog data={data} />}
<UploadSheet caseNumber={caseNumber} />
{/* two-column wrap — main tab content (1fr) + rail (340px) */}
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start mt-6">
<div className="min-w-0">
<TabsContent value="overview" className="mt-0 space-y-5">
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
סקירת התיק
</div>
</div>
<TabsContent value="overview" className="mt-5 space-y-4">
<CardContent className="px-5 py-4 space-y-4">
<div>
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
<h3 className="text-navy text-[0.95rem] font-semibold mb-1.5">תוצאה צפויה</h3>
<p className="text-ink-soft text-sm leading-relaxed">
{expectedOutcomeLabel ?? "לא נקבעה תוצאה צפויה."}
</p>
</div>
<div>
<h3 className="text-navy text-base mb-2">סיכום מהיר</h3>
<div className="pt-2 border-t border-rule-soft">
<dl className="grid grid-cols-2 gap-y-2 gap-x-6 text-sm">
<dt className="text-ink-muted">בעיבוד</dt>
<dd className="text-ink tabular-nums">
@@ -120,7 +131,7 @@ export default function CaseDetailPage({
</dl>
</div>
{canStartWorkflow && (
<div className="pt-2 border-t border-rule">
<div className="pt-2 border-t border-rule-soft">
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
disabled={startWorkflow.isPending}
@@ -144,44 +155,71 @@ export default function CaseDetailPage({
</Button>
</div>
)}
<DocumentsPanel data={data} />
</TabsContent>
<TabsContent value="arguments" className="mt-5">
<LegalArgumentsPanel caseNumber={caseNumber} />
</TabsContent>
<TabsContent value="decision" className="mt-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</TabsContent>
<TabsContent value="drafts" className="mt-5">
<DraftsPanel
caseNumber={caseNumber}
status={data?.status}
/>
</TabsContent>
<TabsContent value="agents" className="mt-5">
<AgentActivityFeed caseNumber={caseNumber} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
<Card className="bg-surface border-rule shadow-sm h-fit">
<CardContent className="px-6 py-5 space-y-5">
<DocumentsPanel data={data} />
{/* gold CTA — open the decision editor (mockup .cta) */}
<Button
asChild
className="w-full bg-gold text-white hover:bg-gold-deep border-transparent py-6 text-base font-semibold"
>
<Link href={`/cases/${caseNumber}/compose`}>
פתח עורך החלטה
</Link>
</Button>
</TabsContent>
<TabsContent value="arguments" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<LegalArgumentsPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="decision" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DecisionBlocksPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="drafts" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="agents" className="mt-0">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<AgentActivityFeed caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
</div>
{/* rail — status timeline + status controls (mockup .rail) */}
<div className="space-y-5">
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0 h-fit">
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
סטטוס התיק
</div>
<CardContent className="px-5 py-4 space-y-4">
<AgentStatusWidget caseNumber={caseNumber} />
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
<WorkflowTimeline status={data?.status} />
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
<StatusGuide />
</CardContent>
</Card>
</div>
</>
)}
</section>
</div>
</Tabs>
</AppShell>
);
}

View File

@@ -2,11 +2,11 @@
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { DigestListPanel } from "@/components/digests/digest-list-panel";
import { DigestSearchPanel } from "@/components/digests/digest-search-panel";
import { DigestUploadDialog } from "@/components/digests/digest-upload-dialog";
import { useDigestPending } from "@/lib/api/digests";
/**
@@ -38,32 +38,58 @@ export default function DigestsPage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<header className="space-y-2">
<nav className="text-[0.78rem] text-ink-muted">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">יומונים</span>
</nav>
<h1 className="text-navy mb-0">יומונים רדאר פסיקה</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
סיכומי &quot;כל יום&quot; (עפר טויסטר) של פסקי דין והחלטות עדכניים.
שכבת-גילוי בלבד: כל יומון <strong>מצביע</strong> על פסק הדין המקורי
הוא אינו מצוטט בהחלטה ואינו מחלץ הלכות. כשהפסק רלוונטי, מעלים אותו
לספריית הפסיקה ומצטטים משם.
<h1 className="text-navy mb-0">יומונים (רדאר)</h1>
<p className="text-ink-muted text-sm max-w-3xl leading-relaxed">
שכבת-גילוי משנית מצביע-לא-מצוטט (X12). מאתרת פסיקה רלוונטית ומפנה
אליה; אינה מקור-אמת לציטוט. סיכומי &quot;כל יום&quot; (עפר טויסטר):
כל יומון <strong>מצביע</strong> על פסק הדין המקורי כשהפסק רלוונטי,
מעלים אותו לספריית הפסיקה ומצטטים משם.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* prominent dashed-gold upload area (mockup 10 `.upload`) */}
<div className="flex items-center gap-4 rounded-lg border-[1.5px] border-dashed border-gold bg-surface px-5 py-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gold-wash text-gold-deep text-xl">
</div>
<div className="min-w-0">
<span className="block text-navy font-semibold text-[0.92rem]">העלאת יומון</span>
<span className="text-[0.78rem] text-ink-muted">
בחר קובץ יומון &quot;כל יום&quot; PDF · עד 20MB
</span>
</div>
<div className="ms-auto">
<DigestUploadDialog
trigger={
<button className="rounded-lg bg-gold px-4 py-2 text-sm font-semibold text-white hover:bg-gold-deep">
בחר קובץ
</button>
}
/>
</div>
</div>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<Tabs defaultValue="list" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="list">
יומונים
<PendingBadge />
<TabsList className="flex w-full justify-start gap-1 rounded-none border-0 border-b border-rule bg-transparent p-0 h-auto">
{[
{ value: "list", label: "יומונים", pill: <PendingBadge /> },
{ value: "search", label: "חיפוש", pill: null },
].map((t) => (
<TabsTrigger
key={t.value}
value={t.value}
className="rounded-none border-0 border-b-2 border-transparent bg-transparent px-4 py-2.5 -mb-px text-sm font-medium text-ink-muted shadow-none data-[state=active]:border-gold data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-navy data-[state=active]:shadow-none"
>
{t.label}
{t.pill}
</TabsTrigger>
<TabsTrigger value="search">חיפוש</TabsTrigger>
))}
</TabsList>
<TabsContent value="list" className="mt-5">
@@ -74,8 +100,6 @@ export default function DigestsPage() {
<DigestSearchPanel />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
</AppShell>
);

View File

@@ -7,12 +7,11 @@ import { toast } from "sonner";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
useFeedbackList,
useResolveFeedback,
useCreateFeedback,
CATEGORY_LABELS,
CATEGORY_COLORS,
BLOCK_LABELS,
type ChairFeedback,
type FeedbackCategory,
@@ -24,6 +23,16 @@ import {
* "טרם יושמו" וקטגוריה, וסימון כל הערה כיושמה. מוזן מ-/api/feedback.
*/
// category chip styling per mockup 06 (.c-missing / .c-tone / .c-struct / .c-fact / .c-style)
const CAT_CHIP: Record<FeedbackCategory, string> = {
missing_content: "bg-warn-bg text-warn",
wrong_tone: "bg-info-bg text-info",
wrong_structure: "bg-gold-wash text-gold-deep border border-rule",
factual_error: "bg-danger-bg text-danger",
style: "bg-rule-soft text-ink-soft",
other: "bg-rule-soft text-ink-soft",
};
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
@@ -57,47 +66,51 @@ function FeedbackCard({ fb }: { fb: ChairFeedback }) {
};
return (
<Card className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-60" : ""}`}>
<CardContent className="px-5 py-4 space-y-2.5">
<div className="flex items-start gap-2 flex-wrap">
<Badge variant="outline" className={`text-[0.7rem] ${CATEGORY_COLORS[fb.category]}`}>
{CATEGORY_LABELS[fb.category]}
</Badge>
<Badge variant="outline" className="text-[0.7rem]">
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
</Badge>
<Card className={`bg-surface border-rule shadow-sm ${fb.resolved ? "opacity-[0.78]" : ""}`}>
<CardContent className="px-[18px] py-4 space-y-2.5">
{/* meta row — where · category chip · when (mockup 06 .meta) */}
<div className="flex items-center gap-2.5 flex-wrap">
<span className="font-semibold text-navy text-[0.84rem]">
{fb.case_number ? (
<Link
href={`/cases/${encodeURIComponent(fb.case_number)}`}
className="text-[0.72rem] text-gold-deep hover:underline"
className="hover:text-gold-deep"
>
תיק {fb.case_number}
ערר {fb.case_number}
</Link>
) : (
<span className="text-[0.72rem] text-ink-muted">ללא תיק</span>
"ללא תיק"
)}
<span className="ms-auto text-[0.72rem] text-ink-muted">
<span className="text-ink-muted font-normal"> · {BLOCK_LABELS[fb.block_id] ?? fb.block_id}</span>
</span>
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold whitespace-nowrap ${CAT_CHIP[fb.category]}`}
>
{CATEGORY_LABELS[fb.category]}
</span>
<span className="ms-auto text-[0.72rem] text-ink-muted whitespace-nowrap">
{formatDate(fb.created_at)}
</span>
</div>
<p className="text-navy text-sm leading-relaxed m-0 whitespace-pre-wrap" dir="rtl">
<p className="text-ink-soft text-sm leading-relaxed m-0 whitespace-pre-wrap" dir="rtl">
{fb.feedback_text}
</p>
{fb.lesson_extracted ? (
<div className="rounded-md bg-gold-wash/40 border-s-[3px] border-gold ps-3 pe-3 py-2">
<div className="text-[0.68rem] text-gold-deep mb-0.5">לקח שהופק</div>
<p className="text-ink-soft text-[0.82rem] leading-relaxed m-0 whitespace-pre-wrap italic" dir="rtl">
{fb.lesson_extracted}
<p
className="text-[0.82rem] text-ink-muted italic leading-relaxed m-0 whitespace-pre-wrap border-s-[3px] border-gold ps-2.5"
dir="rtl"
>
לקח שחולץ: {fb.lesson_extracted}
</p>
</div>
) : null}
<div className="flex items-center justify-end pt-1 border-t border-rule-soft">
{/* action row — gold CTA / applied pill (mockup 06 .actrow) */}
<div className="flex items-center justify-start pt-1">
{fb.resolved ? (
<span className="flex items-center gap-1 text-[0.78rem] text-emerald-700">
<CheckCircle2 className="w-3.5 h-3.5" /> יושמה
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-bg text-success text-[0.76rem] font-semibold px-3 py-1">
<CheckCircle2 className="w-3.5 h-3.5" /> יושם
</span>
) : (
<Button
@@ -118,6 +131,118 @@ function FeedbackCard({ fb }: { fb: ChairFeedback }) {
type CatFilter = "all" | FeedbackCategory;
const BLOCK_OPTIONS = [
"block-vav",
"block-zayin",
"block-chet",
"block-tet",
"block-yod",
"block-yod-alef",
];
function AddFeedbackForm() {
const create = useCreateFeedback();
const [caseNumber, setCaseNumber] = useState("");
const [blockId, setBlockId] = useState("block-yod");
const [category, setCategory] = useState<FeedbackCategory>("missing_content");
const [text, setText] = useState("");
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) {
toast.error("יש להזין את תוכן ההערה");
return;
}
create.mutate(
{
case_number: caseNumber.trim() || undefined,
block_id: blockId,
category,
feedback_text: text.trim(),
},
{
onSuccess: () => {
toast.success("ההערה נשמרה");
setCaseNumber("");
setText("");
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "שגיאה בשמירה"),
},
);
};
const fieldCls =
"w-full text-[0.84rem] text-ink bg-parchment border border-rule rounded-md px-3 py-2";
const labelCls = "block text-[0.76rem] text-ink-muted font-medium mt-2.5 mb-1";
return (
<Card className="bg-surface border-rule shadow-sm lg:sticky lg:top-6">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-base font-semibold mb-3.5">הוסף הערה</h3>
<form onSubmit={onSubmit}>
<label className={labelCls} htmlFor="fb-case">מספר ערר</label>
<input
id="fb-case"
type="text"
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="לדוגמה: 1126-08-25"
className={fieldCls}
dir="rtl"
/>
<label className={labelCls} htmlFor="fb-block">בלוק</label>
<select
id="fb-block"
value={blockId}
onChange={(e) => setBlockId(e.target.value)}
className={`${fieldCls} cursor-pointer`}
>
{BLOCK_OPTIONS.map((b) => (
<option key={b} value={b}>
{BLOCK_LABELS[b]}
</option>
))}
</select>
<label className={labelCls} htmlFor="fb-cat">קטגוריה</label>
<select
id="fb-cat"
value={category}
onChange={(e) => setCategory(e.target.value as FeedbackCategory)}
className={`${fieldCls} cursor-pointer`}
>
{(Object.keys(CATEGORY_LABELS) as FeedbackCategory[]).map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c]}
</option>
))}
</select>
<label className={labelCls} htmlFor="fb-text">תוכן ההערה</label>
<textarea
id="fb-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="מה צריך לתקן ולמה…"
className={`${fieldCls} min-h-[90px] resize-y leading-relaxed`}
dir="rtl"
/>
<Button
type="submit"
disabled={create.isPending}
className="w-full mt-4 bg-gold text-white hover:bg-gold-deep border-transparent"
>
{create.isPending ? "שומר…" : "שמור הערה"}
</Button>
</form>
</CardContent>
</Card>
);
}
export default function FeedbackPage() {
const [unresolvedOnly, setUnresolvedOnly] = useState(true);
const [category, setCategory] = useState<CatFilter>("all");
@@ -159,14 +284,19 @@ export default function FeedbackPage() {
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
שערים אנושיים · יו״ר הוועדה
</div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-navy mb-0">הערות יו״ר</h1>
<span className="inline-block rounded-full bg-gold-wash border border-rule text-ink-muted text-[0.76rem] px-3 py-0.5">
נגיש גם מתוך מרכז האישורים
</span>
</div>
<p className="text-ink-muted text-sm mt-1 max-w-2xl leading-relaxed">
כל ההערות שנרשמו על טיוטות מכל התיקים. סמן כל הערה כיושמה
לאחר שהלקח הוטמע.
</p>
</div>
<div className="text-end">
<div className="text-3xl font-semibold text-navy leading-none">
<div className="text-3xl font-semibold text-navy leading-none tabular-nums">
{unresolvedCount}
</div>
<div className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted mt-1">
@@ -178,15 +308,35 @@ export default function FeedbackPage() {
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* Filters */}
{/* category filter chips (mockup 06 .chips) + resolved-state toggle */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1">
<div className="flex items-center gap-2 flex-wrap">
{categories.map((c) => {
const on = category === c.key;
return (
<button
key={c.key}
type="button"
onClick={() => setCategory(c.key)}
className={`text-[0.8rem] font-medium px-3.5 py-1.5 rounded-full border transition-colors ${
on
? "bg-navy text-white border-navy"
: "bg-surface text-ink-soft border-rule hover:bg-rule-soft"
}`}
>
{c.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 ms-auto">
<button
type="button"
onClick={() => setUnresolvedOnly(true)}
className={`text-[0.78rem] px-3 py-1.5 rounded border transition-colors ${
unresolvedOnly
? "bg-navy text-parchment border-navy"
? "bg-navy text-white border-navy"
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
}`}
>
@@ -197,32 +347,18 @@ export default function FeedbackPage() {
onClick={() => setUnresolvedOnly(false)}
className={`text-[0.78rem] px-3 py-1.5 rounded border transition-colors ${
!unresolvedOnly
? "bg-navy text-parchment border-navy"
? "bg-navy text-white border-navy"
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
}`}
>
הכל
</button>
</div>
<div className="flex items-center gap-1 flex-wrap ms-auto">
{categories.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setCategory(c.key)}
className={`text-[0.74rem] px-2.5 py-1 rounded border transition-colors ${
category === c.key
? "bg-gold-wash text-gold-deep border-gold/40"
: "bg-surface text-ink-muted border-rule hover:bg-rule-soft"
}`}
>
{c.label}
</button>
))}
</div>
</div>
{/* two-column body — feedback list + sticky add-form rail (mockup 06 .wrap grid) */}
<div className="grid gap-6 lg:grid-cols-[1fr_340px] items-start">
<div>
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-ink-muted text-sm">
@@ -230,10 +366,10 @@ export default function FeedbackPage() {
</CardContent>
</Card>
) : isPending ? (
<div className="space-y-3">
<div className="space-y-3.5">
{[0, 1, 2].map((i) => (
<Card key={i} className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 h-28 animate-pulse" />
<CardContent className="px-[18px] py-4 h-28 animate-pulse" />
</Card>
))}
</div>
@@ -245,12 +381,16 @@ export default function FeedbackPage() {
)}
</div>
) : (
<div className="space-y-3">
<div className="space-y-3.5">
{items.map((fb) => (
<FeedbackCard key={fb.id} fb={fb} />
))}
</div>
)}
</div>
<AddFeedbackForm />
</div>
</section>
</AppShell>
);

View File

@@ -8,7 +8,7 @@ export default function GraphPage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<header className="space-y-1.5">
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">
בית
@@ -16,16 +16,20 @@ export default function GraphPage() {
<span aria-hidden> · </span>
<span className="text-navy">מפת הקורפוס</span>
</nav>
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
גרף הציטוטים · ספריית הפסיקה
</div>
<h1 className="text-navy mb-0">מפת הקורפוס</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
רשת הציטוטים של ספריית הפסיקה כל נקודה היא פסיקה או נושא, וקו מציין ציטוט או שיוך.
גודל הנקודה משקף כמה פעמים הפסיקה צוטטה. לחצו על נקודה כדי להתמקד בשכניה.
<p className="text-ink-muted text-sm mt-1 max-w-3xl leading-relaxed">
גרף הציטוטים של הקורפוס פסיקה, נושאים והלכות וקשרי-ההפניה ביניהם.
גררו צמתים, סננו שכבות, ובחנו את מרכזי-הכובד. גודל הנקודה משקף כמה
פעמים הפסיקה צוטטה; לחיצה על נקודה ממקדת בשכניה.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Suspense
fallback={
<div className="grid h-[560px] place-items-center text-sm text-ink-muted">
<div className="grid h-[560px] place-items-center rounded-lg border border-rule bg-gradient-to-b from-[#f3ecda] to-[#efe6cf] text-sm text-ink-muted">
טוען גרף
</div>
}

View File

@@ -21,8 +21,12 @@ export default function MethodologyPage() {
</nav>
<h1 className="text-navy mb-0">מתודולוגיה</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
הגדרות ניסוח יחסי אורך, כללי דיון, וצ׳קליסטים לפי סוג ערר
העורך הקנוני היחיד לכללי-הכותב יחסי אורך, כללי דיון, וצ׳קליסטים לפי סוג ערר
</p>
<div className="inline-flex items-center gap-2 rounded-lg border border-success bg-success-bg text-success px-3.5 py-1.5 text-[0.78rem] font-semibold mt-3">
<span aria-hidden></span>
מקור-אמת יחיד כל הסוכנים קוראים את הכללים מכאן בלבד
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />

View File

@@ -3,9 +3,6 @@
import { useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
useMissingPrecedents,
@@ -17,32 +14,26 @@ import { MissingPrecedentsTable } from "@/components/missing-precedents/missing-
* Missing-precedents page (TaskMaster #35).
*
* Surfaces citations that party briefs invoke but which aren't yet in the
* precedent_library. Four tabs by status; each tab uses the same table
* component with a different filter. Drawer (sheet) opens on row click
* with metadata + upload form that routes to internal_decision_upload
* (ערר/בל"מ citations) or precedent_library_upload (court rulings).
* precedent_library. A status filter (chips) narrows the table; each row uses
* the same table component. Drawer (sheet) opens on row click with metadata +
* upload form that routes to internal_decision_upload (ערר/בל"מ citations) or
* precedent_library_upload (court rulings).
*/
function StatusBadge({ status, count }: { status: MissingPrecedentStatus; count: number }) {
if (!count) return null;
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-gold-wash text-gold-deep border-gold/40",
uploaded: "bg-rule-soft text-ink-muted border-rule",
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
irrelevant: "bg-rule-soft text-ink-muted border-rule",
};
return (
<Badge
variant="outline"
className={`ms-1 text-[0.65rem] ${variants[status]}`}
>
{count}
</Badge>
);
}
type StatusFilter = MissingPrecedentStatus | "all";
const STATUS_CHIPS: { value: StatusFilter; label: string }[] = [
{ value: "open", label: "פתוח" },
{ value: "uploaded", label: "הועלה" },
{ value: "closed", label: "נסגר" },
{ value: "irrelevant", label: "לא-רלוונטי" },
{ value: "all", label: "הכל" },
];
export default function MissingPrecedentsPage() {
const [caseNumber, setCaseNumber] = useState("");
const [legalTopic, setLegalTopic] = useState("");
const [filter, setFilter] = useState<StatusFilter>("open");
const counts = useMissingPrecedents({ limit: 1 });
const byStatus = counts.data?.by_status ?? {};
@@ -50,40 +41,39 @@ export default function MissingPrecedentsPage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<header className="space-y-3">
<nav className="text-[0.78rem] text-ink-muted">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">פסיקה חסרה בקורפוס</span>
</nav>
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
{/* title + inline open-count pill (mockup 09 `.open-count`) */}
<div className="flex items-baseline gap-3.5 flex-wrap">
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם
פערים אוטומטית; היו&quot;ר סוגר אותם על־ידי העלאת המסמך ניתוב
אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר.
</p>
</div>
{byStatus.open ? (
<div className="inline-flex items-baseline gap-2 rounded-lg border border-rule bg-warn-bg px-4 py-2.5">
<span className="text-2xl font-semibold text-warn leading-none tabular-nums">
<span className="inline-flex items-baseline gap-1.5 rounded-lg border border-rule bg-warn-bg px-3.5 py-1">
<span className="text-lg font-bold text-warn tabular-nums leading-none">
{byStatus.open}
</span>
<span className="text-[0.85rem] text-ink-soft">פתוחים</span>
</div>
<span className="text-[0.8rem] text-ink-soft">פתוחים</span>
</span>
) : null}
</div>
<p className="text-ink-muted text-sm max-w-3xl leading-relaxed">
פסיקה שצוטטה בכתבי-הטענות אך אינה קיימת בקורפוס. השלמתה מאפשרת
אימות-הלכה ועיגון-מקור (INV-AH). סוכן המחקר רושם פערים אוטומטית;
היו&quot;ר סוגר אותם על־ידי העלאת המסמך ניתוב אוטומטי בין הקורפוס
הסמכותי (פסקי דין) להחלטות ועדות ערר.
</p>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-5">
{/* Shared filters */}
{/* shared filters */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">תיק (מספר ערר)</label>
<label className="block text-[0.78rem] text-ink-muted mb-1.5">תיק (מספר ערר)</label>
<Input
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
@@ -92,7 +82,7 @@ export default function MissingPrecedentsPage() {
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">נושא משפטי</label>
<label className="block text-[0.78rem] text-ink-muted mb-1.5">נושא משפטי</label>
<Input
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
@@ -102,72 +92,78 @@ export default function MissingPrecedentsPage() {
</div>
</div>
<Tabs defaultValue="open" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="open">
פתוחות
<StatusBadge status="open" count={byStatus.open ?? 0} />
</TabsTrigger>
<TabsTrigger value="uploaded">
הועלו
<StatusBadge status="uploaded" count={byStatus.uploaded ?? 0} />
</TabsTrigger>
<TabsTrigger value="closed">
נסגרו
<StatusBadge status="closed" count={byStatus.closed ?? 0} />
</TabsTrigger>
<TabsTrigger value="irrelevant">
לא רלוונטי
<StatusBadge
status="irrelevant"
count={byStatus.irrelevant ?? 0}
/>
</TabsTrigger>
<TabsTrigger value="all">הכל</TabsTrigger>
</TabsList>
{/* status filter chips (mockup 09 `.filters`) — active = navy filled */}
<div className="flex items-center gap-2 flex-wrap">
{STATUS_CHIPS.map((c) => {
const active = filter === c.value;
const count =
c.value === "all"
? undefined
: (byStatus[c.value as MissingPrecedentStatus] ?? 0);
return (
<button
key={c.value}
type="button"
onClick={() => setFilter(c.value)}
aria-pressed={active}
className={`rounded-full border px-4 py-1.5 text-[0.82rem] transition-colors ${
active
? "bg-navy text-white border-navy font-semibold"
: "bg-surface text-ink-soft border-rule font-medium hover:bg-rule-soft/50"
}`}
>
{c.label}
{count ? (
<span className="ms-1.5 tabular-nums opacity-80">({count})</span>
) : null}
</button>
);
})}
</div>
<TabsContent value="open" className="mt-4">
<MissingPrecedentsTable
status="open"
status={filter === "all" ? "" : filter}
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="uploaded" className="mt-4">
<MissingPrecedentsTable
status="uploaded"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="closed" className="mt-4">
<MissingPrecedentsTable
status="closed"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="irrelevant" className="mt-4">
<MissingPrecedentsTable
status="irrelevant"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<TabsContent value="all" className="mt-4">
<MissingPrecedentsTable
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* lifecycle note (mockup 09 `.lifecycle`) */}
<div className="rounded-lg border border-rule bg-parchment px-5 py-3.5 text-[0.82rem] text-ink-muted leading-7">
<b className="text-ink-soft">מחזור-חיים:</b>{" "}
<LifecycleChip tone="open">פתוח</LifecycleChip> {" "}
<LifecycleChip tone="up">הועלה</LifecycleChip> {" "}
<LifecycleChip tone="closed">נסגר</LifecycleChip>. פריט נפתח אוטומטית
בעת חילוץ ציטוט שאין לו תקדים בקורפוס; בהעלאת פסק-הדין הוא מקושר לרשומת
הפסיקה דרך{" "}
<code className="rounded border border-rule bg-surface px-1.5 py-0.5 text-[0.75rem] text-gold-deep" dir="ltr">
linked_case_law_id
</code>{" "}
ונסגר. פריט שאינו רלוונטי מסומן{" "}
<LifecycleChip tone="na">לא-רלוונטי</LifecycleChip> מבלי שתידרש העלאה.
</div>
</section>
</AppShell>
);
}
type LifecycleTone = "open" | "up" | "closed" | "na";
function LifecycleChip({
tone,
children,
}: {
tone: LifecycleTone;
children: React.ReactNode;
}) {
const cls: Record<LifecycleTone, string> = {
open: "bg-warn-bg text-warn",
up: "bg-info-bg text-info",
closed: "bg-success-bg text-success",
na: "bg-rule-soft text-ink-muted",
};
return (
<span className={`inline-block rounded-full px-2.5 py-0.5 text-[0.72rem] font-semibold ${cls[tone]}`}>
{children}
</span>
);
}

View File

@@ -35,6 +35,12 @@ function mb(bytes: number): string {
return `${Math.round((bytes || 0) / 1024 / 1024)}MB`;
}
// mockup 02: every region opens with a navy section heading (h2, 18px) — the
// page reads as a sequence of titled sections rather than a stack of cards.
function SectionHeader({ children }: { children: React.ReactNode }) {
return <h2 className="text-navy text-lg font-semibold mb-3 mt-2">{children}</h2>;
}
function ago(ms: number): string {
if (!ms) return "—";
const secs = Math.floor((Date.now() - ms) / 1000);
@@ -189,26 +195,49 @@ function ServicesPanel({ data }: { data: OperationsSnapshot }) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-1">ניהול תהליכי-רקע (pm2)</h2>
<p className="text-ink-muted text-xs mb-4">
כמו &quot;שירותים&quot; ב-Windows. דמון = שירות רץ-תמיד (הפעל-מחדש/עצור/הפעל).
תזמון (cron) = רץ לפי לוח-זמנים (&quot;הרץ עכשיו&quot; להרצה מיידית, ומתג
הפעלה/כיבוי של התזמון).
ניהול תהליכי-רקע (pm2) כמו &quot;שירותים&quot; ב-Windows. דמון = שירות
רץ-תמיד (הפעל-מחדש/עצור/הפעל). תזמון (cron) = רץ לפי לוח-זמנים
(&quot;הרץ עכשיו&quot; להרצה מיידית, ומתג הפעלה/כיבוי של התזמון).
</p>
{data.services_error ? (
<p className="text-sm text-destructive">{data.services_error}</p>
) : data.services.length === 0 ? (
<p className="text-sm text-ink-muted">אין שירותים.</p>
) : (
<div className="grid gap-2">
/* mockup 02: services as a table — שירות · סטטוס · זמן-ריצה · זיכרון · controls */
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-rule-soft text-ink-muted">
<th className="text-start font-medium text-xs py-2 pe-3">שירות</th>
<th className="text-start font-medium text-xs py-2 px-3">סטטוס</th>
<th className="text-start font-medium text-xs py-2 px-3 whitespace-nowrap">
זמן-ריצה
</th>
<th className="text-start font-medium text-xs py-2 px-3 whitespace-nowrap">
זיכרון /
</th>
<th className="py-2 ps-3" />
</tr>
</thead>
<tbody>
{data.services.map((s: OpsService) => {
const isCron = !!s.cron;
return (
<div
<tr
key={s.name}
className="flex items-center justify-between gap-3 rounded-md border border-rule-soft bg-rule-soft/30 px-3 py-2"
className="border-b border-rule-soft last:border-0 align-top"
>
<div className="min-w-0">
<td className="py-2.5 pe-3">
<div className="text-navy font-semibold text-[0.82rem]">
{SERVICE_LABELS[s.name] ?? s.name}
</div>
<div className="text-[0.66rem] text-ink-muted font-mono" dir="ltr">
{s.name}
</div>
</td>
<td className="py-2.5 px-3">
<div className="flex items-center gap-2 flex-wrap">
{isCron ? (
<Badge
@@ -221,23 +250,23 @@ function ServicesPanel({ data }: { data: OperationsSnapshot }) {
<StatusBadge value={s.status} />
)}
{s.cron ? (
<span className="text-[0.7rem] text-ink-muted font-mono" dir="ltr">
<span
className="text-[0.66rem] text-ink-muted font-mono"
dir="ltr"
>
{s.cron}
</span>
) : null}
</div>
<div className="text-[0.8rem] text-navy truncate mt-0.5">
{SERVICE_LABELS[s.name] ?? s.name}
</div>
<div className="text-[0.66rem] text-ink-muted flex items-center gap-2 flex-wrap">
<span className="font-mono" dir="ltr">
{s.name}
</span>
<span>{mb(s.memory_bytes)}</span>
<span>{s.restarts}</span>
<span>{isCron ? `ריצה אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}</span>
</div>
</div>
</td>
<td className="py-2.5 px-3 text-[0.78rem] text-ink-soft whitespace-nowrap tabular-nums">
{isCron ? `אחרונה ${ago(s.uptime_ms)}` : ago(s.uptime_ms)}
</td>
<td className="py-2.5 px-3 text-[0.78rem] text-ink-soft whitespace-nowrap tabular-nums">
{mb(s.memory_bytes)} · {s.restarts}
</td>
<td className="py-2.5 ps-3">
<div className="flex justify-end">
<ServiceControls
s={s}
busy={busy}
@@ -245,8 +274,12 @@ function ServicesPanel({ data }: { data: OperationsSnapshot }) {
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
@@ -336,6 +369,7 @@ function PipelineCard({
children,
href,
hrefLabel,
gate = false,
}: {
title: string;
desc: string;
@@ -344,9 +378,18 @@ function PipelineCard({
// (/approvals), never duplicated here. /operations only monitors.
href?: string;
hrefLabel?: string;
// mockup 02: gate cards get a gold-wash treatment + gold border so the
// human-gates read as distinct from the automatic pipelines.
gate?: boolean;
}) {
return (
<Card className="bg-surface border-rule shadow-sm">
<Card
className={
gate
? "bg-gold-wash border-gold/40 shadow-sm"
: "bg-surface border-rule shadow-sm"
}
>
<CardContent className="px-5 py-4 space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
@@ -356,7 +399,7 @@ function PipelineCard({
{href && (
<Link
href={href}
className="shrink-0 text-[0.72rem] text-gold-deep hover:underline whitespace-nowrap"
className="shrink-0 text-[0.72rem] text-gold-deep font-semibold hover:underline whitespace-nowrap"
>
{hrefLabel ?? "לטיפול ←"}
</Link>
@@ -456,10 +499,9 @@ function LiveAgentsPanel() {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<div className="flex items-center justify-between gap-3 mb-1 flex-wrap">
<h2 className="text-navy text-lg mb-0">סוכנים פעילים</h2>
{/* mockup 02: status pills row (running / queued) + company hint */}
{data ? (
<div className="flex items-center gap-2 text-[0.72rem]">
<div className="flex items-center gap-2 text-[0.72rem] mb-3 flex-wrap">
{/* ADM-6 (INV-IA5): counts sum only the companies that loaded.
When a company errored, mark the totals as a floor ("+") so
the operator isn't shown a shrunken depth as if complete. */}
@@ -477,9 +519,9 @@ function LiveAgentsPanel() {
חלקי
</span>
) : null}
<span className="text-ink-muted ms-auto">חברות: CMP · CMPA</span>
</div>
) : null}
</div>
<p className="text-ink-muted text-xs mb-4">
מי מבין סוכני-הוועדה עובד כרגע ומה הפלט שלו כולל עבודה שלא קשורה לתיק (כמו
ריקון תור הלכות ע״י ה-CEO). עצירה היא מבוקרת דרך הפלטפורמה (לא kill).
@@ -597,10 +639,14 @@ export default function OperationsPage() {
</div>
) : (
<>
<SectionHeader>סוכנים פעילים</SectionHeader>
<LiveAgentsPanel />
<SectionHeader>שירותים</SectionHeader>
<ServicesPanel data={data} />
<SectionHeader>צינורות-עבודה</SectionHeader>
{/* Automatic pipelines — uniform stat cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<PipelineCard
title="אחזור פסיקה (X13)"
@@ -633,10 +679,14 @@ export default function OperationsPage() {
לפסיקה
</p>
</PipelineCard>
</div>
{/* mockup 02: human-gate pointer cards — gold-wash, "לתיבת-האישורים ←" */}
<div className="grid gap-4 md:grid-cols-2 mt-4">
<PipelineCard
gate
title="אישור הלכות (שער יו״ר)"
desc="הלכות שחולצו, ממתינות להכרעת דפנה — שער-אנושי, לא תהליך"
desc="שער-אנושי, לא תהליך — הפעולה ב-/approvals"
href="/approvals"
hrefLabel="לתיבת-האישורים ←"
>
@@ -644,6 +694,7 @@ export default function OperationsPage() {
</PipelineCard>
<PipelineCard
gate
title="פסיקה חסרה"
desc="פערים בקורפוס — נסגרים אוטומטית כשנקלטים"
href="/approvals"
@@ -653,6 +704,7 @@ export default function OperationsPage() {
</PipelineCard>
</div>
<SectionHeader>אחזורים אחרונים</SectionHeader>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4">
@@ -712,9 +764,9 @@ export default function OperationsPage() {
{/* INV-IA4: the former /diagnostics surface, folded in here — one
monitoring intent, one surface. /diagnostics now redirects here. */}
<div className="space-y-3 pt-2">
<h2 className="text-navy text-lg mb-0">בריאות-מערכת</h2>
<p className="text-ink-muted text-sm">
<div className="pt-2">
<SectionHeader>בריאות-מערכת</SectionHeader>
<p className="text-ink-muted text-sm mb-3">
מצב ה-DB, תורי-אישור, ומסמכים שנכשלו/תקועים. (אוחד מ״אבחון״.)
</p>
<SystemHealthSection />

View File

@@ -9,8 +9,30 @@ import { AppealTypeBars, subtypeOf } from "@/components/cases/appeal-type-bars";
import { CasesTable } from "@/components/cases/cases-table";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useCases, type Case } from "@/lib/api/cases";
import { usePendingApprovals } from "@/lib/api/chair";
import { useCases, type Case, type CaseStatus } from "@/lib/api/cases";
import {
usePendingApprovals,
type ApprovalSeverity,
} from "@/lib/api/chair";
// severity dot per the approved 04-home mockup gate-list (.dot.high/.med/.ok)
const SEVERITY_DOT: Record<ApprovalSeverity, string> = {
high: "bg-danger",
medium: "bg-warn",
low: "bg-info",
ok: "bg-success",
};
// "תיקים לפי סטטוס" horizontal status bars (mockup 04: .bar / .track / .fill).
// Driven by the same live cases the KPI row uses — five status groups onto the
// gold/info/success/danger/muted palette.
type StatusBarRow = { label: string; fill: string; match: CaseStatus[] };
const STATUS_BARS: StatusBarRow[] = [
{ label: "בהכנה", fill: "bg-info", match: ["new", "uploading", "processing", "documents_ready", "analyst_verified", "research_complete", "outcome_set"] },
{ label: "ניתוח וכיוון", fill: "bg-gold", match: ["brainstorming", "direction_approved", "analysis_enriched", "ready_for_writing"] },
{ label: "בכתיבה", fill: "bg-warn", match: ["drafting", "qa_review", "drafted"] },
{ label: "הושלם", fill: "bg-success", match: ["exported", "reviewed", "final"] },
];
export default function HomePage() {
const { data, isPending, error } = useCases(true);
@@ -30,9 +52,19 @@ export default function HomePage() {
return { permits, levies };
}, [data]);
const statusBars = useMemo(() => {
const cases = data ?? [];
const counts = STATUS_BARS.map((b) => ({
...b,
n: cases.filter((c) => b.match.includes(c.status)).length,
}));
const max = Math.max(1, ...counts.map((c) => c.n));
return { counts, max };
}, [data]);
return (
<AppShell>
<section className="space-y-8">
<section className="space-y-7">
<header className="flex items-end justify-between gap-6 flex-wrap">
<div className="space-y-1.5">
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
@@ -40,21 +72,53 @@ export default function HomePage() {
</div>
<h1 className="text-navy">עוזר משפטי</h1>
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
לוח בקרה לניהול תיקי ערר, ניתוח סגנון, וכתיבת החלטות לפי ארכיטקטורת
12 הבלוקים.
מבט-על על הוועדה תיקים, אישורים ופעילות אחרונה במקום אחד.
</p>
</div>
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<div className="flex gap-2.5">
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
<Link href="/cases/new">+ תיק חדש</Link>
</Button>
<Button asChild variant="outline" className="border-rule text-navy">
<Link href="/precedents">חיפוש בקורפוס</Link>
</Button>
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{/* KPI row — mockup 04 .kpis (4-up, gold-washed "ממתינים לאישור") */}
<KPICards cases={data} loading={isPending} />
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* two-column body — main flow + narrow gold gate rail (mockup 04 .cols) */}
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<div className="space-y-6 min-w-0">
{/* תיקים לפי סטטוס — horizontal bars (mockup 04) */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4">תיקים לפי סטטוס</h2>
<ul className="space-y-3">
{statusBars.counts.map((b) => (
<li key={b.label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-[0.82rem] text-ink-soft">
{b.label}
</span>
<span className="flex-1 h-3.5 rounded-full bg-rule-soft overflow-hidden">
<span
className={`block h-full rounded-full ${b.fill} transition-[width] duration-500`}
style={{ width: `${(b.n / statusBars.max) * 100}%` }}
/>
</span>
<span className="w-9 shrink-0 text-end text-[0.82rem] font-semibold text-navy tabular-nums">
{isPending ? "—" : b.n}
</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* live case tables kept in full (richer than the mockup's single feed) */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
@@ -103,38 +167,39 @@ export default function HomePage() {
</div>
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
{approvals && approvals.total_pending > 0 ? (
<Card className="bg-gold-wash border-gold/40 shadow-sm">
{/* מה ממתין להכרעתך — gold gate card with dot+label+count rows (mockup 04 .gatecard) */}
<Card className="bg-gold-wash border-gold/50 shadow-sm">
<CardContent className="px-6 py-5">
<div className="flex items-center justify-between gap-3 mb-3">
<h2 className="text-navy text-lg mb-0">מה ממתין להכרעתך</h2>
<span className="text-2xl font-semibold text-gold-deep leading-none tabular-nums">
{approvals.total_pending}
</span>
</div>
<ul className="space-y-1.5 mb-4">
{approvals.categories
.filter((c) => c.count > 0)
.map((c) => (
<h2 className="text-navy text-lg mb-3">מה ממתין להכרעתך</h2>
{approvals && approvals.categories.length > 0 ? (
<ul className="mb-1">
{approvals.categories.map((c) => (
<li
key={c.key}
className="flex items-center justify-between gap-2 text-[0.85rem] text-ink-soft"
className="flex items-center gap-2.5 py-2.5 text-[0.88rem] text-ink-soft border-b border-rule-soft last:border-b-0"
>
<span>{c.label}</span>
<span className="text-navy font-semibold tabular-nums">{c.count}</span>
<span
className={`h-2.5 w-2.5 shrink-0 rounded-full ${SEVERITY_DOT[c.severity]}`}
aria-hidden
/>
<span className="grow min-w-0">{c.label}</span>
<span className="font-bold text-navy tabular-nums">{c.count}</span>
</li>
))}
</ul>
<Button
asChild
size="sm"
className="bg-gold text-white hover:bg-gold-deep border-transparent w-full"
) : (
<p className="text-[0.85rem] text-ink-muted mb-2">
אין פריטים הממתינים להכרעתך.
</p>
)}
<Link
href="/approvals"
className="inline-block mt-3 text-[0.85rem] font-semibold text-gold-deep hover:text-navy"
>
<Link href="/approvals">למרכז האישורים </Link>
</Button>
למרכז האישורים
</Link>
</CardContent>
</Card>
) : null}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">

View File

@@ -5,7 +5,6 @@ import Link from "next/link";
import { Pencil, Check, X, Share2 } from "lucide-react";
import { toast } from "sonner";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -34,6 +33,16 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
appeals_committee: "ועדת ערר",
};
/** label/value pair in the parchment meta-band (mockup 08 `.mb`). */
function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-[0.7rem] text-ink-muted font-medium">{label}</span>
<span className="text-[0.84rem] text-ink font-semibold">{children}</span>
</div>
);
}
/* Next 16 breaking change: route params are now a Promise.
* The `use()` hook unwraps them inside a client component. */
export default function PrecedentDetailPage({
@@ -48,61 +57,77 @@ export default function PrecedentDetailPage({
const [editingCitation, setEditingCitation] = useState(false);
const [citationDraft, setCitationDraft] = useState("");
if (error) {
return (
<AppShell>
<section className="space-y-6" dir="rtl">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<Link href="/precedents" className="hover:text-gold-deep">ספריית פסיקה</Link>
<span aria-hidden> · </span>
<span className="text-navy">פרטי פסיקה</span>
</nav>
</header>
{error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-6 text-center space-y-3">
<div className="rounded-lg border border-danger/40 bg-danger-bg px-6 py-6 text-center space-y-3">
<p className="text-danger font-semibold">שגיאה בטעינת הפסיקה</p>
<p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline">
<Link href="/precedents">חזרה לספרייה</Link>
</Button>
</CardContent>
</Card>
) : isPending || !data ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : (
<>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
</section>
</AppShell>
);
}
if (isPending || !data) {
return (
<AppShell>
<section className="space-y-6" dir="rtl">
<Skeleton className="h-40 w-full" />
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-28 w-full" />)}
</div>
<Skeleton className="h-64 w-full" />
</div>
</section>
</AppShell>
);
}
const date = data.date ? data.date.slice(0, 10) : null;
return (
<AppShell>
<div dir="rtl">
{/* ── parchment header band (mockup 08 `.band`) — breaks out to the
AppShell <main> edges (px-10 py-10) for a full-width band. ──── */}
<div className="-mx-10 -mt-10 mb-6 border-b border-rule bg-parchment px-10 py-6">
<nav className="text-[0.78rem] text-ink-muted mb-2">
<Link href="/precedents" className="text-gold-deep hover:underline">פסיקה</Link>
<span aria-hidden> </span>
<Link href="/precedents" className="text-gold-deep hover:underline">ספרייה</Link>
<span aria-hidden> </span>
<span>תקדים</span>
</nav>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<h1 className="text-navy text-2xl font-semibold mb-1 leading-tight">
<div className="min-w-0">
<h1 className="text-navy text-2xl font-bold leading-snug mb-1">
{data.case_name || "—"}
</h1>
<div className="text-ink-muted text-sm font-mono" dir="ltr">
<div className="text-ink-soft text-sm font-mono" dir="ltr">
{data.case_number}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<Button asChild variant="outline" size="sm" className="border-rule">
<Link href={`/graph?focus=cl:${id}`}>
<Share2 className="w-3.5 h-3.5 me-1" /> הצג בגרף
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Button variant="outline" size="sm" className="border-rule" onClick={() => setEditing(true)}>
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
</Button>
</div>
</div>
{/* Citation per Israeli unified citation rules. The LLM
extractor composes this from the document; the chair
can override below. */}
{/* citation (unified Israeli citation rules) — chair-editable */}
<div className="mt-4 max-w-3xl">
<CitationBlock
precedent={data as Precedent}
editing={editingCitation}
@@ -122,113 +147,134 @@ export default function PrecedentDetailPage({
toast.success("מראה מקום עודכן");
setEditingCitation(false);
} catch (e) {
toast.error(
e instanceof Error ? e.message : "שמירה נכשלה",
);
toast.error(e instanceof Error ? e.message : "שמירה נכשלה");
}
}}
saving={update.isPending}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* meta-band — label/value pairs + chips (mockup 08 `.metaband`) */}
<div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-3">
{data.court ? <MetaItem label="בית-משפט">{data.court}</MetaItem> : null}
{date ? (
<MetaItem label="תאריך">
<span className="tabular-nums" dir="ltr">{date}</span>
</MetaItem>
) : null}
{data.practice_area ? (
<Badge variant="outline" className="text-[0.7rem] bg-info-bg text-info border-transparent">
<MetaItem label="תחום">
<Badge variant="outline" className="text-[0.72rem] bg-info-bg text-info border-transparent rounded-full px-3">
{PRACTICE_AREA_LABELS[data.practice_area] ?? data.practice_area}
</Badge>
</MetaItem>
) : null}
{data.source_type ? (
<Badge variant="outline" className="text-[0.7rem]">
<MetaItem label="סוג-מקור">
<Badge variant="outline" className="text-[0.72rem] rounded-full px-3">
{SOURCE_TYPE_LABELS[data.source_type] ?? data.source_type}
</Badge>
</MetaItem>
) : null}
{data.precedent_level ? (
<Badge variant="outline" className="text-[0.7rem] bg-gold-wash text-gold-deep border-rule">
<MetaItem label="רמת-תקדים">
<Badge variant="outline" className="text-[0.72rem] bg-gold-wash text-gold-deep border-rule rounded-full px-3">
{data.precedent_level}
</Badge>
</MetaItem>
) : null}
{data.is_binding ? (
<Badge
variant="outline"
className="text-[0.7rem] bg-success-bg text-success border-transparent"
>
הלכה מחייבת
<MetaItem label="סיווג-מחייבות">
<Badge variant="outline" className="text-[0.72rem] bg-success-bg text-success border-transparent rounded-full px-3">
מחייב
</Badge>
) : null}
{data.court ? (
<span className="text-[0.78rem] text-ink-muted">{data.court}</span>
) : null}
{data.date ? (
<span className="text-[0.78rem] text-ink-muted tabular-nums" dir="ltr">
{data.date.slice(0, 10)}
</span>
</MetaItem>
) : null}
</div>
{data.headnote ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">Headnote</h3>
<p className="text-ink-soft text-sm leading-relaxed m-0">
{data.headnote}
</p>
</div>
) : null}
{data.summary ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">תקציר</h3>
<p className="text-ink-soft text-sm leading-relaxed m-0 whitespace-pre-line">
{data.summary}
</p>
</div>
) : null}
{(data as { key_quote?: string }).key_quote ? (
<div>
<h3 className="text-navy text-sm font-semibold m-0 mb-1">ציטוט מרכזי</h3>
<blockquote className="text-ink-soft text-sm leading-relaxed border-s-[3px] border-gold bg-gold-wash ps-3 pe-4 py-3 rounded-e m-0">
{(data as { key_quote?: string }).key_quote}
</blockquote>
</div>
) : null}
{data.subject_tags?.length ? (
<div className="flex items-center gap-1 flex-wrap pt-1">
<div className="mt-3 flex items-center gap-1 flex-wrap">
{data.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
<Badge key={t} variant="outline" className="text-[0.65rem] bg-surface">
{t}
</Badge>
))}
</div>
) : null}
</CardContent>
</Card>
</div>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
/>
</CardContent>
</Card>
{/* ── two-column body (mockup 08 `.wrap` grid) ────────────────── */}
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* main column */}
<div className="space-y-4">
{data.summary ? (
<DetailCard title="תקציר">
<p className="text-ink-soft text-sm leading-8 m-0 whitespace-pre-line">
{data.summary}
</p>
</DetailCard>
) : null}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
{data.headnote ? (
<DetailCard title="כותרת-הלכה (headnote)" prov="opus">
<p className="text-ink-soft text-sm leading-8 m-0">{data.headnote}</p>
</DetailCard>
) : null}
{(data as { key_quote?: string }).key_quote ? (
<DetailCard title="ציטוט-מפתח" prov="opus">
<blockquote className="rounded-e border-s-[3px] border-gold bg-gold-wash px-4 py-3 text-sm text-ink-soft leading-8 m-0">
{(data as { key_quote?: string }).key_quote}
</blockquote>
</DetailCard>
) : null}
<div className="rounded-lg border border-rule bg-surface shadow-sm px-5 py-4">
<ExtractedHalachotSection halachot={data.halachot ?? []} />
</CardContent>
</Card>
</>
)}
</div>
</div>
{/* side rail — citations + corroboration */}
<div className="space-y-4">
<RelatedCasesSection caseId={id} related={data.related_cases ?? []} />
</div>
</div>
</div>
<PrecedentEditSheet
caseLawId={editing ? id : null}
onOpenChange={(open) => setEditing(open)}
/>
</section>
</AppShell>
);
}
/** main-column card with a navy heading + optional provenance pill
* ("מולא ע״י Opus", mockup 08 `.prov`). */
function DetailCard({
title,
prov,
children,
}: {
title: string;
prov?: "opus";
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm px-5 py-4">
<h2 className="text-navy text-[1.05rem] font-semibold mb-1.5 flex items-center gap-2.5 flex-wrap">
{title}
{prov === "opus" ? (
<span className="inline-flex items-center rounded-full bg-info-bg text-info text-[0.68rem] font-semibold px-2.5 py-0.5">
מולא ע״י Opus
</span>
) : null}
</h2>
{children}
</div>
);
}
function CitationBlock({
precedent,
editing,
@@ -252,7 +298,7 @@ function CitationBlock({
if (editing) {
return (
<div className="rounded-md border border-gold/40 bg-gold-wash/30 p-3 space-y-2">
<div className="rounded-md border border-gold/40 bg-gold-wash/40 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[0.78rem] font-semibold text-navy">
עריכת מראה מקום
@@ -266,7 +312,7 @@ function CitationBlock({
onChange={(e) => onChange(e.target.value)}
rows={3}
dir="rtl"
className="font-mono text-sm"
className="font-mono text-sm bg-surface"
placeholder='ערר (ועדות ערר ...) 1234/24 **עורר נ&apos; הוועדה המקומית** (נבו 1.2.2025)'
disabled={saving}
/>
@@ -280,12 +326,7 @@ function CitationBlock({
<Check className="w-3.5 h-3.5 me-1" />
שמור
</Button>
<Button
size="sm"
variant="outline"
onClick={onCancel}
disabled={saving}
>
<Button size="sm" variant="outline" onClick={onCancel} disabled={saving}>
<X className="w-3.5 h-3.5 me-1" />
ביטול
</Button>
@@ -296,7 +337,7 @@ function CitationBlock({
if (!citation) {
return (
<div className="rounded-md border border-dashed border-rule bg-rule-soft/30 p-3 flex items-center justify-between gap-2">
<div className="rounded-md border border-dashed border-rule bg-surface/60 p-3 flex items-center justify-between gap-2">
<span className="text-[0.78rem] text-ink-muted">
מראה מקום (כללי הציטוט האחיד) טרם חולץ
</span>
@@ -309,7 +350,7 @@ function CitationBlock({
}
return (
<div className="rounded-md border border-rule bg-parchment-50 p-3 space-y-1.5">
<div className="rounded-md border border-rule bg-surface p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[0.7rem] uppercase tracking-wide text-ink-muted">
מראה מקום

View File

@@ -1,15 +1,14 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { AppShell } from "@/components/app-shell";
import { LibraryListPanel } from "@/components/precedents/library-list-panel";
import { LibrarySearchPanel } from "@/components/precedents/library-search-panel";
import { HalachaReviewPanel } from "@/components/precedents/halacha-review-panel";
import { LibraryStatsPanel } from "@/components/precedents/library-stats-panel";
import { useHalachotPending } from "@/lib/api/precedent-library";
import { useMissingPrecedents } from "@/lib/api/missing-precedents";
/**
* Precedent Library admin page.
@@ -25,72 +24,133 @@ import { useHalachotPending } from "@/lib/api/precedent-library";
* per-case precedent attacher (chair-attached quotes scoped to a case).
*/
function PendingBadge() {
const { data } = useHalachotPending();
const n = data?.count ?? 0;
/** Colored count pill riding on a tab trigger (mockup 07: warn for review
* queue, info for incoming). Returns null when the queue is empty. */
function CountPill({ n, tone }: { n: number; tone: "warn" | "info" }) {
if (!n) return null;
const cls =
tone === "warn"
? "bg-warn text-white"
: "bg-info text-white";
return (
<Badge
variant="outline"
className="ms-1 bg-gold-wash text-gold-deep border-gold/40 text-[0.65rem]"
<span
className={`ms-1.5 inline-flex items-center justify-center rounded-full px-1.5 min-w-[1.15rem] h-[1.15rem] text-[0.68rem] font-semibold tabular-nums ${cls}`}
>
{n}
</Badge>
</span>
);
}
function PendingPill() {
const { data } = useHalachotPending();
return <CountPill n={data?.count ?? 0} tone="warn" />;
}
function IncomingPill() {
// "פסיקה נכנסת" = open missing-precedents waiting for the chair to upload.
const { data } = useMissingPrecedents({ status: "open", limit: 1 });
return <CountPill n={data?.by_status?.open ?? 0} tone="info" />;
}
export default function PrecedentsPage() {
return (
<AppShell>
<Tabs defaultValue="library" dir="rtl">
<section className="space-y-6">
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<header className="space-y-3">
<nav className="text-[0.78rem] text-ink-muted">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">ספריית פסיקה</span>
</nav>
<div className="space-y-1">
<h1 className="text-navy mb-0">ספריית הפסיקה הסמכותית</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
פסיקה חיצונית פסקי דין של ערכאות עליונות והחלטות של ועדות ערר אחרות.
כל קובץ עובר חילוץ הלכות אוטומטי, וההלכות ממתינות לאישור היו&quot;ר לפני
שהן זמינות לסוכני הכתיבה (legal-writer וכו&apos;).
<p className="text-ink-muted text-sm mt-1 max-w-3xl leading-relaxed">
קורפוס הפסיקה והלכות המערכת חיפוש סמנטי, תור-אישור והשלמת
פסיקה חסרה. כל קובץ עובר חילוץ הלכות אוטומטי, וההלכות ממתינות
לאישור היו&quot;ר לפני שהן זמינות לסוכני הכתיבה.
</p>
</div>
{/* tabs as a dedicated row under the header — underline-style
triggers with colored count pills (mockup 07). */}
<TabsList className="flex w-full justify-start gap-1 rounded-none border-0 border-b border-rule bg-transparent p-0 h-auto">
{[
{ value: "library", label: "ספרייה", pill: null },
{ value: "search", label: "חיפוש בקורפוס", pill: null },
{ value: "review", label: "תור הלכות", pill: <PendingPill /> },
{
value: "incoming",
label: "פסיקה נכנסת",
pill: <IncomingPill />,
},
{ value: "stats", label: "סטטיסטיקה", pill: null },
].map((t) => (
<TabsTrigger
key={t.value}
value={t.value}
className="rounded-none border-0 border-b-2 border-transparent bg-transparent px-4 py-2.5 -mb-px text-sm font-medium text-ink-muted shadow-none data-[state=active]:border-gold data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-navy data-[state=active]:shadow-none"
>
{t.label}
{t.pill}
</TabsTrigger>
))}
</TabsList>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<Tabs defaultValue="library" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="library">ספרייה</TabsTrigger>
<TabsTrigger value="search">חיפוש סמנטי</TabsTrigger>
<TabsTrigger value="review">
ממתין לאישור
<PendingBadge />
</TabsTrigger>
<TabsTrigger value="stats">סטטיסטיקה</TabsTrigger>
</TabsList>
<TabsContent value="library" className="mt-5">
<TabsContent value="library" className="mt-0">
<LibraryListPanel />
</TabsContent>
<TabsContent value="search" className="mt-5">
<TabsContent value="search" className="mt-0">
<LibrarySearchPanel />
</TabsContent>
<TabsContent value="review" className="mt-5">
<TabsContent value="review" className="mt-0">
<HalachaReviewPanel />
</TabsContent>
<TabsContent value="stats" className="mt-5">
{/* "פסיקה נכנסת" — the incoming/missing-precedent queue. Kept as a
tab per the mockup; full management lives on /missing-precedents. */}
<TabsContent value="incoming" className="mt-0">
<IncomingTab />
</TabsContent>
<TabsContent value="stats" className="mt-0">
<LibraryStatsPanel />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
</Tabs>
</AppShell>
);
}
/** Lightweight in-tab pointer to the dedicated missing-precedents page,
* preserving the mockup's "פסיקה נכנסת" tab without duplicating the table. */
function IncomingTab() {
const { data } = useMissingPrecedents({ status: "open", limit: 1 });
const open = data?.by_status?.open ?? 0;
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm p-6 space-y-3">
<div className="flex items-baseline gap-3 flex-wrap">
<h2 className="text-navy text-lg font-semibold m-0">פסיקה נכנסת</h2>
{open ? (
<span className="inline-flex items-baseline gap-1.5 rounded-lg border border-rule bg-warn-bg px-3 py-1">
<span className="text-base font-bold text-warn tabular-nums">{open}</span>
<span className="text-[0.8rem] text-ink-soft">פתוחים</span>
</span>
) : null}
</div>
<p className="text-ink-soft text-sm leading-relaxed max-w-2xl">
פסיקה שצוטטה בכתבי-הטענות אך אינה קיימת בקורפוס. השלמתה מאפשרת
אימות-הלכה ועיגון-מקור (INV-AH).
</p>
<Link
href="/missing-precedents"
className="inline-flex items-center rounded-md bg-gold px-4 py-2 text-sm font-semibold text-white hover:bg-gold-deep"
>
לניהול פסיקה חסרה
</Link>
</div>
);
}

View File

@@ -1,27 +1,133 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Markdown } from "@/components/ui/markdown";
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { fetchScriptsCatalog } from "@/lib/api/scripts";
/*
* /scripts — read-only catalog of everything under scripts/.
* /scripts — catalog of everything under scripts/, rendered as the
* approved IA-redesign table (name mono · role · status chip · run/source
* ghost button).
*
* The content is `scripts/SCRIPTS.md` verbatim (active · archived · deleted
* tables), served by GET /api/scripts/catalog. SCRIPTS.md is the single
* source of truth — CLAUDE.md mandates updating it on every script change —
* so we render it directly rather than re-describing the scripts here.
* The single source of truth is still `scripts/SCRIPTS.md` (CLAUDE.md mandates
* updating it on every script change), served verbatim by
* GET /api/scripts/catalog. We parse its markdown tables into structured rows
* for display — editing remains git/Gitea only, so the per-row "מקור" button
* deep-links to the file in Gitea rather than inventing a run-from-UI mutation.
*/
type ScriptStatus = "active" | "once" | "archive" | "deleted";
type ScriptRow = {
name: string;
role: string;
status: ScriptStatus;
};
const STATUS_LABEL: Record<ScriptStatus, string> = {
active: "פעיל",
once: "חד-פעמי",
archive: "ארכיון",
deleted: "נמחק",
};
const STATUS_TONE: Record<ScriptStatus, { wrap: string; dot: string }> = {
active: { wrap: "bg-success-bg text-success", dot: "bg-success" },
once: { wrap: "bg-info-bg text-info", dot: "bg-info" },
archive: { wrap: "bg-rule-soft text-ink-muted", dot: "bg-ink-muted" },
deleted: { wrap: "bg-danger-bg text-danger", dot: "bg-danger" },
};
// "חד-פעמי" / "one-shot" markers inside the Scheduled column of an active row.
const ONCE_RE = /חד-?פעמי|one-?shot|בוצע/;
/**
* Parse SCRIPTS.md markdown tables into typed rows. The file has three
* sections with different shapes; we read the first two columns of each
* (name + role) and derive status from the section + scheduling note.
*/
function parseScripts(md: string): ScriptRow[] {
const lines = md.split("\n");
const rows: ScriptRow[] = [];
let section: ScriptStatus = "active";
for (const raw of lines) {
const line = raw.trim();
if (line.startsWith("## ")) {
if (line.includes(".archive") || line.includes("הושלמו")) section = "archive";
else if (line.includes("נמחק")) section = "deleted";
else section = "active";
continue;
}
if (!line.startsWith("|")) continue;
// skip header + separator rows
const cells = line
.split("|")
.slice(1, -1)
.map((c) => c.trim());
if (cells.length < 2) continue;
if (/^-+$/.test(cells[0].replace(/[-:]/g, "-"))) continue; // separator
if (cells[0] === "Script") continue; // header
if (!cells[0]) continue;
const name = cells[0].replace(/`/g, "");
if (!name) continue;
let status: ScriptStatus = section;
if (section === "active") {
const scheduled = cells[3] ?? "";
status = ONCE_RE.test(scheduled) ? "once" : "active";
}
// role: active = Purpose (col 2), archive = Original Purpose (col 1),
// deleted = Reason (col 1).
const role =
section === "active" ? cells[2] ?? cells[1] ?? "" : cells[1] ?? "";
rows.push({ name, role: stripMd(role), status });
}
return rows;
}
// Strip bold/inline-code markdown so the role reads as plain text in a cell.
function stripMd(s: string): string {
return s.replace(/\*\*/g, "").replace(/`/g, "");
}
function StatusChip({ status }: { status: ScriptStatus }) {
const tone = STATUS_TONE[status];
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-[3px] text-[0.75rem] font-semibold ${tone.wrap}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} aria-hidden />
{STATUS_LABEL[status]}
</span>
);
}
export default function ScriptsPage() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["scripts-catalog"],
queryFn: ({ signal }) => fetchScriptsCatalog(signal),
});
const rows = useMemo(
() => (data?.content ? parseScripts(data.content) : []),
[data],
);
const lastModified =
data?.last_modified != null
? new Date(data.last_modified * 1000).toLocaleDateString("he-IL", {
@@ -31,63 +137,113 @@ export default function ScriptsPage() {
})
: null;
const giteaBase = data?.gitea_url ?? null;
return (
<AppShell>
<section className="space-y-6">
<div className="flex items-end justify-between gap-4">
<div>
<header>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">סקריפטים</span>
</nav>
<h1 className="text-navy mb-0">סקריפטים</h1>
<p className="text-sm text-ink-muted mt-1">
קטלוג כל הסקריפטים בתיקיית{" "}
<code className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem]">
scripts/
</code>{" "}
שם, סוג, תפקיד ותזמון. מקור-האמת הוא{" "}
<p className="text-sm text-ink-muted mt-1 max-w-2xl">
סקריפטי-תחזוקה ותפעול. מקור-האמת הוא{" "}
<code className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem]">
scripts/SCRIPTS.md
</code>
; עריכה דרך git, לא מכאן.
</code>{" "}
עריכה דרך git, לא מכאן.
</p>
</div>
{data?.gitea_url ? (
<a
href={data.gitea_url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-sm text-gold-deep hover:text-gold underline underline-offset-2"
>
מקור ב-Gitea
</a>
) : null}
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
{isLoading ? (
<p className="text-sm text-ink-muted">טוען קטלוג</p>
<Card className="bg-surface border-rule px-6 py-5 text-sm text-ink-muted">
טוען קטלוג
</Card>
) : isError ? (
<p className="text-sm text-danger">
<Card className="bg-danger-bg border-danger/40 px-6 py-5 text-sm text-danger">
שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"}
</p>
) : data ? (
<>
<Markdown content={data.content} />
</Card>
) : (
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<Table>
<TableHeader>
<TableRow className="bg-parchment hover:bg-parchment border-rule">
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
שם הסקריפט
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
תפקיד
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
סטטוס
</TableHead>
<TableHead className="text-end text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
פעולה
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((s) => {
const disabled = s.status === "archive" || s.status === "deleted";
const href = giteaBase
? `${giteaBase.replace(/\/$/, "")}/${s.name}`
: null;
return (
<TableRow
key={s.name}
className="border-rule-soft hover:bg-gold-wash align-middle"
>
<TableCell className="px-5 py-3.5">
<code
className="font-mono text-[0.81rem] font-semibold text-navy"
dir="ltr"
>
{s.name}
</code>
</TableCell>
<TableCell className="px-5 py-3.5 text-ink-soft text-[0.84rem] leading-snug max-w-xl whitespace-normal">
<span className="line-clamp-2">{s.role}</span>
</TableCell>
<TableCell className="px-5 py-3.5">
<StatusChip status={s.status} />
</TableCell>
<TableCell className="px-5 py-3.5 text-end">
{disabled || !href ? (
<button
type="button"
disabled
className="rounded-lg border border-rule-soft px-4 py-1.5 text-[0.81rem] font-semibold text-ink-muted cursor-default"
>
מקור
</button>
) : (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-block rounded-lg border border-rule px-4 py-1.5 text-[0.81rem] font-semibold text-gold-deep hover:bg-gold-wash hover:border-gold transition-colors"
>
מקור
</a>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{lastModified ? (
<p className="mt-6 pt-3 border-t border-rule text-xs text-ink-muted">
<p className="px-5 py-3 border-t border-rule text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}
</p>
) : null}
</>
) : null}
</CardContent>
</Card>
)}
</section>
</AppShell>
);

View File

@@ -1,7 +1,6 @@
"use client";
import { AlertTriangle, CheckCircle2, HelpCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
type Props = {
drift: boolean;
@@ -9,31 +8,31 @@ type Props = {
coolifyAvailable?: boolean;
};
// Filled pill chips matching IA-redesign mockup 15 (.c-synced / .c-drift).
export function DriftBadge({ drift, coolifyAvailable = true }: Props) {
if (!coolifyAvailable) {
return (
<Badge
variant="outline"
className="text-ink-muted border-rule gap-1"
<span
className="inline-flex items-center gap-1 rounded-full bg-rule-soft text-ink-muted text-[0.72rem] font-semibold px-2.5 py-0.5"
title="Coolify לא זמין — מצב ה-drift לא ידוע"
>
<HelpCircle className="w-3 h-3" />
Unknown
</Badge>
</span>
);
}
if (drift) {
return (
<Badge variant="outline" className="text-warn border-warn/40 gap-1">
<span className="inline-flex items-center gap-1 rounded-full bg-warn-bg text-warn text-[0.72rem] font-semibold px-2.5 py-0.5">
<AlertTriangle className="w-3 h-3" />
Drift
</Badge>
</span>
);
}
return (
<Badge variant="outline" className="text-success border-success/40 gap-1">
<span className="inline-flex items-center gap-1 rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-2.5 py-0.5">
<CheckCircle2 className="w-3 h-3" />
Synced
</Badge>
</span>
);
}

View File

@@ -3,7 +3,6 @@
import { useState } from "react";
import { ExternalLink, Save, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { McpEnvVar } from "@/lib/api/settings";
import { useUpdateMcpEnv } from "@/lib/api/settings";
import { toast } from "sonner";
@@ -44,36 +43,38 @@ export function EnvVarRow({
`https://coolify.nautilus.marcusgroup.org/project/applications/${coolifyAppUuid}/environment-variables`;
return (
<div className="rounded-md border border-rule p-4 bg-rule-soft/20 hover:bg-rule-soft/40 transition-colors">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="px-5 py-4 border-b border-rule-soft last:border-b-0">
{/* envtop — key + type chip + secret chip + drift/synced chip */}
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-medium text-navy" dir="ltr">
<code className="font-mono text-[0.84rem] font-semibold text-navy" dir="ltr">
{spec.key}
</code>
<Badge variant="outline" className="text-[0.7rem]">
<span className="inline-flex items-center rounded-full bg-info-bg text-info text-[0.72rem] font-semibold px-2.5 py-0.5">
{spec.type}
</Badge>
</span>
{spec.is_secret && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40 gap-1">
<span className="inline-flex items-center gap-1 rounded-full bg-navy text-white text-[0.72rem] font-semibold px-2.5 py-0.5">
<Lock className="w-3 h-3" />
secret
</Badge>
</span>
)}
<DriftBadge drift={spec.drift} coolifyAvailable={coolifyAvailable} />
{spec.has_duplicates && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40">
<span className="inline-flex items-center rounded-full bg-warn-bg text-warn text-[0.72rem] font-semibold px-2.5 py-0.5">
duplicates
</Badge>
</span>
)}
</div>
<p className="text-sm text-ink-muted mt-1">{spec.description}</p>
</div>
</div>
{spec.description && (
<p className="text-[0.82rem] text-ink-muted mt-1">{spec.description}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-[0.72rem] text-ink-muted w-20">Coolify:</span>
{/* vals — Coolify value | Container value (+pending) | save (mockup .vals 3-col) */}
<div className="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3.5 items-end mt-3.5">
<div>
<span className="block text-[0.72rem] text-ink-muted font-semibold mb-1">
Coolify:
</span>
{spec.is_editable ? (
<EnvVarEditor
spec={spec}
@@ -82,53 +83,61 @@ export function EnvVarRow({
disabled={update.isPending}
/>
) : (
<span className="font-mono text-ink" dir="ltr">
{spec.coolify_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[0.72rem] text-ink-muted w-20">Container:</span>
<span className="font-mono text-ink" dir="ltr">
{spec.container_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
{/* ADM-5 (INV-IA5/INV-IA6): when Coolify ≠ Container the container is
running a stale value until a redeploy — say so in plain Hebrew
right here, not only via the top "Drift" badge. */}
{coolifyAvailable && spec.drift && (
<Badge
variant="outline"
className="text-[0.7rem] text-warn border-warn/40"
title="הערך נשמר ב-Coolify אך הקונטיינר עדיין מריץ את הקודם — נדרש redeploy"
>
ממתין ל-redeploy
</Badge>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-3">
{!spec.is_editable && (
<a
href={coolifyEnvUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[0.78rem] text-gold-deep hover:underline flex items-center gap-1"
className="font-mono text-[0.81rem] text-gold-deep hover:underline flex items-center gap-1"
dir="ltr"
>
{spec.coolify_value ?? "— לא מוגדר —"}
<ExternalLink className="w-3 h-3 shrink-0" />
</a>
)}
</div>
<div>
<span className="block text-[0.72rem] text-ink-muted font-semibold mb-1">
Container:
</span>
<div className="font-mono text-[0.81rem] text-ink-soft bg-rule-soft rounded-md px-3 py-2 flex items-center gap-2 flex-wrap" dir="ltr">
<span>{spec.container_value ?? "— לא מוגדר —"}</span>
{/* ADM-5 (INV-IA5/INV-IA6): Coolify ≠ Container ⇒ container is stale
until a redeploy — say so in plain Hebrew right here. */}
{coolifyAvailable && spec.drift && (
<span
className="inline-flex items-center rounded-full bg-warn-bg text-warn border border-warn text-[0.7rem] font-semibold px-2 py-0.5"
dir="rtl"
title="הערך נשמר ב-Coolify אך הקונטיינר עדיין מריץ את הקודם — נדרש redeploy"
>
ממתין ל-redeploy
</span>
)}
</div>
</div>
<div className="flex md:block items-end">
{spec.is_editable ? (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || update.isPending}
variant={dirty ? "default" : "outline"}
className={dirty ? "bg-gold text-white hover:bg-gold-deep border-transparent" : ""}
>
<Save className="w-3.5 h-3.5" data-icon="inline-start" />
{update.isPending ? "שומר..." : "שמור"}
</Button>
) : (
<a
href={coolifyEnvUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-rule text-navy px-4 py-1.5 text-[0.81rem] font-semibold hover:bg-gold-wash"
>
ערוך ב-Coolify
<ExternalLink className="w-3 h-3" />
</a>
)}
{spec.is_editable && (
<Button
size="sm"
onClick={handleSave}
disabled={!dirty || update.isPending}
>
<Save className="w-3.5 h-3.5" data-icon="inline-start" />
{update.isPending ? "שומר..." : "שמור"}
</Button>
)}
</div>
</div>
</div>
);

View File

@@ -72,37 +72,39 @@ export function EnvironmentTab() {
const duplicatesCount = data.vars.filter((v) => v.has_duplicates).length;
return (
<div className="space-y-4">
<Card className="bg-surface border-rule">
<div className="space-y-5">
{/* summary band — Coolify app id + drift/dup counts + redeploy CTA */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-4 flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-wrap text-sm">
<Badge variant="outline">
Coolify app: <code dir="ltr" className="ms-1">{data.coolify_app_uuid.slice(0, 8)}</code>
</Badge>
{driftCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
{driftCount} drift
</Badge>
<span className="inline-flex items-center rounded-full bg-warn-bg text-warn text-[0.72rem] font-semibold px-2.5 py-0.5">
{driftCount} ב-Drift
</span>
)}
{duplicatesCount > 0 && (
<Badge variant="outline" className="text-warn border-warn/40">
<span className="inline-flex items-center rounded-full bg-warn-bg text-warn text-[0.72rem] font-semibold px-2.5 py-0.5">
{duplicatesCount} duplicates
</Badge>
</span>
)}
{data.errors.length > 0 && (
<Badge variant="outline" className="text-danger border-danger/40">
<span className="inline-flex items-center rounded-full bg-danger-bg text-danger text-[0.72rem] font-semibold px-2.5 py-0.5">
{data.errors.join(", ")}
</Badge>
</span>
)}
</div>
<Button
onClick={handleRedeploy}
disabled={redeploy.isPending}
variant={pendingRedeploy ? "default" : "outline"}
size="sm"
className={pendingRedeploy ? "bg-gold text-white hover:bg-gold-deep border-transparent" : ""}
variant={pendingRedeploy ? "default" : "outline"}
>
<RefreshCw className={redeploy.isPending ? "w-3.5 h-3.5 animate-spin" : "w-3.5 h-3.5"} data-icon="inline-start" />
{redeploy.isPending ? "Redeploying..." : "Redeploy now"}
{redeploy.isPending ? "פורס מחדש..." : "פרוס מחדש"}
</Button>
</CardContent>
</Card>
@@ -110,16 +112,31 @@ export function EnvironmentTab() {
{CATEGORY_ORDER.map((cat) => {
const vars = grouped.get(cat);
if (!vars || vars.length === 0) return null;
const catDrift = vars.filter((v) => v.drift).length;
return (
<Card key={cat} className="bg-surface border-rule">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-4 flex items-center gap-2">
<div
key={cat}
className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden"
>
{/* panel header (.ph) — parchment band: title + count + drift chip */}
<div className="flex items-center justify-between gap-3 px-5 py-4 border-b border-rule bg-parchment">
<div>
<h2 className="text-navy text-base font-semibold mb-0 flex items-center gap-2">
{CATEGORY_LABELS[cat]}
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{vars.length}
</Badge>
<span className="text-[0.72rem] text-ink-muted font-medium tabular-nums">
({vars.length})
</span>
</h2>
<div className="space-y-3">
<p className="text-[0.78rem] text-ink-muted mt-0.5">
מקור-האמת הוא Coolify. שינוי נכנס לתוקף רק לאחר redeploy של הקונטיינר.
</p>
</div>
{coolifyAvailable && catDrift > 0 && (
<span className="inline-flex items-center rounded-full bg-warn-bg text-warn text-[0.72rem] font-semibold px-2.5 py-0.5 shrink-0">
{catDrift} ב-Drift
</span>
)}
</div>
{vars.map((v) => (
<EnvVarRow
key={v.key}
@@ -130,8 +147,6 @@ export function EnvironmentTab() {
/>
))}
</div>
</CardContent>
</Card>
);
})}
</div>

View File

@@ -11,11 +11,34 @@ import { RegistrationsTab } from "./_components/registrations-tab";
import { BlocksTab } from "./_components/blocks-tab";
import { AgentsTab } from "./_components/agents-tab";
/*
* Settings — IA-redesign composition (mockup 15): a header band with a
* top-end "redeploy" deep-link, then a two-column layout — a vertical
* sidenav (section list) + the active section panel. Implemented on top of
* shadcn Tabs so all six sections and their logic-heavy contents
* (Paperclip, agents, env vars w/ drift+redeploy, tools, blocks,
* registrations) are preserved verbatim; only the chrome is restyled from
* a horizontal tab strip to the approved sidenav layout.
*/
const COOLIFY_APP_UUID = "gyjo0mtw2c42ej3xxvbz8zio";
const COOLIFY_REDEPLOY_URL = `https://coolify.nautilus.marcusgroup.org/project/applications/${COOLIFY_APP_UUID}`;
const SECTIONS = [
{ value: "paperclip", label: "Paperclip / סוכנים", icon: Building2 },
{ value: "agents", label: "סוכנים", icon: Bot },
{ value: "environment", label: "משתני סביבה", icon: Server },
{ value: "tools", label: "כלי MCP", icon: Wrench },
{ value: "blocks", label: "בלוקים", icon: Layers },
{ value: "registrations", label: "רישומים", icon: Plug },
] as const;
export default function SettingsPage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<header className="flex items-end justify-between gap-4 flex-wrap">
<div>
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">
בית
@@ -25,46 +48,55 @@ export default function SettingsPage() {
</nav>
<h1 className="text-navy mb-0">הגדרות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
תצורת המערכת, MCP server, ו-Paperclip integration.
תצורת הפלטפורמה סוכנים, סביבה, כלים ובלוקים.
</p>
</div>
<a
href={COOLIFY_REDEPLOY_URL}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-lg bg-gold text-white hover:bg-gold-deep px-5 py-2.5 text-sm font-semibold transition-colors"
>
פרוס מחדש (redeploy)
</a>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<Tabs dir="rtl" defaultValue="paperclip" className="space-y-4">
<TabsList>
<TabsTrigger value="paperclip">
<Building2 className="w-4 h-4" data-icon="inline-start" />
Paperclip
</TabsTrigger>
<TabsTrigger value="agents">
<Bot className="w-4 h-4" data-icon="inline-start" />
סוכנים
</TabsTrigger>
<TabsTrigger value="environment">
<Server className="w-4 h-4" data-icon="inline-start" />
סביבה
</TabsTrigger>
<TabsTrigger value="tools">
<Wrench className="w-4 h-4" data-icon="inline-start" />
כלים
</TabsTrigger>
<TabsTrigger value="blocks">
<Layers className="w-4 h-4" data-icon="inline-start" />
בלוקים
</TabsTrigger>
<TabsTrigger value="registrations">
<Plug className="w-4 h-4" data-icon="inline-start" />
רישומים
<Tabs
dir="rtl"
defaultValue="environment"
orientation="vertical"
className="flex-row gap-6 items-start"
>
{/* vertical sidenav (mockup .sidenav) */}
<TabsList
variant="line"
className="w-[230px] shrink-0 items-stretch gap-0 rounded-lg border border-rule bg-surface shadow-sm overflow-hidden p-0"
>
{SECTIONS.map((s) => {
const Icon = s.icon;
return (
<TabsTrigger
key={s.value}
value={s.value}
className="relative justify-start gap-2 rounded-none border-b border-rule-soft last:border-b-0 px-4 py-3 text-sm font-medium text-ink-soft data-active:bg-gold-wash data-active:text-gold-deep data-active:font-semibold data-active:after:opacity-100 data-active:after:bg-gold hover:bg-gold-wash/40"
>
<Icon className="w-4 h-4 shrink-0" />
{s.label}
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
<TabsContent value="agents"><AgentsTab /></TabsContent>
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
<TabsContent value="tools"><ToolsTab /></TabsContent>
<TabsContent value="blocks"><BlocksTab /></TabsContent>
<TabsContent value="registrations"><RegistrationsTab /></TabsContent>
<div className="flex-1 min-w-0">
<TabsContent value="paperclip" className="mt-0"><PaperclipTab /></TabsContent>
<TabsContent value="agents" className="mt-0"><AgentsTab /></TabsContent>
<TabsContent value="environment" className="mt-0"><EnvironmentTab /></TabsContent>
<TabsContent value="tools" className="mt-0"><ToolsTab /></TabsContent>
<TabsContent value="blocks" className="mt-0"><BlocksTab /></TabsContent>
<TabsContent value="registrations" className="mt-0"><RegistrationsTab /></TabsContent>
</div>
</Tabs>
</section>
</AppShell>

View File

@@ -1,95 +1,64 @@
"use client";
import Link from "next/link";
import { Plug, HardDrive, Database, FileText } from "lucide-react";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSkills, type Skill } from "@/lib/api/skills";
function formatSize(bytes: number | null) {
if (bytes == null) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
function formatChars(skill: Skill): string {
// Mockup column = "גודל (תווים)" — the DB markdown char count, grouped.
const n = skill.db_markdown_chars ?? 0;
return n.toLocaleString("en-US");
}
function StatusDot({ tone }: { tone: string }) {
return <span className={`h-1.5 w-1.5 rounded-full ${tone}`} aria-hidden />;
function formatUpdated(iso: string | null): string {
if (!iso) return "—";
try {
// ISO-style date to match the mockup (2026-06-09), tabular.
return new Date(iso).toISOString().slice(0, 10);
} catch {
return "—";
}
}
function statusBadge(s: Skill) {
if (s.not_in_db) {
return (
<Badge variant="outline" className="gap-1.5 bg-warn-bg text-warn border-warn/40">
<StatusDot tone="bg-warn" />לא סונכרן
</Badge>
);
/**
* Sync chip — colored dot + label, faithful to mockup 14:
* - מסונכרן (success) when present in DB + on disk
* - DB בלבד (info) when in DB but no disk copy
* - לא מסונכרן (warn) when missing from the DB
*/
function SyncChip({ skill }: { skill: Skill }) {
let tone: { wrap: string; dot: string };
let label: string;
if (skill.not_in_db) {
tone = { wrap: "bg-warn-bg text-warn", dot: "bg-warn" };
label = "לא מסונכרן";
} else if (skill.db_markdown_chars > 0 && skill.disk_exists) {
tone = { wrap: "bg-success-bg text-success", dot: "bg-success" };
label = "מסונכרן";
} else if (skill.db_markdown_chars > 0) {
tone = { wrap: "bg-info-bg text-info", dot: "bg-info" };
label = "DB בלבד";
} else {
tone = { wrap: "bg-rule-soft text-ink-muted", dot: "bg-ink-muted" };
label = "לא ידוע";
}
if (s.db_markdown_chars > 0 && s.disk_exists) {
return (
<Badge variant="outline" className="gap-1.5 bg-success-bg text-success border-success/40">
<StatusDot tone="bg-success" />מסונכרן
</Badge>
);
}
if (s.db_markdown_chars > 0) {
return (
<Badge variant="outline" className="gap-1.5 bg-info-bg text-info border-info/40">
<StatusDot tone="bg-info" />DB בלבד
</Badge>
);
}
return <Badge variant="outline">לא ידוע</Badge>;
}
function SkillCard({ skill }: { skill: Skill }) {
const fileCount = skill.file_inventory?.length ?? 0;
return (
<Card className="bg-surface border-rule shadow-sm hover:shadow-md transition-shadow">
<CardContent className="px-5 py-4">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2 min-w-0">
<Plug className="w-4 h-4 text-gold-deep shrink-0" />
<div className="min-w-0">
<h3 className="text-navy font-semibold text-base mb-0 truncate">
{skill.name || skill.slug}
</h3>
<code className="text-[0.72rem] text-ink-muted tabular-nums">
{skill.slug}
</code>
</div>
</div>
{statusBadge(skill)}
</div>
<dl className="grid grid-cols-3 gap-2 text-[0.72rem] text-ink-muted mt-3">
<div className="flex items-center gap-1">
<FileText className="w-3 h-3" />
<span className="tabular-nums">{fileCount}</span>
<span>קבצים</span>
</div>
<div className="flex items-center gap-1">
<Database className="w-3 h-3" />
<span className="tabular-nums">
{(skill.db_markdown_chars / 1000).toFixed(1)}K
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-[3px] text-[0.75rem] font-semibold ${tone.wrap}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${tone.dot}`} aria-hidden />
{label}
</span>
<span>תווים</span>
</div>
<div className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
<span className="tabular-nums">
{formatSize(skill.disk_skill_md_bytes)}
</span>
</div>
</dl>
{skill.updated_at && (
<p className="text-[0.7rem] text-ink-light mt-2">
עודכן: {new Date(skill.updated_at).toLocaleDateString("he-IL")}
</p>
)}
</CardContent>
</Card>
);
}
@@ -105,9 +74,9 @@ export default function SkillsPage() {
<span aria-hidden> · </span>
<span className="text-navy">מיומנויות</span>
</nav>
<h1 className="text-navy mb-0">מיומנויות Paperclip</h1>
<h1 className="text-navy mb-0">מיומנויות</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
רשימת ה-skills המותקנים במערכת Paperclip ומצב הסנכרון שלהם בין ה-DB
סקילים מותקנים בפלטפורמה שם, גודל, מועד עדכון ומצב הסנכרון בין ה-DB
לדיסק.
</p>
</header>
@@ -115,28 +84,69 @@ export default function SkillsPage() {
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-6 text-center text-danger">
<Card className="bg-danger-bg border-danger/40 px-6 py-6 text-center text-danger">
{error.message}
</CardContent>
</Card>
) : isPending ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full rounded-lg" />
))}
</div>
<Skeleton className="h-80 w-full rounded-lg" />
) : data?.length === 0 ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-12 text-center text-ink-muted">
<Card className="bg-surface border-rule px-6 py-12 text-center text-ink-muted">
<div className="text-gold text-3xl mb-2" aria-hidden></div>
אין skills מותקנים
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data?.map((s) => <SkillCard key={s.slug} skill={s} />)}
</div>
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0">
<Table>
<TableHeader>
<TableRow className="bg-parchment hover:bg-parchment border-rule">
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
סלאג
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
שם תצוגה
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
גודל (תווים)
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
עודכן
</TableHead>
<TableHead className="text-start text-[0.75rem] font-semibold text-ink-muted px-5 py-3.5">
סנכרון
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.map((s) => (
<TableRow
key={s.slug}
className="border-rule-soft hover:bg-gold-wash"
>
<TableCell className="px-5 py-4">
<code
className="font-mono text-[0.81rem] font-semibold text-navy"
dir="ltr"
>
{s.slug}
</code>
</TableCell>
<TableCell className="px-5 py-4 font-semibold text-navy text-[0.9rem]">
{s.name || s.slug}
</TableCell>
<TableCell className="px-5 py-4 text-ink-soft tabular-nums">
{formatChars(s)}
</TableCell>
<TableCell className="px-5 py-4 text-ink-muted text-[0.81rem] tabular-nums">
{formatUpdated(s.updated_at)}
</TableCell>
<TableCell className="px-5 py-4">
<SyncChip skill={s} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</section>
</AppShell>

View File

@@ -22,16 +22,19 @@ export default function TrainingPage() {
<AppShell>
<section className="space-y-6">
<header className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="space-y-1">
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">אימון סגנון</span>
</nav>
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
לוח בקרה של קורפוס האימון סטטיסטיקות, אנטומיית החלטה ממוצעת,
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
רכישת-הסגנון של דפנה · פורטרט-הקול
</div>
<h1 className="text-navy mb-0">אימון סגנון</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl leading-relaxed">
פורטרט הקול שנלמד מהקורפוס, מוזן read-only לכותב סטטיסטיקות,
אנטומיית החלטה ממוצעת, ביטויי חתימה, וכלי השוואה בין החלטות.
</p>
</div>
<Button

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import { SyncIndicator } from "@/components/cases/sync-indicator";
@@ -25,10 +25,36 @@ function formatDate(iso?: string | null) {
}
}
export function CaseHeader({ data }: { data?: CaseDetail }) {
function partiesLine(data?: CaseDetail): string | null {
const appellant = data?.appellants?.filter(Boolean) ?? [];
const respondent = data?.respondents?.filter(Boolean) ?? [];
const parts: string[] = [];
if (appellant.length) parts.push(`עוררת: ${appellant.join(", ")}`);
if (respondent.length) parts.push(`משיבה: ${respondent.join(", ")}`);
return parts.length ? parts.join(" · ") : null;
}
/**
* Case header — parchment band (IA-redesign mockup 17): full-bleed band with
* the case title + status/type chips inline, a parties line, the case actions
* (edit / archive / repo / sync), and a metadata strip. The `tabs` slot renders
* the tab strip inside the band, anchored to its bottom edge.
*/
export function CaseHeader({
data,
actions,
tabs,
}: {
data?: CaseDetail;
actions?: ReactNode;
tabs?: ReactNode;
}) {
const parties = partiesLine(data);
const isBlam =
data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype);
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<div className="-mx-10 -mt-10 mb-2 bg-parchment border-b border-rule px-10 pt-6">
<nav className="text-[0.78rem] text-ink-muted mb-3 flex items-center gap-2">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
@@ -38,16 +64,17 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
</nav>
<div className="flex items-start justify-between gap-6 flex-wrap">
<div className="space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
<div className="min-w-0">
{/* title row — H1 + status/type/blam chips inline (mockup .band h1) */}
<h1 className="text-navy text-[1.7rem] font-bold leading-tight flex items-center gap-3 flex-wrap mb-0">
<span className="tabular-nums">
{data?.proceeding_type ?? "ערר"} {data?.case_number ?? "—"}
</span>
{data?.status && <StatusBadge status={data.status} />}
{data?.archived_at && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-ink-muted/10 text-ink-muted border-ink-muted/30"
className="rounded-full px-3 py-0.5 text-[0.75rem] font-semibold bg-ink-muted/10 text-ink-muted border-ink-muted/30"
>
בארכיון
</Badge>
@@ -55,7 +82,7 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
{data?.practice_area && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-gold-wash text-gold-deep border-gold/40"
className="rounded-full px-3 py-0.5 text-[0.75rem] font-semibold bg-gold-wash text-gold-deep border-rule"
>
{PRACTICE_AREA_LABELS[data.practice_area]}
{data.appeal_subtype && data.appeal_subtype !== "unknown" && (
@@ -63,15 +90,34 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
)}
</Badge>
)}
{(data?.proceeding_type === 'בל"מ' || isBlamSubtype(data?.appeal_subtype)) && (
{isBlam && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-bold bg-warn/10 text-warn-deep border-warn/40"
className="rounded-full px-3 py-0.5 text-[0.75rem] font-bold bg-warn/10 text-warn-deep border-warn/40"
title="בקשה להארכת מועד להגשת ערר"
>
בל&quot;מ
</Badge>
)}
</h1>
{/* case title / subject under the heading */}
{data?.title && (
<p className="text-navy/90 text-base font-semibold mt-2 max-w-3xl leading-snug">
{data.title}
</p>
)}
{/* parties line (mockup .parties) */}
{parties ? (
<p className="text-ink-soft text-sm mt-1.5">{parties}</p>
) : data?.subject ? (
<p className="text-ink-soft text-sm mt-1.5 max-w-3xl leading-relaxed">
{data.subject}
</p>
) : null}
{/* case actions — kept verbatim, moved into the band */}
<div className="flex items-center gap-2 flex-wrap mt-3">
{data?.case_number && (
<CaseArchiveAction
caseNumber={data.case_number}
@@ -79,18 +125,12 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
/>
)}
<CreateRepoButton data={data} />
{actions}
</div>
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
{data?.title ?? "טוען…"}
</h1>
{data?.subject && (
<p className="text-ink-muted text-sm max-w-2xl leading-relaxed">
{data.subject}
</p>
)}
</div>
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
{/* metadata strip — hearing date / updated / sync */}
<dl className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm shrink-0">
<dt className="text-ink-muted text-[0.72rem] uppercase tracking-wider">
תאריך דיון
</dt>
@@ -105,7 +145,9 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
<dd><SyncIndicator caseNumber={data?.case_number} /></dd>
</dl>
</div>
</CardContent>
</Card>
{/* tab strip anchored to band bottom (mockup .tabs) */}
{tabs ? <div className="mt-5">{tabs}</div> : null}
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
@@ -295,6 +295,25 @@ function DocumentRow({
/* ── Main panel ────────────────────────────────────────────────── */
// IA-redesign mockup 17 — card with a parchment header band wrapping the
// (unchanged) document list. Module-level so it isn't re-created during render
// (React Compiler: "Cannot create components during render").
function DocumentsShell({ count, children }: { count: number; children: ReactNode }) {
return (
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
מסמכי התיק
{count > 0 && (
<span className="ms-2 text-[0.72rem] text-ink-muted font-medium tabular-nums">
({count})
</span>
)}
</div>
<div className="px-5 py-4">{children}</div>
</div>
);
}
export function DocumentsPanel({
data,
}: {
@@ -305,10 +324,12 @@ export function DocumentsPanel({
if (docs.length === 0) {
return (
<DocumentsShell count={docs.length}>
<div className="text-center py-12 text-ink-muted">
<div className="text-gold text-2xl mb-2" aria-hidden="true"></div>
<p className="text-sm">אין מסמכים בתיק זה</p>
</div>
</DocumentsShell>
);
}
@@ -328,6 +349,7 @@ export function DocumentsPanel({
const pct = docs.length > 0 ? Math.round((done / docs.length) * 100) : 0;
return (
<DocumentsShell count={docs.length}>
<div className="space-y-3">
{hasIncomplete && (
<div className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2" dir="rtl">
@@ -372,5 +394,6 @@ export function DocumentsPanel({
</ul>
</div>
</div>
</DocumentsShell>
);
}

View File

@@ -38,11 +38,7 @@ export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
const currentIdx = phaseIndexOf(status);
return (
<ol className="relative space-y-4">
<div
className="absolute top-2 bottom-2 right-[11px] w-px bg-rule"
aria-hidden
/>
<ol className="relative">
{PHASES.map((phase, i) => {
const state =
currentIdx === -1 ? "pending"
@@ -51,9 +47,9 @@ export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
: "pending";
const dotTone =
state === "done" ? "bg-success border-success"
: state === "current" ? "bg-gold border-gold shadow-[0_0_0_4px_color-mix(in_oklab,var(--color-gold)_20%,transparent)]"
: "bg-surface border-rule";
state === "done" ? "bg-success [box-shadow:0_0_0_1px_var(--color-success)]"
: state === "current" ? "bg-gold [box-shadow:0_0_0_1px_var(--color-gold)]"
: "bg-rule";
const labelTone =
state === "done" ? "text-ink-soft"
@@ -66,34 +62,55 @@ export function WorkflowTimeline({ status }: { status?: CaseStatus }) {
: "text-ink-muted/50";
const PhaseIcon = phase.icon;
const StatusIcon = status ? STATUS_ICONS[status] : null;
const isLast = i === PHASES.length - 1;
return (
<li key={phase.key} className="relative flex items-start gap-3 ps-7">
<li
key={phase.key}
className="relative flex items-center gap-3 py-2"
>
{/* connector line below the dot (mockup .tl .line) */}
{!isLast && (
<span
className={`absolute right-[5px] top-1 inline-block w-3 h-3 rounded-full border-2 ${dotTone}`}
className="absolute top-[26px] w-px h-[20px] bg-rule"
style={{ insetInlineStart: "5px" }}
aria-hidden
/>
<div className="flex flex-col gap-0.5">
<span className={`text-sm flex items-center gap-1.5 ${labelTone}`}>
)}
<span
className={`inline-block w-[11px] h-[11px] rounded-full border-2 border-surface shrink-0 ${dotTone}`}
aria-hidden
/>
<div className="flex items-center gap-2 grow min-w-0">
<span className={`text-[0.84rem] flex items-center gap-1.5 ${labelTone}`}>
<PhaseIcon className={`w-3.5 h-3.5 shrink-0 ${iconTone}`} />
{phase.label}
</span>
{state === "current" && status && (
<span className="text-[0.72rem] text-gold-deep flex items-center gap-1">
{StatusIcon && <StatusIcon className="w-3 h-3 shrink-0" />}
{STATUS_LABELS[status]}
<span className="ms-auto text-[0.72rem] text-ink-muted tabular-nums shrink-0">
{state === "current" ? "כעת" : state === "done" ? "✓" : "—"}
</span>
)}
{state === "current" && status && STATUS_DESCRIPTIONS[status] && (
<span className="text-[0.65rem] text-ink-muted leading-snug mt-0.5">
{STATUS_DESCRIPTIONS[status]}
</span>
)}
</div>
</li>
);
})}
{/* current micro-status detail under the active phase */}
{currentIdx !== -1 && status && (
<li className="ps-[26px] pt-1">
<span className="text-[0.72rem] text-gold-deep flex items-center gap-1">
{STATUS_ICONS[status] &&
(() => {
const Icon = STATUS_ICONS[status];
return <Icon className="w-3 h-3 shrink-0" />;
})()}
{STATUS_LABELS[status]}
</span>
{STATUS_DESCRIPTIONS[status] && (
<span className="block text-[0.65rem] text-ink-muted leading-snug mt-0.5">
{STATUS_DESCRIPTIONS[status]}
</span>
)}
</li>
)}
</ol>
);
}

View File

@@ -33,26 +33,24 @@ export function DigestCard({
actions?: ReactNode;
}) {
const linked = Boolean(digest.linked_case_law_id);
const pending = digest.extraction_status !== "completed";
return (
<div className="rounded-lg border border-rule bg-surface p-4 space-y-2">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
{digest.concept_tag && (
<Badge className="bg-gold text-navy border-0">{digest.concept_tag}</Badge>
)}
<div className="flex flex-col rounded-lg border border-rule bg-surface shadow-sm p-4">
{/* top row — yomon-number + date at start, concept tag pushed to end */}
<div className="flex items-center gap-2.5 mb-2.5 flex-wrap">
{digest.yomon_number && (
<span className="font-mono" dir="ltr">
<span className="text-navy font-bold text-[0.95rem]">
יומון {digest.yomon_number}
</span>
)}
{digest.digest_date && (
<span className="text-ink-muted text-[0.78rem]">· {formatDate(digest.digest_date)}</span>
)}
{digest.publication && digest.publication !== "כל יום" && (
<Badge variant="outline" className="bg-rule-soft text-ink-muted text-[0.65rem]">
{digest.publication}
</Badge>
)}
{digest.digest_date && <span>· {formatDate(digest.digest_date)}</span>}
{digest.practice_area && (
<span>· {practiceAreaLabel(digest.practice_area)}</span>
)}
{digest.digest_kind === "announcement" && (
<Badge variant="outline" className="bg-sky-50 text-sky-700 border-sky-300 text-[0.65rem]">
עדכון
@@ -63,47 +61,45 @@ export function DigestCard({
מאמר
</Badge>
)}
{digest.extraction_status !== "completed" && (
<Badge variant="outline" className="bg-rule-soft text-ink-muted text-[0.65rem]">
{digest.extraction_status === "pending" ? "ממתין לעיבוד" : digest.extraction_status}
</Badge>
{digest.concept_tag && (
<span className="ms-auto rounded-full bg-info-bg text-info text-[0.72rem] font-semibold px-2.5 py-0.5">
{digest.concept_tag}
</span>
)}
{typeof score === "number" && (
<span className="ms-auto tabular-nums">דירוג {score.toFixed(2)}</span>
<span className="ms-1 tabular-nums text-ink-muted text-[0.78rem]">
דירוג {score.toFixed(2)}
</span>
)}
</div>
{/* holding — the digest headline */}
{digest.headline_holding && (
<p className="text-navy font-medium text-[0.95rem]" dir="rtl">
<p className="text-ink font-medium text-[0.92rem] leading-6 mb-2.5" dir="rtl">
{digest.headline_holding}
</p>
)}
{/* source ruling — "מקור:" + citation, start-bordered (mockup `.ruling`) */}
<div className="border-s-2 border-rule ps-3 text-[0.8rem] text-ink-muted leading-6" dir="rtl">
<b className="text-ink-soft font-semibold">מקור:</b>{" "}
{digest.underlying_citation || "—"}
</div>
{digest.summary && (
<p className="text-ink-soft text-sm leading-relaxed" dir="rtl">
<p className="text-ink-soft text-[0.82rem] leading-relaxed mt-2" dir="rtl">
{digest.summary}
</p>
)}
<div className="flex items-start gap-2 flex-wrap pt-1 border-t border-rule-soft mt-1">
<span className="text-[0.72rem] text-ink-muted mt-1">פסק מקורי:</span>
<span className="text-[0.82rem] text-ink font-mono flex-1 min-w-[200px]" dir="rtl">
{digest.underlying_citation || "—"}
{digest.practice_area && (
<span className="text-ink-muted text-[0.72rem] mt-2">
{practiceAreaLabel(digest.practice_area)}
</span>
{linked ? (
<Link href={`/precedents/${digest.linked_case_law_id}`}>
<Badge className="bg-emerald-100 text-emerald-800 border-emerald-300 hover:bg-emerald-200">
מקושר לפסק
</Badge>
</Link>
) : (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300">
הפסק טרם בקורפוס
</Badge>
)}
</div>
{digest.subject_tags?.length > 0 && (
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1 mt-2">
{digest.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem] bg-surface">
{t}
@@ -112,7 +108,31 @@ export function DigestCard({
</div>
)}
{actions && <div className="flex items-center gap-2 pt-1">{actions}</div>}
{/* foot — link-status chip + passive "ממתין לעיבוד" label (mockup `.foot`) */}
<div className="mt-3 pt-3 border-t border-rule-soft flex items-center gap-2 flex-wrap">
{linked ? (
<Link href={`/precedents/${digest.linked_case_law_id}`}>
<span className="rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-3 py-0.5 hover:opacity-90">
מקושר לפסיקה
</span>
</Link>
) : (
<span className="rounded-full bg-rule-soft text-ink-muted text-[0.72rem] font-semibold px-3 py-0.5">
לא מקושר
</span>
)}
{pending && (
/* passive status label — a dot + text, deliberately NOT a button */
<span className="ms-auto inline-flex items-center gap-1.5 text-[0.72rem] font-medium text-ink-muted">
<span className="h-[7px] w-[7px] rounded-full bg-warn/60" aria-hidden />
{digest.extraction_status === "pending" ? "ממתין לעיבוד" : digest.extraction_status}
</span>
)}
</div>
{actions && (
<div className="flex items-center gap-2 pt-3">{actions}</div>
)}
</div>
);
}

View File

@@ -14,7 +14,6 @@ import {
import type { PracticeArea } from "@/lib/api/precedent-library";
import { PRACTICE_AREAS } from "@/components/precedents/practice-area";
import { DigestCard } from "./digest-card";
import { DigestUploadDialog } from "./digest-upload-dialog";
type LinkedFilter = "all" | "linked" | "unlinked";
@@ -96,9 +95,6 @@ export function DigestListPanel() {
</SelectContent>
</Select>
</div>
<div className="ms-auto">
<DigestUploadDialog />
</div>
</div>
{error ? (
@@ -117,6 +113,8 @@ export function DigestListPanel() {
) : (
<div className="space-y-3">
<p className="text-[0.78rem] text-ink-muted">{data.count} יומונים</p>
{/* two-column card grid (mockup 10 `.grid`) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{data.items.map((d) => (
<DigestCard
key={d.id}
@@ -149,6 +147,7 @@ export function DigestListPanel() {
/>
))}
</div>
</div>
)}
</div>
);

View File

@@ -24,7 +24,7 @@ import { PRACTICE_AREAS } from "@/components/precedents/practice-area";
* via the MCP drainer ``digest_process_pending`` — so the toast tells the
* user the digest is queued, not yet searchable.
*/
export function DigestUploadDialog() {
export function DigestUploadDialog({ trigger }: { trigger?: React.ReactNode } = {}) {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [yomonNumber, setYomonNumber] = useState("");
@@ -71,10 +71,12 @@ export function DigestUploadDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-navy text-parchment hover:bg-navy-soft">
{trigger ?? (
<Button className="bg-gold text-white hover:bg-gold-deep border-transparent">
<Upload className="w-4 h-4 me-1" />
העלאת יומון
</Button>
)}
</DialogTrigger>
<DialogContent dir="rtl">
<DialogHeader>
@@ -140,7 +142,7 @@ export function DigestUploadDialog() {
<Button
type="submit"
disabled={upload.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
className="bg-gold text-white hover:bg-gold-deep border-transparent"
>
{upload.isPending ? "מעלה…" : "העלה"}
</Button>

View File

@@ -88,8 +88,11 @@ export function GraphFilterPanel({
facets?: GraphFacets;
}) {
return (
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
<Card className="bg-surface border-rule shadow-sm w-[300px] shrink-0 max-h-full overflow-y-auto">
<CardContent className="space-y-5 p-4">
<div className="text-[0.8rem] font-semibold text-navy border-b border-rule-soft pb-2">
פילטרים וסינון
</div>
<div className="space-y-1.5">
<Label htmlFor="graph-search" className="text-xs text-ink-muted">
חיפוש פסיקה
@@ -250,33 +253,51 @@ export function GraphFilterPanel({
<Separator />
<div className="space-y-3">
<Label className="text-xs text-ink-muted">סוגי נקודות</Label>
<div className="text-[0.8rem] font-semibold text-navy border-b border-rule-soft pb-2">
שכבות הגרף
</div>
<ToggleRow
label="נקודות-נושא"
swatch="#a97d3a"
checked={controls.showTopics}
onCheckedChange={(v) => onChange({ showTopics: v })}
/>
<ToggleRow
label="נקודות-תחום"
swatch="#4a7c59"
checked={controls.showPracticeAreas}
onCheckedChange={(v) => onChange({ showPracticeAreas: v })}
/>
<ToggleRow
label="חוסרי מחקר (פסיקה חסרה)"
swatch="#a54242"
checked={controls.showGaps}
onCheckedChange={(v) => onChange({ showGaps: v })}
/>
<ToggleRow
label="יומונים (כל יום)"
swatch="#b8894a"
checked={controls.showDigests}
onCheckedChange={(v) => onChange({ showDigests: v })}
/>
<ToggleRow
label="הלכות"
</div>
{/* Stage-2 gate — halacha layer is dense, gated by default (mockup 11) */}
<div className="rounded-lg border border-gold bg-gold-wash p-3.5 space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-navy m-0">שלב ב׳ שכבת הלכות</h4>
<Switch
className="ms-auto"
checked={controls.showHalachot}
onCheckedChange={(v) => onChange({ showHalachot: v })}
aria-label="הצגת שכבת ההלכות"
/>
</div>
<p className="text-[0.72rem] text-ink-muted leading-relaxed m-0">
הפעלת שכבת ההלכות (1,454 צמתים). מגודרת כברירת-מחדל בשל הצפיפות
הדלקה מציגה את הקשרים הלכהפסיקה.
</p>
</div>
</CardContent>
</Card>
);
@@ -350,18 +371,28 @@ function ToggleRow({
checked,
onCheckedChange,
disabled,
swatch,
}: {
label: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
swatch?: string;
}) {
return (
<div className="flex items-center justify-between">
<span className={`text-sm ${disabled ? "text-ink-muted/50" : "text-ink"}`}>
<div className={`flex items-center gap-2.5 ${disabled ? "opacity-55" : ""}`}>
{swatch ? (
<span
className="inline-block size-2.5 rounded-full shrink-0 ring-1 ring-black/10"
style={{ backgroundColor: swatch }}
aria-hidden
/>
) : null}
<span className={`text-sm ${disabled ? "text-ink-muted/50" : "text-ink-soft"}`}>
{label}
</span>
<Switch
className="ms-auto"
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}

View File

@@ -226,7 +226,7 @@ export function GraphView() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 text-xs text-ink-muted">
<span>
<span className="inline-flex items-center gap-2 rounded-full border border-rule bg-surface px-3 py-1 tabular-nums">
{data ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` : "—"}
</span>
<div className="flex items-center gap-3">
@@ -248,12 +248,12 @@ export function GraphView() {
</div>
</div>
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
<div className="flex gap-5 h-[calc(100vh-320px)] min-h-[560px] items-start">
<GraphFilterPanel controls={controls} onChange={onChange} facets={facets} />
<div
ref={canvasAreaRef}
className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden"
className="relative flex-1 h-full rounded-lg border border-rule bg-gradient-to-b from-[#f3ecda] to-[#efe6cf] shadow-sm overflow-hidden"
>
{error ? (
<div className="grid h-full place-items-center p-6 text-center">
@@ -311,6 +311,12 @@ export function GraphView() {
)}
<Legend colorBy={controls.colorBy} />
{data ? (
<div className="absolute bottom-3 start-3 rounded-full bg-surface/70 backdrop-blur px-2.5 py-1 text-[0.72rem] text-ink-muted tabular-nums">
{data.nodes.length} צמתים מוצגים
</div>
) : null}
</div>
{selectedNode ? (
@@ -340,9 +346,16 @@ function RankingPanel({
.sort((a, b) => (b.betweenness ?? 0) - (a.betweenness ?? 0))
.slice(0, 12);
const communities = new Set(
nodes.map((n) => n.community).filter((c) => c != null),
).size;
return (
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
<CardContent className="p-4">
<Card className="bg-surface border-rule shadow-sm w-[300px] shrink-0 max-h-full overflow-y-auto">
<CardContent className="p-4 space-y-4">
<div className="text-[0.8rem] font-semibold text-navy border-b border-rule-soft pb-2">
אנליטיקה
</div>
<Tabs defaultValue="pagerank">
<TabsList className="w-full">
<TabsTrigger value="pagerank" className="flex-1">
@@ -359,6 +372,12 @@ function RankingPanel({
<RankList items={byBetweenness} metric="betweenness" onPick={onPick} />
</TabsContent>
</Tabs>
{communities > 0 ? (
<div className="flex items-center gap-2 border-t border-rule-soft pt-3 text-sm text-ink-soft">
אשכולות:
<b className="text-navy text-lg tabular-nums">{communities}</b>
</div>
) : null}
</CardContent>
</Card>
);
@@ -377,18 +396,19 @@ function RankList({
return <p className="text-ink-muted text-xs mt-3">אין נתונים.</p>;
}
return (
<ol className="mt-2 space-y-1">
<ol className="mt-2">
{items.map((n, i) => (
<li key={n.id}>
<li key={n.id} className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => onPick(n)}
className="flex w-full items-baseline justify-between gap-2 rounded px-2 py-1 text-start text-sm hover:bg-gold-wash"
className="flex w-full items-baseline gap-2 px-1 py-1.5 text-start text-sm hover:bg-gold-wash rounded"
>
<span className="truncate">
<span className="text-ink-muted text-xs">{i + 1}.</span> {n.label}
<span className="w-4 shrink-0 text-ink-muted text-xs tabular-nums">
{i + 1}
</span>
<span className="text-ink-muted text-xs tabular-nums shrink-0">
<span className="truncate text-ink-soft">{n.label}</span>
<span className="ms-auto text-gold-deep font-semibold text-xs tabular-nums shrink-0">
{((n[metric] ?? 0) * 100).toFixed(0)}
</span>
</button>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
@@ -115,84 +115,124 @@ export function ContentChecklistsPanel() {
);
}
const itemCount = current
? current.draft.split("\n").filter((l) => /^\s*-\s*\[/.test(l)).length
: 0;
return (
<div className="space-y-4">
{/* Tab selector */}
<div className="flex gap-2 flex-wrap">
{items.map((item) => (
<Button
<div className="space-y-[18px]">
{/* Type buttons — gold active (mockup 13) */}
<div className="flex gap-2.5 flex-wrap">
{items.map((item) => {
const isActive = active === item.key;
return (
<button
key={item.key}
size="sm"
variant={active === item.key ? "default" : "outline"}
onClick={() => { setActive(item.key); setPreview(false); }}
className="text-xs"
type="button"
onClick={() => {
setActive(item.key);
setPreview(false);
}}
className={
isActive
? "rounded-lg border border-gold bg-gold px-4 py-2 text-[0.84rem] font-semibold text-white"
: "rounded-lg border border-rule bg-surface px-4 py-2 text-[0.84rem] font-medium text-ink-soft hover:border-gold/50"
}
>
{item.label}
{item.isOverride && (
<Badge variant="secondary" className="text-[9px] mr-1.5 px-1">
<Badge
variant="secondary"
className={`text-[9px] ms-1.5 px-1 ${isActive ? "bg-white/20 text-white" : ""}`}
>
מותאם
</Badge>
)}
</Button>
))}
</button>
);
})}
</div>
{/* Editor / Preview */}
{current && (
<Card className="border-rule">
<CardContent className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-navy">{current.label}</h3>
{CHECKLIST_APPLIES[current.key] && (
<p className="text-[0.72rem] text-ink-muted mt-0.5">
חל על: {CHECKLIST_APPLIES[current.key]}
</p>
)}
{/* "חל על:" explainer band — gold-wash (mockup 13) */}
{current && CHECKLIST_APPLIES[current.key] && (
<div className="flex items-baseline gap-2 rounded-lg border border-rule bg-gold-wash px-4 py-2.5 text-[0.84rem] text-ink-soft">
<b className="text-gold-deep font-semibold whitespace-nowrap">חל על:</b>
<span>{CHECKLIST_APPLIES[current.key]}</span>
</div>
)}
{/* Editor — framed card with header chip + parchment editor + footer */}
{current && (
<Card className="border-rule shadow-sm overflow-hidden p-0 gap-0">
<div className="flex items-center gap-2.5 px-[18px] py-3.5 border-b border-rule-soft">
<h2 className="text-[0.95rem] font-semibold text-navy m-0">
צ׳קליסט תוכן {current.label}
</h2>
<span
className={`ms-auto rounded-full text-xs font-semibold px-2.5 py-0.5 ${
current.isOverride
? "bg-gold-wash text-gold-deep border border-rule"
: "bg-info-bg text-info"
}`}
>
{current.isOverride ? "מותאם" : "ידני"}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => setPreview(!preview)}
className="text-xs shrink-0"
className="text-xs shrink-0 h-7"
>
{preview ? <EyeOff className="w-3.5 h-3.5 ml-1" /> : <Eye className="w-3.5 h-3.5 ml-1" />}
{preview ? (
<EyeOff className="w-3.5 h-3.5 ms-1" />
) : (
<Eye className="w-3.5 h-3.5 ms-1" />
)}
{preview ? "עריכה" : "תצוגה מקדימה"}
</Button>
</div>
{preview ? (
<div className="border border-rule rounded-md p-4 bg-sand-soft/30 max-h-[500px] overflow-y-auto">
<div className="p-[18px] bg-parchment max-h-[500px] overflow-y-auto border-b border-rule-soft">
<Markdown content={current.draft} />
</div>
) : (
<Textarea
value={current.draft}
onChange={(e) => updateDraft(e.target.value)}
className="min-h-[400px] font-mono text-sm leading-relaxed"
className="min-h-[340px] rounded-none border-0 border-b border-rule-soft bg-parchment font-mono text-[0.84rem] leading-[1.95] text-ink-soft focus-visible:ring-0 resize-y"
dir="rtl"
/>
)}
<div className="flex items-center gap-2">
<Button size="sm" disabled={!current.dirty || update.isPending} onClick={handleSave}>
{update.isPending ? <Loader2 className="w-3 h-3 animate-spin ml-1" /> : <Save className="w-3 h-3 ml-1" />}
<div className="flex items-center gap-2.5 px-[18px] py-3.5">
<Button
disabled={!current.dirty || update.isPending}
onClick={handleSave}
className="bg-gold text-white hover:bg-gold-deep"
>
{update.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin ms-1" />
) : (
<Save className="w-3.5 h-3.5 ms-1" />
)}
שמור
</Button>
{current.isOverride && (
<Button size="sm" variant="outline" disabled={reset.isPending} onClick={handleReset}>
<RotateCcw className="w-3 h-3 ml-1" />
איפוס לברירת מחדל
<Button
variant="outline"
disabled={reset.isPending}
onClick={handleReset}
className="border-rule text-navy"
>
<RotateCcw className="w-3.5 h-3.5 ms-1" />
אפס לברירת-מחדל
</Button>
)}
<Badge
variant={current.isOverride ? "default" : "secondary"}
className="text-[10px] mr-auto"
>
{current.isOverride ? "מותאם" : "ברירת מחדל"}
</Badge>
<span className="ms-auto text-[0.78rem] text-ink-muted tabular-nums">
{itemCount} פריטים
</span>
</div>
</CardContent>
</Card>
)}
</div>

View File

@@ -15,6 +15,7 @@ import {
useDeleteMissingPrecedent,
CITED_BY_PARTY_LABELS,
STATUS_LABELS,
type CitedByParty,
type MissingPrecedent,
type MissingPrecedentStatus,
} from "@/lib/api/missing-precedents";
@@ -29,20 +30,39 @@ function formatDate(iso: string | null) {
}
}
/** Status chip — mockup 09 tones (open=warn, uploaded=info, closed=success,
* irrelevant=muted). Pill-shaped, whitespace-nowrap. */
function StatusBadge({ status }: { status: MissingPrecedentStatus }) {
const variants: Record<MissingPrecedentStatus, string> = {
open: "bg-gold-wash text-gold-deep border-gold/40",
uploaded: "bg-rule-soft text-ink-muted border-rule",
closed: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
irrelevant: "bg-rule-soft text-ink-muted border-rule line-through",
open: "bg-warn-bg text-warn border-transparent",
uploaded: "bg-info-bg text-info border-transparent",
closed: "bg-success-bg text-success border-transparent",
irrelevant: "bg-rule-soft text-ink-muted border-transparent",
};
return (
<Badge variant="outline" className={variants[status]}>
<Badge variant="outline" className={`rounded-full whitespace-nowrap ${variants[status]}`}>
{STATUS_LABELS[status]}
</Badge>
);
}
/** Citing-party chip — colored by side (mockup 09 source chips). */
function SourceChip({ party }: { party: CitedByParty | null }) {
if (!party) return <span className="text-ink-muted text-sm"></span>;
const variants: Record<CitedByParty, string> = {
appellant: "bg-info-bg text-info border-transparent",
respondent: "bg-gold-wash text-gold-deep border-rule",
committee: "bg-success-bg text-success border-transparent",
permit_applicant: "bg-info-bg text-info border-transparent",
unknown: "bg-rule-soft text-ink-muted border-transparent",
};
return (
<Badge variant="outline" className={`rounded-full whitespace-nowrap ${variants[party]}`}>
{CITED_BY_PARTY_LABELS[party]}
</Badge>
);
}
function TableSkeleton({ cols }: { cols: number }) {
return (
<>
@@ -100,14 +120,14 @@ export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props
<>
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">פסיקה</TableHead>
<TableHead className="text-navy text-right">נושא</TableHead>
<TableHead className="text-navy text-right">תיק</TableHead>
<TableHead className="text-navy text-right">צד מצטט</TableHead>
<TableHead className="text-navy text-right">סטטוס</TableHead>
<TableHead className="text-navy text-right">נוצר</TableHead>
<TableHeader className="bg-parchment">
<TableRow className="border-rule hover:bg-transparent">
<TableHead className="text-ink-muted text-right font-medium text-xs">פסיקה</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">נושא</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">תיק</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">צוטט ע״י</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">סטטוס</TableHead>
<TableHead className="text-ink-muted text-right font-medium text-xs">נוצר</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
@@ -128,7 +148,7 @@ export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props
onClick={() => setOpenId(mp.id)}
>
<TableCell className="max-w-[440px]">
<div className="text-sm text-navy font-medium truncate">
<div className="text-sm text-navy font-semibold truncate">
{mp.case_name || mp.citation.split(" ").slice(0, 6).join(" ")}
</div>
<div className="text-[0.72rem] text-ink-muted truncate" dir="rtl">
@@ -153,11 +173,9 @@ export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props
)}
</TableCell>
<TableCell className="text-sm text-ink">
{mp.cited_by_party
? CITED_BY_PARTY_LABELS[mp.cited_by_party]
: "—"}
<SourceChip party={mp.cited_by_party} />
{mp.cited_by_party_name ? (
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px]">
<div className="text-[0.7rem] text-ink-muted truncate max-w-[160px] mt-1">
{mp.cited_by_party_name}
</div>
) : null}
@@ -165,7 +183,7 @@ export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props
<TableCell>
<StatusBadge status={mp.status} />
{mp.linked_case_law_number ? (
<div className="text-[0.7rem] text-emerald-700 mt-1">
<div className="text-[0.7rem] text-success mt-1">
{mp.linked_case_law_name || mp.linked_case_law_number}
</div>
) : null}
@@ -174,7 +192,27 @@ export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props
{formatDate(mp.created_at)}
</TableCell>
<TableCell className="text-end">
<div className="flex items-center justify-end gap-1">
<div className="flex items-center justify-end gap-2">
{mp.status === "open" ? (
/* gold "העלה והשלם" CTA (mockup 09 `.btn`) */
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
setOpenId(mp.id);
}}
className="h-7 bg-gold text-white hover:bg-gold-deep border-transparent text-[0.78rem] font-semibold"
>
<Upload className="w-3.5 h-3.5 me-1" />
העלה והשלם
</Button>
) : (
/* passive "done" label for non-open rows */
<span className="inline-flex items-center gap-1 rounded-md bg-rule-soft text-ink-muted text-[0.78rem] font-medium px-2.5 py-1">
{mp.status === "closed" ? "קושר" : STATUS_LABELS[mp.status]}
</span>
)}
{mp.status !== "open" ? (
<Button
variant="ghost"
size="sm"
@@ -182,14 +220,11 @@ export function MissingPrecedentsTable({ status, caseNumber, legalTopic }: Props
e.stopPropagation();
setOpenId(mp.id);
}}
title={mp.status === "open" ? "העלאה" : "פרטים"}
title="פרטים"
>
{mp.status === "open" ? (
<Upload className="w-4 h-4" />
) : (
<Pencil className="w-4 h-4" />
)}
</Button>
) : null}
<Button
variant="ghost"
size="sm"

View File

@@ -24,32 +24,56 @@ function formatDate(iso: string | null) {
}
}
/** Score chip — boxed, gold-deep, tabular (mockup 07 `.score`). Sits at the
* end of the meta row via `ms-auto`. White fill on gold-wash halacha cards. */
function ScoreChip({ score, onWash }: { score: number; onWash?: boolean }) {
return (
<span
className={`ms-auto rounded-md border border-rule px-2.5 py-0.5 text-xs font-semibold text-gold-deep tabular-nums ${
onWash ? "bg-white" : "bg-surface"
}`}
>
דירוג {score.toFixed(2)}
</span>
);
}
function HalachaCard({ hit }: { hit: Extract<SearchHit, { type: "halacha" }> }) {
return (
<div className="rounded-lg border border-gold/40 bg-gold-wash/40 p-4 space-y-2">
<div className="rounded-lg border border-rule bg-gold-wash p-4 shadow-sm space-y-2.5">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<Badge className="bg-gold text-navy border-0">הלכה</Badge>
<span className="font-mono" dir="ltr">{hit.case_number}</span>
{hit.court && <span>· {hit.court}</span>}
{hit.decision_date && <span>· {formatDate(hit.decision_date)}</span>}
{hit.precedent_level && <span>· {hit.precedent_level}</span>}
{/* PRE-3/PRE-5 (INV-IA5): the derived authority (binding/persuasive)
rides on the wire but was dropped here — render it as in the review
tab so search shows the same provenance everywhere. */}
<Badge className="rounded bg-gold text-white border-0 text-[0.68rem] font-bold tracking-wide">
הלכה
</Badge>
<span className="text-navy font-semibold" dir="ltr">{hit.case_number}</span>
{(hit.court || hit.decision_date || hit.precedent_level) && (
<span className="text-ink-muted">
{hit.court ? `· ${hit.court}` : ""}
{hit.decision_date ? ` · ${formatDate(hit.decision_date)}` : ""}
{hit.precedent_level ? ` · ${hit.precedent_level}` : ""}
</span>
)}
{/* PRE-3/PRE-5 (INV-IA5): derived authority (binding/persuasive)
rides on the wire — render the pill as in the review tab. */}
<AuthorityBadge authority={hit.authority} />
<span className="ms-auto tabular-nums">דירוג {hit.score.toFixed(2)}</span>
<ScoreChip score={hit.score} onWash />
</div>
<p className="text-navy font-medium text-[0.95rem]" dir="rtl">
<p className="text-ink font-medium text-[0.95rem] leading-7" dir="rtl">
{hit.rule_statement}
</p>
<blockquote className="text-ink-soft text-sm border-s-2 border-gold ps-3" dir="rtl">
<blockquote
className="rounded-e border-s-[3px] border-gold bg-white/50 px-3 py-2 text-sm text-ink-soft leading-7"
dir="rtl"
>
&ldquo;{hit.supporting_quote}&rdquo;
{hit.page_reference && <span className="text-ink-muted text-[0.72rem] ms-2">({hit.page_reference})</span>}
{hit.page_reference && (
<span className="text-ink-muted text-[0.72rem] ms-2">({hit.page_reference})</span>
)}
</blockquote>
{hit.subject_tags?.length > 0 && (
<div className="flex flex-wrap gap-1">
{hit.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem] bg-surface">
<Badge key={t} variant="outline" className="text-[0.65rem] bg-white">
{t}
</Badge>
))}
@@ -61,16 +85,24 @@ function HalachaCard({ hit }: { hit: Extract<SearchHit, { type: "halacha" }> })
function PassageCard({ hit }: { hit: Extract<SearchHit, { type: "passage" }> }) {
return (
<div className="rounded-lg border border-rule bg-surface p-4 space-y-2">
<div className="rounded-lg border border-rule bg-surface p-4 shadow-sm space-y-2.5">
<div className="flex items-center gap-2 text-[0.78rem] text-ink-muted flex-wrap">
<Badge variant="outline" className="bg-info-bg text-info border-transparent">קטע</Badge>
<span className="font-mono" dir="ltr">{hit.case_number}</span>
{hit.court && <span>· {hit.court}</span>}
{hit.decision_date && <span>· {formatDate(hit.decision_date)}</span>}
<span className="text-[0.7rem]">· {hit.section_type}</span>
<span className="ms-auto tabular-nums">דירוג {hit.score.toFixed(2)}</span>
<Badge variant="outline" className="rounded bg-info-bg text-info border-transparent text-[0.68rem] font-bold tracking-wide">
קטע
</Badge>
<span className="text-navy font-semibold" dir="ltr">{hit.case_number}</span>
{(hit.court || hit.decision_date) && (
<span className="text-ink-muted">
{hit.court ? `· ${hit.court}` : ""}
{hit.decision_date ? ` · ${formatDate(hit.decision_date)}` : ""}
</span>
)}
<span className="rounded bg-rule-soft text-ink-muted text-[0.68rem] px-2 py-0.5 font-medium">
{hit.section_type}
</span>
<ScoreChip score={hit.score} />
</div>
<p className="text-ink text-sm leading-relaxed" dir="rtl">
<p className="text-ink-soft text-sm leading-7" dir="rtl">
{hit.content.slice(0, 600)}
{hit.content.length > 600 && <span></span>}
</p>
@@ -98,18 +130,26 @@ export function LibrarySearchPanel() {
};
return (
<div className="space-y-4">
<form onSubmit={onSubmit} className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[300px]">
<label className="text-[0.78rem] text-ink-muted">שאילתת חיפוש</label>
<div className="space-y-6">
{/* search panel — boxed surface with query row + two-up filter row +
controls strip (checkbox start, gold CTA end) per mockup 07. */}
<form
onSubmit={onSubmit}
className="rounded-lg border border-rule bg-surface shadow-sm p-5 space-y-3.5"
>
<div>
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">
שאילתת חיפוש
</label>
<Input value={draft} onChange={(e) => setDraft(e.target.value)}
placeholder="השבחה אובייקטיבית" dir="rtl" />
</div>
<div className="min-w-[180px]">
<label className="text-[0.78rem] text-ink-muted">תחום</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5">
<div>
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">תחום</label>
<Select value={practiceArea || "_all"}
onValueChange={(v) => setPracticeArea(v === "_all" ? "" : v as PracticeArea)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRACTICE_AREAS.map((a) => (
@@ -118,11 +158,11 @@ export function LibrarySearchPanel() {
</SelectContent>
</Select>
</div>
<div className="min-w-[170px]">
<label className="text-[0.78rem] text-ink-muted">רמת תקדים</label>
<div>
<label className="block text-[0.78rem] text-ink-muted font-medium mb-1.5">רמת תקדים</label>
<Select value={precedentLevel || "_all"}
onValueChange={(v) => setPrecedentLevel(v === "_all" ? "" : v)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="_all">הכל</SelectItem>
{PRECEDENT_LEVELS.map((l) => (
@@ -131,18 +171,20 @@ export function LibrarySearchPanel() {
</SelectContent>
</Select>
</div>
<Button type="submit" className="bg-navy text-parchment hover:bg-navy-soft">
</div>
<div className="flex items-center gap-4 flex-wrap pt-1">
<label className="flex items-center gap-2 cursor-pointer text-[0.85rem] text-ink-soft">
<input type="checkbox" className="w-[15px] h-[15px] accent-gold" checked={includeHalachot}
onChange={(e) => setIncludeHalachot(e.target.checked)} />
כלול הלכות
</label>
<Button type="submit" className="ms-auto bg-gold text-white hover:bg-gold-deep border-transparent">
<Search className="w-4 h-4 me-1" />
חפש
</Button>
</div>
</form>
<label className="flex items-center gap-2 cursor-pointer text-sm text-ink-muted">
<input type="checkbox" checked={includeHalachot}
onChange={(e) => setIncludeHalachot(e.target.checked)} />
כלול הלכות (rule-level matches)
</label>
{!query.trim() ? (
<div className="text-center text-ink-muted py-12">
הקלד שאילתא כדי לחפש בקורפוס. החיפוש סמנטי לא טקסטואלי.
@@ -160,11 +202,13 @@ export function LibrarySearchPanel() {
לא נמצאו תוצאות. נסה ניסוח אחר או הסר פילטרים.
</div>
) : (
<div className="space-y-3">
<p className="text-[0.78rem] text-ink-muted flex items-center gap-2">
<span className="inline-flex items-center rounded-full bg-success-bg text-success text-[0.7rem] font-semibold px-2.5 py-0.5">
<div className="space-y-3.5">
<p className="text-[0.82rem] text-ink-muted flex items-center gap-2">
<span>תוצאות</span>
<span className="inline-flex items-center rounded-full bg-success-bg text-success text-[0.72rem] font-semibold px-2.5 py-0.5">
הלכות מאושרות בלבד
</span>
<span aria-hidden>·</span>
<span className="tabular-nums">{data.count} תוצאות</span>
</p>
{data.items.map((hit, i) =>

View File

@@ -128,60 +128,6 @@ function LinkDialog({ caseId, currentRelated, open, onOpenChange }: DialogProps)
);
}
// ── Related Case Card ────────────────────────────────────────────────
function RelatedCaseCard({ caseId, related }: { caseId: string; related: RelatedCase }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
async function handleUnlink() {
try {
await unlinkCase(related.id);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-rule bg-surface">
<a
href={`/precedents/${related.id}`}
className="min-w-0 flex-1 hover:opacity-80 transition-opacity"
>
<div className="text-sm font-medium text-navy truncate">
{related.case_name || related.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{related.precedent_level && (
<Badge
variant="outline"
className={`text-[0.62rem] ${LEVEL_COLORS[related.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[related.precedent_level] ?? related.precedent_level}
</Badge>
)}
{related.court && (
<span className="text-[0.7rem] text-ink-muted truncate">{related.court}</span>
)}
{related.date && (
<span className="text-[0.7rem] text-ink-muted tabular-nums" dir="ltr">
{related.date.slice(0, 10)}
</span>
)}
</div>
</a>
<button
onClick={handleUnlink}
disabled={isPending}
className="p-1 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// ── Public section component ─────────────────────────────────────────
type SectionProps = {
@@ -189,28 +135,68 @@ type SectionProps = {
related: RelatedCase[];
};
/* Rail-styled citations card (mockup 08 side rail). Renders linked related
* decisions as a navy-headed card with arrow-prefixed rows; keeps the full
* link/unlink logic. Used in the precedent-detail side rail. */
export function RelatedCasesSection({ caseId, related }: SectionProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-navy text-sm font-semibold">
החלטות קשורות{related.length > 0 ? ` (${related.length})` : ""}
<div className="rounded-lg border border-rule bg-surface shadow-sm px-4 py-3.5 space-y-2.5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-navy text-[0.92rem] font-semibold m-0">
ציטוטים מקושרים{related.length > 0 ? ` (${related.length})` : ""}
</h3>
<Button variant="outline" size="sm" onClick={() => setDialogOpen(true)}>
<Link2 className="w-3.5 h-3.5 me-1" /> קשר החלטה
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[0.72rem] border-rule"
onClick={() => setDialogOpen(true)}
>
<Link2 className="w-3 h-3 me-1" /> קשר
</Button>
</div>
{related.length === 0 ? (
<p className="text-ink-muted text-sm">אין החלטות קשורות עדיין</p>
<p className="text-ink-muted text-[0.82rem] m-0">אין החלטות קשורות עדיין</p>
) : (
<div className="space-y-1.5">
<ul className="list-none p-0 m-0">
{related.map((r) => (
<RelatedCaseCard key={r.id} caseId={caseId} related={r} />
))}
<li
key={r.id}
className="flex items-start gap-2 py-2 border-b border-rule-soft last:border-b-0"
>
<span className="text-gold font-bold leading-6 shrink-0" aria-hidden></span>
<a
href={`/precedents/${r.id}`}
className="min-w-0 flex-1 group hover:opacity-90 transition-opacity"
>
<div className="text-[0.82rem] text-ink-soft leading-5 group-hover:text-navy">
{r.case_name || r.case_number}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{r.precedent_level && (
<Badge
variant="outline"
className={`text-[0.6rem] ${LEVEL_COLORS[r.precedent_level] ?? ""}`}
>
{LEVEL_LABELS[r.precedent_level] ?? r.precedent_level}
</Badge>
)}
{r.court && (
<span className="text-[0.68rem] text-ink-muted truncate">{r.court}</span>
)}
{r.date && (
<span className="text-[0.68rem] text-ink-muted tabular-nums" dir="ltr">
{r.date.slice(0, 10)}
</span>
)}
</div>
</a>
<UnlinkButton caseId={caseId} relatedId={r.id} />
</li>
))}
</ul>
)}
<LinkDialog
@@ -222,3 +208,24 @@ export function RelatedCasesSection({ caseId, related }: SectionProps) {
</div>
);
}
function UnlinkButton({ caseId, relatedId }: { caseId: string; relatedId: string }) {
const { mutateAsync: unlinkCase, isPending } = useUnlinkRelatedCase(caseId);
return (
<button
onClick={async () => {
try {
await unlinkCase(relatedId);
toast.success("הקישור הוסר");
} catch {
toast.error("שגיאה בהסרת הקישור");
}
}}
disabled={isPending}
className="p-0.5 rounded hover:bg-danger-bg hover:text-danger transition-colors text-ink-muted disabled:opacity-40 shrink-0"
title="הסר קישור"
>
<X className="w-3 h-3" />
</button>
);
}

View File

@@ -3,7 +3,10 @@
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { SubjectDonut } from "@/components/training/subject-donut";
import { useStyleReport } from "@/lib/api/training";
import { useStyleReport, useCuratorStats } from "@/lib/api/training";
// Mockup 12 anatomy palette — info · gold · gold-deep · success, cycling.
const ANATOMY_COLORS = ["#4e6a8c", "#a97d3a", "#8b6428", "#4a7c59"];
function KPICard({
label,
@@ -16,15 +19,13 @@ function KPICard({
}) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 flex flex-col gap-0.5">
<span className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted">
{label}
</span>
<span className="font-display text-[2rem] font-black leading-none text-navy">
<CardContent className="px-[18px] py-4 flex flex-col">
<span className="font-display text-[1.85rem] font-bold leading-[1.1] text-navy tabular-nums">
{value}
</span>
<span className="text-[0.81rem] text-ink-soft mt-1">{label}</span>
{caption && (
<span className="text-[0.78rem] text-ink-muted mt-1">{caption}</span>
<span className="text-[0.72rem] text-ink-muted mt-0.5">{caption}</span>
)}
</CardContent>
</Card>
@@ -33,6 +34,7 @@ function KPICard({
export function StyleReportPanel() {
const { data, isPending, error } = useStyleReport();
const curator = useCuratorStats();
if (error) {
return (
@@ -63,14 +65,13 @@ export function StyleReportPanel() {
return (
<div className="space-y-6">
{/* Headline */}
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-6 py-4">
<p className="font-display text-gold-deep text-lg font-semibold leading-snug">
{c.headline}
{/* Headline banner — gold-wash, ★ aligned to start (mockup 12) */}
<div className="flex items-start gap-3 rounded-lg border border-gold bg-gold-wash px-5 py-4 shadow-sm">
<span className="text-gold-deep text-xl leading-tight shrink-0"></span>
<p className="text-ink-soft text-[0.95rem] leading-relaxed m-0">
{c.headline}
</p>
</CardContent>
</Card>
</div>
{/* KPIs */}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
@@ -121,23 +122,22 @@ export function StyleReportPanel() {
{data.anatomy.sections.length === 0 ? (
<p className="text-ink-muted text-sm">אין נתונים על מבנה</p>
) : (
<ul className="space-y-2.5">
{data.anatomy.sections.map((s) => {
<ul className="space-y-3.5">
{data.anatomy.sections.map((s, i) => {
const pct = Math.round(s.pct * 100);
const fill = ANATOMY_COLORS[i % ANATOMY_COLORS.length];
return (
<li key={s.type} className="space-y-1">
<div className="flex items-center justify-between text-[0.78rem]">
<span className="text-ink-soft font-medium">
{s.label}
</span>
<span className="text-ink-muted tabular-nums">
<li key={s.type} className="space-y-1.5">
<div className="flex items-center justify-between text-[0.81rem]">
<span className="text-ink-soft">{s.label}</span>
<span className="text-navy font-semibold tabular-nums">
{pct}% · {s.avg_chars.toLocaleString()} תווים
</span>
</div>
<div className="h-2 rounded bg-rule-soft overflow-hidden">
<div className="h-2.5 rounded-full bg-rule-soft overflow-hidden">
<div
className="h-full bg-gradient-to-l from-gold to-gold-deep"
style={{ width: `${pct}%` }}
className="h-full rounded-full"
style={{ width: `${pct}%`, backgroundColor: fill }}
/>
</div>
</li>
@@ -149,42 +149,39 @@ export function StyleReportPanel() {
</Card>
</div>
{/* Signature phrases */}
{/* Signature phrases + curator stat — two columns (mockup 12) */}
<div className="grid gap-6 lg:grid-cols-2 items-start">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h3 className="text-navy text-lg mb-1">ביטויי חתימה</h3>
{data.signature_phrases.headline && (
<p className="text-[0.78rem] text-gold-deep mb-4">
<p className="text-[0.78rem] text-gold-deep mb-3">
{data.signature_phrases.headline}
</p>
)}
{data.signature_phrases.items.length === 0 ? (
<p className="text-ink-muted text-sm">אין ביטויים שחולצו עדיין</p>
) : (
<ol className="space-y-2">
<ol>
{data.signature_phrases.items.slice(0, 12).map((p, i) => (
<li
key={`${p.type}-${i}`}
className="flex items-start gap-3 rounded border border-rule bg-parchment/40 px-3 py-2"
className="flex items-baseline gap-2.5 py-2.5 border-b border-rule-soft last:border-b-0"
>
<span className="text-[0.7rem] text-ink-muted tabular-nums shrink-0 mt-0.5">
#{i + 1}
<span className="w-5 shrink-0 text-gold-deep font-bold tabular-nums text-sm">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-ink leading-relaxed text-sm">{p.text}</p>
<p className="text-ink-soft leading-relaxed text-[0.85rem] m-0">
{p.text}
</p>
{p.context && (
<p className="text-[0.7rem] text-ink-muted mt-0.5">
<p className="text-[0.7rem] text-ink-muted mt-0.5 m-0">
{p.context}
</p>
)}
</div>
<span
className="
shrink-0 text-[0.72rem] rounded-full
bg-gold-wash text-gold-deep border border-gold/40
px-2 py-0.5 tabular-nums
"
>
<span className="shrink-0 text-[0.72rem] font-semibold rounded-full bg-gold-wash text-gold-deep border border-rule px-2.5 py-0.5 tabular-nums whitespace-nowrap">
×{p.frequency}
</span>
</li>
@@ -193,6 +190,51 @@ export function StyleReportPanel() {
)}
</CardContent>
</Card>
{/* Curator — surfaced style findings (INV-LRN1 writer gate) */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 flex flex-col gap-4">
<h3 className="text-navy text-lg m-0">אוצֵר ממצאי-סגנון</h3>
<div className="flex items-center gap-4 rounded-lg border border-success bg-success-bg px-[18px] py-3.5">
<span className="text-success font-bold text-[1.75rem] leading-none tabular-nums">
{curator.data ? curator.data.findings_approved : "—"}
</span>
<div className="min-w-0">
<b className="text-navy text-sm font-semibold block">
ממצאים מאושרים (זורמים לכותב)
</b>
<span className="text-ink-muted text-[0.78rem]">
אושרו ע״י היו״ר · review_status=approved
</span>
</div>
</div>
<div className="flex gap-3.5">
<div className="flex-1 rounded-lg border border-rule bg-warn-bg px-3.5 py-3">
<div className="text-warn font-bold text-[1.35rem] tabular-nums leading-none">
{curator.data
? Math.max(
0,
curator.data.total_findings -
curator.data.findings_approved,
)
: "—"}
</div>
<div className="text-[0.78rem] text-ink-soft mt-1">לא-מאושרים</div>
</div>
<div className="flex-1 rounded-lg border border-rule bg-rule-soft px-3.5 py-3">
<div className="text-ink-muted font-bold text-[1.35rem] tabular-nums leading-none">
{curator.data ? curator.data.total_findings : "—"}
</div>
<div className="text-[0.78rem] text-ink-soft mt-1">סך ממצאים</div>
</div>
</div>
<p className="text-[0.72rem] text-ink-muted leading-relaxed m-0">
רק ממצא מאושר זורם לכותב (INV-LRN1). ממתינים ונדחים אינם משפיעים על
הטיוטות.
</p>
</CardContent>
</Card>
</div>
</div>
);
}