feat(ui): IA redesign → production · יישום נאמן של 16 הדפים הנותרים למוקאפים
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s

תיקון הגישה: יישום מלא ונאמן של עיצוב-המוקאפים המאושרים (Claude Design) על כל
הדפים — שינוי-הרכב אמיתי פר-מוקאפ, לא ליטוש-טוקנים. כל hook/query/mutation/טאב/
טופס/נתון נשמר (אומת: tsc נקי + בדיקת-נוכחות hooks קריטיים; 0 פונקציונליות נמחקה).

דפים (← מוקאפ):
- בית — לוח: KPI + "תיקים לפי סטטוס" (bars) + כרטיס-אישורים + CTA כפול.
- ארכיון — filter-bar שטוח + טבלה נקייה + צ'יפי-סוג/תוצאה.
- הערות יו״ר — פריסה דו-טורית + טופס-הוספה חי + כרטיסי-הערה.
- ספריית-פסיקה — tabs קו-תחתון + כרטיסי-תוצאה halacha/קטע + AuthorityBadge.
- דף-תקדים — באנר-meta parchment + דו-טורי + provenance pills.
- פסיקה-חסרה — pill פתוחים + צ'יפי-סטטוס + CTA העלאה.
- יומונים — אזור-העלאה מקווקו + כרטיסי-digest + "ממתין" כתווית פסיבית.
- גרף — פאנל-צד שכבות/אנליטיקה + canvas parchment.
- אימון-סגנון — פורטרט: banner + KPI + אנטומיה + ביטויי-חתימה.
- מתודולוגיה — עורך-צ'קליסט + "חל על:" + canon chip.
- מיומנויות/סקריפטים — טבלאות אמיתיות + צ'יפי-סטטוס.
- הגדרות — sidenav דו-טורי + env-rows עם "ממתין ל-redeploy".
- דף-תיק — באנר-תיק parchment + tabs + timeline + "פתח עורך החלטה".
- תפעול — SectionHeaders + טבלת-שירותים + כרטיסי-שער gold-wash.
- compose — באנר-תיק + SOT pill + פריסה דו-טורית + "השלמה והעברה".

תיקונים שלי אחרי הסוכנים: documents-panel (הוצאת רכיב Shell מ-render — React
Compiler), scripts useMemo deps. /approvals כבר נבנה מחדש נאמנה (commit קודם).

בדיקות: npx tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109 קיים-מראש).
שימור-פונקציונליות אומת. CI Docker build = שער סופי לפני deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:00:25 +00:00
parent c53ef9a7c4
commit f3b075d282
32 changed files with 2925 additions and 1799 deletions

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,126 +190,141 @@ 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>
<p className="text-ink-muted text-base max-w-2xl leading-relaxed">
תיקים שסגרו את הטיפול בהם. שחזור מחזיר את התיק לרשימה הראשית
ופותח מחדש את הפרויקט המקביל ב-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 className="space-y-1">
<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-base max-w-2xl leading-relaxed">
כל ההחלטות שהושלמו לחיפוש, סינון ועיון. {total} תיקים סגורים.
שחזור מחזיר את התיק לרשימה הראשית ופותח מחדש את הפרויקט המקביל ב-Paperclip.
</p>
</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">
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="חיפוש לפי מס׳ ערר או כותרת…"
className="max-w-sm bg-surface"
dir="rtl"
/>
</div>
{/* 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="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">
<Table>
<TableHeader className="bg-rule-soft/60">
{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"
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{{ asc: " ▲", desc: " ▼" }[
header.column.getIsSorted() as string
] ?? ""}
</TableHead>
{/* 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-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-ink-muted font-medium text-[0.78rem] cursor-pointer select-none text-start"
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{{ asc: " ▲", desc: "" }[
header.column.getIsSorted() as string
] ?? ""}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isPending ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i} className="border-rule-soft">
{columns.map((_c, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isPending ? (
Array.from({ length: 3 }).map((_, i) => (
<TableRow key={i} className="border-rule">
{columns.map((_c, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))
) : error ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center text-danger py-8"
>
שגיאה בטעינת ארכיון: {error.message}
</TableCell>
))
) : error ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center text-danger py-8"
>
שגיאה בטעינת ארכיון: {error.message}
</TableCell>
</TableRow>
) : filteredRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center text-ink-muted py-12"
>
<div className="text-gold text-2xl mb-2" aria-hidden>
</div>
{globalFilter || typeFilter !== "all"
? "אין תיקים תואמים לחיפוש"
: "אין תיקים בארכיון"}
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow
key={row.id}
className="border-rule-soft hover:bg-gold-wash transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center text-ink-muted py-12"
>
<div className="text-gold text-2xl mb-2" aria-hidden>
</div>
{globalFilter
? "אין תיקים תואמים לחיפוש"
: "אין תיקים בארכיון"}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="border-rule hover:bg-gold-wash/40 transition-colors"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
))
)}
</TableBody>
</Table>
</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,58 +111,79 @@ 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>
)}
<input
ref={fileRef}
type="file"
accept=".md"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
}}
/>
<Button
variant="outline"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "מעלה..." : "העלה ניתוח מעודכן"}
</Button>
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
a.download = `analysis-${caseNumber}.md`;
a.click();
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-4 py-4">
<h3 className="text-navy text-[0.9rem] font-semibold mb-3">השלמה והעברה</h3>
<input
ref={fileRef}
type="file"
accept=".md"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleUpload(f);
}}
>
הורד ניתוח
/>
<div className="space-y-2">
{hasAnalysis && (
<Button
variant="outline"
className="w-full justify-center"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
a.click();
}}
>
ייצוא DOCX
</Button>
)}
<Button
className="w-full justify-center bg-gold text-white hover:bg-gold-deep"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "מעלה…" : "העלאת ניתוח מעודכן"}
</Button>
{hasAnalysis && (
<Button
variant="outline"
className="w-full justify-center"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/download`;
a.download = `analysis-${caseNumber}.md`;
a.click();
}}
>
הורד ניתוח (MD)
</Button>
)}
</div>
{uploadMsg && (
<p className={`text-xs mt-2 ${uploadMsg.ok ? "text-success" : "text-danger"}`}>
{uploadMsg.text}
</p>
)}
{/* 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>
)}
{hasAnalysis && (
<Button
variant="outline"
onClick={() => {
const a = document.createElement("a");
a.href = `/api/cases/${caseNumber}/research/analysis/export-docx`;
a.click();
}}
>
הורד כ-DOCX
</Button>
)}
<Button asChild variant="outline">
<Link href={`/cases/${caseNumber}`}>חזרה לתיק</Link>
</Button>
</div>
</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,121 +229,84 @@ 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">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden>·</span>
<Link
href={`/cases/${caseNumber}`}
className="hover:text-gold-deep"
>
ערר {caseNumber}
</Link>
<span aria-hidden>·</span>
<span className="text-navy">עורך החלטה</span>
</nav>
<h1 className="text-navy mb-0">ניתוח משפטי וכתיבת עמדה</h1>
{caseQuery.data?.title && (
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
{caseQuery.data.title}
</p>
)}
</div>
<AnalysisActions caseNumber={caseNumber} hasAnalysis={!!analysis.data} onUploaded={() => analysis.refetch()} />
{/* ── 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">
ערר {caseNumber}
</Link>
<span aria-hidden>·</span>
<span className="text-navy">עורך החלטה</span>
</nav>
<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>
{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">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
) : isNotFound ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-12 text-center space-y-3">
<div className="text-gold text-3xl" aria-hidden></div>
<h2 className="text-navy text-lg mb-0">
טרם בוצע ניתוח משפטי לתיק זה
</h2>
<p className="text-ink-muted text-sm max-w-md mx-auto">
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
</p>
</CardContent>
</Card>
) : analysis.error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-5 text-center">
<p className="text-danger">{analysis.error.message}</p>
</CardContent>
</Card>
) : analysis.data ? (
<div className="space-y-6">
{/* Case-level general precedents */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-xl mb-1">פסיקה כללית לדיון</h2>
<p className="text-[0.78rem] text-ink-muted mb-4">
ציטוטים התומכים בעמדה באופן רוחבי ישולבו בפתיחת בלוק י (דיון).
</p>
<PrecedentsSection
caseNumber={caseNumber}
sectionId={null}
precedents={caseLevelPrecedents}
practiceArea={practiceArea}
emptyHelperText="עדיין לא צורפה פסיקה כללית לתיק"
/>
</CardContent>
</Card>
{/* Threshold claims */}
{analysis.data.threshold_claims &&
analysis.data.threshold_claims.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-navy text-xl mb-0">טענות סף</h2>
<span className="text-[0.72rem] rounded-full bg-gold-wash text-gold-deep px-2 py-0.5 border border-gold/40 tabular-nums">
{analysis.data.threshold_claims.length}
</span>
</div>
<div className="space-y-3">
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={tc.id}
caseNumber={caseNumber}
item={tc}
precedents={precedentsBySection.get(tc.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
</div>
)}
{/* Issues */}
{analysis.data.issues && analysis.data.issues.length > 0 && (
{analysis.isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-4 w-80" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
) : isNotFound ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-12 text-center space-y-3">
<div className="text-gold text-3xl" aria-hidden></div>
<h2 className="text-navy text-lg mb-0">
טרם בוצע ניתוח משפטי לתיק זה
</h2>
<p className="text-ink-muted text-sm max-w-md mx-auto">
לאחר שקובץ <code>analysis-and-research.md</code> ייווצר, תוכלי
לערוך כאן את עמדת הוועדה לכל טענת סף וסוגיה.
</p>
</CardContent>
</Card>
) : analysis.error ? (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-5 text-center">
<p className="text-danger">{analysis.error.message}</p>
</CardContent>
</Card>
) : analysis.data ? (
/* ── 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.issues.length}
{analysis.data.threshold_claims.length}
</span>
</div>
<div className="space-y-3">
{analysis.data.issues.map((iss) => (
<div className="space-y-2.5">
{analysis.data.threshold_claims.map((tc) => (
<SubsectionCard
key={iss.id}
key={tc.id}
caseNumber={caseNumber}
item={iss}
precedents={precedentsBySection.get(iss.id) ?? []}
item={tc}
precedents={precedentsBySection.get(tc.id) ?? []}
practiceArea={practiceArea}
/>
))}
@@ -274,8 +314,31 @@ export default function ComposePage({
</div>
)}
{(!analysis.data.threshold_claims?.length &&
!analysis.data.issues?.length) && (
{/* Issues */}
{analysis.data.issues && analysis.data.issues.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-navy text-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-2.5">
{analysis.data.issues.map((iss) => (
<SubsectionCard
key={iss.id}
caseNumber={caseNumber}
item={iss}
precedents={precedentsBySection.get(iss.id) ?? []}
practiceArea={practiceArea}
/>
))}
</div>
</div>
)}
{!analysis.data.threshold_claims?.length &&
!analysis.data.issues?.length && (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-10 text-center text-ink-muted">
לא נמצאו טענות סף או סוגיות בניתוח זה.
@@ -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. */}
<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}
/>
{/* 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-lg font-semibold mb-0">רקע לניתוח</h2>
<ProseSection title="צד מיוצג" content={analysis.data.represented_party} />
<ProseSection title="רקע דיוני" content={analysis.data.procedural_background} />
<ProseSection title="עובדות מוסכמות" content={analysis.data.agreed_facts} />
<ProseSection title="עובדות במחלוקת" content={analysis.data.disputed_facts} />
</CardContent>
</Card>
{analysis.data.conclusions?.trim() && (
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<h2 className="text-gold-deep text-lg font-semibold mb-0">מסקנות</h2>
<Markdown content={analysis.data.conclusions.trim()} />
</CardContent>
</Card>
{analysis.data.conclusions?.trim() && (
<Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<h2 className="text-gold-deep text-xl mb-0">מסקנות</h2>
<Markdown content={analysis.data.conclusions.trim()} />
</CardContent>
</Card>
)}
)}
</div>
) : null}
</section>
{/* 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}
</AppShell>
);
}

View File

@@ -45,10 +45,10 @@ export default function CaseDetailPage({
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
: null;
return (
<AppShell>
<section className="space-y-6">
{error ? (
if (error) {
return (
<AppShell>
<section className="space-y-6">
<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,130 +58,168 @@ 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 ? (
<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" />
</div>
) : (
<>
{isPending ? (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-6 w-96" />
<CaseHeader data={data} actions={bandActions} tabs={tabsList} />
)}
{/* 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>
<CardContent className="px-5 py-4 space-y-4">
<div>
<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 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">
{data?.processing_count ?? 0}
</dd>
</dl>
</div>
{canStartWorkflow && (
<div className="pt-2 border-t border-rule-soft">
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
disabled={startWorkflow.isPending}
onClick={() =>
startWorkflow.mutate(undefined, {
onSuccess: (res) =>
toast.success(
`תהליך הופעל — ${res.issue_identifier}`,
),
onError: (err) =>
toast.error(`שגיאה: ${err.message}`),
})
}
>
{startWorkflow.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Play className="w-4 h-4 me-1.5" />
)}
התחל תהליך
</Button>
</div>
)}
</CardContent>
</Card>
) : (
<CaseHeader data={data} />
)}
<div className="grid gap-6 lg:grid-cols-[1fr_280px]">
<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">
<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} />
</div>
</div>
<TabsContent value="overview" className="mt-5 space-y-4">
<div>
<h3 className="text-navy text-base mb-2">תוצאה צפויה</h3>
<p className="text-ink-soft text-sm leading-relaxed">
{expectedOutcomeLabel ?? "לא נקבעה תוצאה צפויה."}
</p>
</div>
<div>
<h3 className="text-navy text-base mb-2">סיכום מהיר</h3>
<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">
{data?.processing_count ?? 0}
</dd>
</dl>
</div>
{canStartWorkflow && (
<div className="pt-2 border-t border-rule">
<Button
className="bg-gold-deep hover:bg-gold-deep/90 text-parchment"
disabled={startWorkflow.isPending}
onClick={() =>
startWorkflow.mutate(undefined, {
onSuccess: (res) =>
toast.success(
`תהליך הופעל — ${res.issue_identifier}`,
),
onError: (err) =>
toast.error(`שגיאה: ${err.message}`),
})
}
>
{startWorkflow.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1.5" />
) : (
<Play className="w-4 h-4 me-1.5" />
)}
התחל תהליך
</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>
<LegalArgumentsPanel caseNumber={caseNumber} />
</CardContent>
</Card>
</TabsContent>
<Card className="bg-surface border-rule shadow-sm h-fit">
<CardContent className="px-6 py-5 space-y-5">
<AgentStatusWidget caseNumber={caseNumber} />
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
<WorkflowTimeline status={data?.status} />
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
<StatusGuide />
<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>
</div>
</>
)}
</section>
</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} />
<WorkflowTimeline status={data?.status} />
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
<StatusGuide />
</CardContent>
</Card>
</div>
</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,44 +38,68 @@ 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 />
</TabsTrigger>
<TabsTrigger value="search">חיפוש</TabsTrigger>
</TabsList>
<Tabs defaultValue="list" dir="rtl">
<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>
))}
</TabsList>
<TabsContent value="list" className="mt-5">
<DigestListPanel />
</TabsContent>
<TabsContent value="list" className="mt-5">
<DigestListPanel />
</TabsContent>
<TabsContent value="search" className="mt-5">
<DigestSearchPanel />
</TabsContent>
</Tabs>
</CardContent>
</Card>
<TabsContent value="search" className="mt-5">
<DigestSearchPanel />
</TabsContent>
</Tabs>
</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]}`}>
<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="hover:text-gold-deep"
>
ערר {fb.case_number}
</Link>
) : (
"ללא תיק"
)}
<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]}
</Badge>
<Badge variant="outline" className="text-[0.7rem]">
{BLOCK_LABELS[fb.block_id] ?? fb.block_id}
</Badge>
{fb.case_number ? (
<Link
href={`/cases/${encodeURIComponent(fb.case_number)}`}
className="text-[0.72rem] text-gold-deep hover:underline"
>
תיק {fb.case_number}
</Link>
) : (
<span className="text-[0.72rem] text-ink-muted">ללא תיק</span>
)}
<span className="ms-auto text-[0.72rem] text-ink-muted">
</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>
</div>
<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>
) : 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>
<h1 className="text-navy mb-0">הערות יו״ר</h1>
<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,60 +347,50 @@ 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>
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-ink-muted text-sm">
שגיאה בטעינת ההערות. נסה לרענן.
</CardContent>
</Card>
) : isPending ? (
<div className="space-y-3">
{[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" />
{/* 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">
שגיאה בטעינת ההערות. נסה לרענן.
</CardContent>
</Card>
))}
</div>
) : items.length === 0 ? (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הערות בקטגוריה זו.</p>
{unresolvedOnly && (
<p className="text-sm mt-2">כל ההערות יושמו </p>
) : isPending ? (
<div className="space-y-3.5">
{[0, 1, 2].map((i) => (
<Card key={i} className="bg-surface border-rule shadow-sm">
<CardContent className="px-[18px] py-4 h-28 animate-pulse" />
</Card>
))}
</div>
) : items.length === 0 ? (
<div className="text-center text-ink-muted py-16">
<p className="text-lg">אין הערות בקטגוריה זו.</p>
{unresolvedOnly && (
<p className="text-sm mt-2">כל ההערות יושמו </p>
)}
</div>
) : (
<div className="space-y-3.5">
{items.map((fb) => (
<FeedbackCard key={fb.id} fb={fb} />
))}
</div>
)}
</div>
) : (
<div className="space-y-3">
{items.map((fb) => (
<FeedbackCard key={fb.id} fb={fb} />
))}
</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,124 +41,129 @@ 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>
<h1 className="text-navy mb-0">פסיקה חסרה בקורפוס</h1>
<p className="text-ink-muted text-sm mt-1 max-w-3xl">
פסיקות שצוטטו בכתבי הטענות אך אינן עדיין בקורפוס. סוכן המחקר רושם
פערים אוטומטית; היו&quot;ר סוגר אותם על־ידי העלאת המסמך ניתוב
אוטומטי בין הקורפוס הסמכותי (פסקי דין) להחלטות ועדות ערר.
</p>
</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>
{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 */}
<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>
<Input
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="1017-03-26"
dir="rtl"
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="text-[0.78rem] text-ink-muted">נושא משפטי</label>
<Input
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
{/* shared filters */}
<div className="flex items-end gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="block text-[0.78rem] text-ink-muted mb-1.5">תיק (מספר ערר)</label>
<Input
value={caseNumber}
onChange={(e) => setCaseNumber(e.target.value)}
placeholder="1017-03-26"
dir="rtl"
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-[0.78rem] text-ink-muted mb-1.5">נושא משפטי</label>
<Input
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</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"
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
</TabsContent>
<MissingPrecedentsTable
status={filter === "all" ? "" : filter}
caseNumber={caseNumber.trim() || undefined}
legalTopic={legalTopic.trim() || undefined}
/>
<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,64 +195,91 @@ 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">
{data.services.map((s: OpsService) => {
const isCron = !!s.cron;
return (
<div
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"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{isCron ? (
<Badge
variant={s.disabled ? "destructive" : "default"}
className="font-normal"
>
{s.disabled ? "כבוי" : "פעיל (מתוזמן)"}
</Badge>
) : (
<StatusBadge value={s.status} />
)}
{s.cron ? (
<span className="text-[0.7rem] 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>
<ServiceControls
s={s}
busy={busy}
onAction={(a) => action.mutate({ name: s.name, action: a })}
onToggle={(disabled) => toggle.mutate({ name: s.name, disabled })}
/>
</div>
);
})}
/* 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 (
<tr
key={s.name}
className="border-b border-rule-soft last:border-0 align-top"
>
<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
variant={s.disabled ? "destructive" : "default"}
className="font-normal"
>
{s.disabled ? "כבוי" : "פעיל (מתוזמן)"}
</Badge>
) : (
<StatusBadge value={s.status} />
)}
{s.cron ? (
<span
className="text-[0.66rem] text-ink-muted font-mono"
dir="ltr"
>
{s.cron}
</span>
) : null}
</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}
onAction={(a) => action.mutate({ name: s.name, action: a })}
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,30 +499,29 @@ 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>
{data ? (
<div className="flex items-center gap-2 text-[0.72rem]">
{/* 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. */}
<Badge variant="default" className="font-normal">
רצים {data.running}{data.errors.length > 0 ? "+" : ""}
</Badge>
<Badge variant="secondary" className="font-normal">
בתור {data.queued}{data.errors.length > 0 ? "+" : ""}
</Badge>
{data.errors.length > 0 ? (
<span
className="text-warn"
title={`ספירה חלקית — חברות שלא נטענו: ${data.errors.join(" · ")}`}
>
חלקי
</span>
) : null}
</div>
) : null}
</div>
{/* mockup 02: status pills row (running / queued) + company hint */}
{data ? (
<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. */}
<Badge variant="default" className="font-normal">
רצים {data.running}{data.errors.length > 0 ? "+" : ""}
</Badge>
<Badge variant="secondary" className="font-normal">
בתור {data.queued}{data.errors.length > 0 ? "+" : ""}
</Badge>
{data.errors.length > 0 ? (
<span
className="text-warn"
title={`ספירה חלקית — חברות שלא נטענו: ${data.errors.join(" · ")}`}
>
חלקי
</span>
) : null}
<span className="text-ink-muted ms-auto">חברות: CMP · CMPA</span>
</div>
) : null}
<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">
<Link href="/cases/new">+ תיק חדש</Link>
</Button>
<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">
<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) => (
<li
key={c.key}
className="flex items-center justify-between gap-2 text-[0.85rem] text-ink-soft"
>
<span>{c.label}</span>
<span className="text-navy font-semibold tabular-nums">{c.count}</span>
</li>
))}
{/* מה ממתין להכרעתך — 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">
<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 gap-2.5 py-2.5 text-[0.88rem] text-ink-soft border-b border-rule-soft last:border-b-0"
>
<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"
>
<Link href="/approvals">למרכז האישורים </Link>
</Button>
</CardContent>
</Card>
) : null}
) : (
<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>
</CardContent>
</Card>
<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,187 +57,224 @@ export default function PrecedentDetailPage({
const [editingCitation, setEditingCitation] = useState(false);
const [citationDraft, setCitationDraft] = useState("");
if (error) {
return (
<AppShell>
<section className="space-y-6" dir="rtl">
<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>
</div>
</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>
<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>
<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>
</header>
{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>
<p className="text-sm text-ink-muted">{error.message}</p>
<Button asChild variant="outline">
<Link href="/precedents">חזרה לספרייה</Link>
<div className="flex items-start justify-between gap-3 flex-wrap">
<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-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" className="border-rule">
<Link href={`/graph?focus=cl:${id}`}>
<Share2 className="w-3.5 h-3.5 me-1" /> הצג בגרף
</Link>
</Button>
</CardContent>
</Card>
) : isPending || !data ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => <Skeleton key={i} className="h-16 w-full" />)}
<Button variant="outline" size="sm" className="border-rule" onClick={() => setEditing(true)}>
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
</Button>
</div>
</div>
) : (
<>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 space-y-4">
<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">
{data.case_name || "—"}
</h1>
<div className="text-ink-muted text-sm font-mono" dir="ltr">
{data.case_number}
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm">
<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)}>
<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. */}
<CitationBlock
precedent={data as Precedent}
editing={editingCitation}
draft={citationDraft}
onStartEdit={() => {
setCitationDraft(data.citation_formatted ?? "");
setEditingCitation(true);
}}
onCancel={() => setEditingCitation(false)}
onChange={setCitationDraft}
onSave={async () => {
try {
await update.mutateAsync({
id,
patch: { citation_formatted: citationDraft.trim() },
});
toast.success("מראה מקום עודכן");
setEditingCitation(false);
} catch (e) {
toast.error(
e instanceof Error ? e.message : "שמירה נכשלה",
);
}
}}
saving={update.isPending}
/>
{/* citation (unified Israeli citation rules) — chair-editable */}
<div className="mt-4 max-w-3xl">
<CitationBlock
precedent={data as Precedent}
editing={editingCitation}
draft={citationDraft}
onStartEdit={() => {
setCitationDraft(data.citation_formatted ?? "");
setEditingCitation(true);
}}
onCancel={() => setEditingCitation(false)}
onChange={setCitationDraft}
onSave={async () => {
try {
await update.mutateAsync({
id,
patch: { citation_formatted: citationDraft.trim() },
});
toast.success("מראה מקום עודכן");
setEditingCitation(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שמירה נכשלה");
}
}}
saving={update.isPending}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{data.practice_area ? (
<Badge variant="outline" className="text-[0.7rem] bg-info-bg text-info border-transparent">
{PRACTICE_AREA_LABELS[data.practice_area] ?? data.practice_area}
</Badge>
) : null}
{data.source_type ? (
<Badge variant="outline" className="text-[0.7rem]">
{SOURCE_TYPE_LABELS[data.source_type] ?? data.source_type}
</Badge>
) : null}
{data.precedent_level ? (
<Badge variant="outline" className="text-[0.7rem] bg-gold-wash text-gold-deep border-rule">
{data.precedent_level}
</Badge>
) : null}
{data.is_binding ? (
<Badge
variant="outline"
className="text-[0.7rem] bg-success-bg text-success border-transparent"
>
הלכה מחייבת
</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>
) : null}
</div>
{/* 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 ? (
<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 ? (
<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 ? (
<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 ? (
<MetaItem label="סיווג-מחייבות">
<Badge variant="outline" className="text-[0.72rem] bg-success-bg text-success border-transparent rounded-full px-3">
מחייב
</Badge>
</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.subject_tags?.length ? (
<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] bg-surface">
{t}
</Badge>
))}
</div>
) : null}
</div>
{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}
{/* ── 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}
{(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.headnote ? (
<DetailCard title="כותרת-הלכה (headnote)" prov="opus">
<p className="text-ink-soft text-sm leading-8 m-0">{data.headnote}</p>
</DetailCard>
) : null}
{data.subject_tags?.length ? (
<div className="flex items-center gap-1 flex-wrap pt-1">
{data.subject_tags.map((t) => (
<Badge key={t} variant="outline" className="text-[0.65rem]">
{t}
</Badge>
))}
</div>
) : null}
</CardContent>
</Card>
{(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}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<RelatedCasesSection
caseId={id}
related={data.related_cases ?? []}
/>
</CardContent>
</Card>
<div className="rounded-lg border border-rule bg-surface shadow-sm px-5 py-4">
<ExtractedHalachotSection halachot={data.halachot ?? []} />
</div>
</div>
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<ExtractedHalachotSection halachot={data.halachot ?? []} />
</CardContent>
</Card>
</>
)}
{/* 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>
<PrecedentEditSheet
caseLawId={editing ? id : null}
onOpenChange={(open) => setEditing(open)}
/>
</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>
<section className="space-y-6">
<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-ink-muted text-sm mt-1 max-w-3xl">
פסיקה חיצונית פסקי דין של ערכאות עליונות והחלטות של ועדות ערר אחרות.
כל קובץ עובר חילוץ הלכות אוטומטי, וההלכות ממתינות לאישור היו&quot;ר לפני
שהן זמינות לסוכני הכתיבה (legal-writer וכו&apos;).
</p>
</header>
<Tabs defaultValue="library" dir="rtl">
<section className="space-y-6">
<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 leading-relaxed">
קורפוס הפסיקה והלכות המערכת חיפוש סמנטי, תור-אישור והשלמת
פסיקה חסרה. כל קובץ עובר חילוץ הלכות אוטומטי, וההלכות ממתינות
לאישור היו&quot;ר לפני שהן זמינות לסוכני הכתיבה.
</p>
</div>
<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 />
{/* 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>
<TabsTrigger value="stats">סטטיסטיקה</TabsTrigger>
</TabsList>
))}
</TabsList>
</header>
<TabsContent value="library" className="mt-5">
<LibraryListPanel />
</TabsContent>
<TabsContent value="library" className="mt-0">
<LibraryListPanel />
</TabsContent>
<TabsContent value="search" className="mt-5">
<LibrarySearchPanel />
</TabsContent>
<TabsContent value="search" className="mt-0">
<LibrarySearchPanel />
</TabsContent>
<TabsContent value="review" className="mt-5">
<HalachaReviewPanel />
</TabsContent>
<TabsContent value="review" className="mt-0">
<HalachaReviewPanel />
</TabsContent>
<TabsContent value="stats" className="mt-5">
<LibraryStatsPanel />
</TabsContent>
</Tabs>
</CardContent>
</Card>
</section>
{/* "פסיקה נכנסת" — 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>
</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>
<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>{" "}
שם, סוג, תפקיד ותזמון. מקור-האמת הוא{" "}
<code className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem]">
scripts/SCRIPTS.md
</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>
<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 max-w-2xl">
סקריפטי-תחזוקה ותפעול. מקור-האמת הוא{" "}
<code className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem]">
scripts/SCRIPTS.md
</code>{" "}
עריכה דרך git, לא מכאן.
</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">
{isLoading ? (
<p className="text-sm text-ink-muted">טוען קטלוג</p>
) : isError ? (
<p className="text-sm text-danger">
שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"}
{isLoading ? (
<Card className="bg-surface border-rule px-6 py-5 text-sm text-ink-muted">
טוען קטלוג
</Card>
) : isError ? (
<Card className="bg-danger-bg border-danger/40 px-6 py-5 text-sm text-danger">
שגיאה בטעינת הקטלוג: {(error as Error)?.message ?? "לא ידוע"}
</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="px-5 py-3 border-t border-rule text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}
</p>
) : data ? (
<>
<Markdown content={data.content} />
{lastModified ? (
<p className="mt-6 pt-3 border-t border-rule text-xs text-ink-muted">
עודכן לאחרונה: {lastModified}
</p>
) : null}
</>
) : null}
</CardContent>
</Card>
</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="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-medium text-navy" dir="ltr">
{spec.key}
</code>
<Badge variant="outline" className="text-[0.7rem]">
{spec.type}
</Badge>
{spec.is_secret && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40 gap-1">
<Lock className="w-3 h-3" />
secret
</Badge>
)}
<DriftBadge drift={spec.drift} coolifyAvailable={coolifyAvailable} />
{spec.has_duplicates && (
<Badge variant="outline" className="text-[0.7rem] text-warn border-warn/40">
duplicates
</Badge>
)}
</div>
<p className="text-sm text-ink-muted mt-1">{spec.description}</p>
</div>
<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-[0.84rem] font-semibold text-navy" dir="ltr">
{spec.key}
</code>
<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}
</span>
{spec.is_secret && (
<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
</span>
)}
<DriftBadge drift={spec.drift} coolifyAvailable={coolifyAvailable} />
{spec.has_duplicates && (
<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
</span>
)}
</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"
<a
href={coolifyEnvUrl}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[0.81rem] text-gold-deep hover:underline flex items-center gap-1"
dir="ltr"
>
ממתין ל-redeploy
</Badge>
{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>
)}
</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"
>
ערוך ב-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>
);

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,28 +112,41 @@ 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">
{CATEGORY_LABELS[cat]}
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{vars.length}
</Badge>
</h2>
<div className="space-y-3">
{vars.map((v) => (
<EnvVarRow
key={v.key}
spec={v}
coolifyAppUuid={data.coolify_app_uuid}
coolifyAvailable={coolifyAvailable}
onPendingRedeploy={() => setPendingRedeploy(true)}
/>
))}
<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]}
<span className="text-[0.72rem] text-ink-muted font-medium tabular-nums">
({vars.length})
</span>
</h2>
<p className="text-[0.78rem] text-ink-muted mt-0.5">
מקור-האמת הוא Coolify. שינוי נכנס לתוקף רק לאחר redeploy של הקונטיינר.
</p>
</div>
</CardContent>
</Card>
{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}
spec={v}
coolifyAppUuid={data.coolify_app_uuid}
coolifyAvailable={coolifyAvailable}
onPendingRedeploy={() => setPendingRedeploy(true)}
/>
))}
</div>
);
})}
</div>

View File

@@ -11,60 +11,92 @@ 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>
<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">
תצורת המערכת, MCP server, ו-Paperclip integration.
</p>
<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">
בית
</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">
תצורת הפלטפורמה סוכנים, סביבה, כלים ובלוקים.
</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" />
רישומים
</TabsTrigger>
<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>
<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>
<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>
);
}
@@ -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">
{error.message}
</CardContent>
<Card className="bg-danger-bg border-danger/40 px-6 py-6 text-center text-danger">
{error.message}
</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">
<div className="text-gold text-3xl mb-2" aria-hidden></div>
אין skills מותקנים
</CardContent>
<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 מותקנים
</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