היו"ר לא יכל לגשת לטיוטה שהסוכן שמר בדיסק (interim-decision-draft.md) אלא ב-SSH.
מימוש מוקאפ X17 מאושר (18m): פאנל דפדפן-קבצים בטאב "טיוטות והערות".
Backend (web/app.py) — הכללה של ה-endpoints הקיימים ל**כל** תת-תיקייה:
- `_case_file_folders` — מונה כל תת-תיקיית-תוכן תחת התיק (מקור/מחולץ/מחקר/הגהה/
טיוטות/גיבויים), רקורסיה שכבה אחת לתוך documents/, מדלג על מוסתרות (.git/.claude)
ו-thumbnails. `_resolve_case_file` מאמת folder-key מול הרשימה + חוסם path-traversal.
- `/local-files` מחזיר את כל התיקיות (כולל ריקות, עם files=[]); `/local-files/{folder}/{filename}`
מגיש מכל תיקייה עם media-type לפי סיומת.
Frontend:
- `lib/api/case-files.ts` — useCaseFiles / useCaseFileText / caseFileUrl.
- `CaseFilesBrowser` — עץ-תיקיות (אקורדיון, ריקות מסומנות "ריק") + viewer מובנה
ל-md/txt (Markdown הקיים) + הורדה לכל קובץ. נטען בטאב "טיוטות והערות".
אין response_model → אין תלות ב-api:types (מודול טיפוסים בכתב-יד).
Invariants: G1, G2 (אין מסלול-קבצים מקביל — מכליל את הקיים), אבטחת-נתיב.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { use, 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 {
|
||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||
} from "@/components/ui/accordion";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { CaseHeader } from "@/components/cases/case-header";
|
||
import { CaseEditDialog } from "@/components/cases/case-edit-dialog";
|
||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||
import { CaseFilesBrowser } from "@/components/cases/case-files-browser";
|
||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||
import { PositionsPanel } from "@/components/cases/positions-panel";
|
||
import { HearingChangesPanel } from "@/components/cases/hearing-changes-panel";
|
||
import { CitationVerificationPanel } from "@/components/compose/citation-verification-panel";
|
||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Play, Loader2, LayoutGrid, Scale, BadgeCheck, FileText, MessageSquare, Users,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
|
||
/*
|
||
* Next 16 breaking change: route params are now a Promise.
|
||
* The `use()` hook unwraps them inside a client component.
|
||
*/
|
||
export default function CaseDetailPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ caseNumber: string }>;
|
||
}) {
|
||
const { caseNumber } = use(params);
|
||
const [tab, setTab] = useState("overview");
|
||
const { data, isPending, error, refetch } = useCase(caseNumber);
|
||
const startWorkflow = useStartWorkflow(caseNumber);
|
||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||
|
||
// Only take over the whole page when there is NO data to show. A transient
|
||
// 5xx on the 5s background refetch must not blow away an already-loaded page.
|
||
if (error && !data) {
|
||
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>
|
||
<p className="text-sm text-ink-muted">{error.message}</p>
|
||
<div className="flex items-center justify-center gap-2">
|
||
<Button variant="outline" onClick={() => refetch()}>
|
||
נסה שוב
|
||
</Button>
|
||
<Button asChild variant="ghost">
|
||
<Link href="/">חזרה לרשימת התיקים</Link>
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</section>
|
||
</AppShell>
|
||
);
|
||
}
|
||
|
||
// Workflow order (X17): intake → arguments+positions → verify citations →
|
||
// write decision → drafts/final → agents (monitoring).
|
||
const tabDefs: [string, string, LucideIcon][] = [
|
||
["overview", "סקירה", LayoutGrid],
|
||
["arguments", "טיעונים ועמדות", Scale],
|
||
["verify", "אימות פסיקה", BadgeCheck],
|
||
["decision", "ההחלטה", FileText],
|
||
["drafts", "טיוטות והערות", MessageSquare],
|
||
["agents", "סוכנים", Users],
|
||
];
|
||
|
||
// V2 "segmented" tab strip (X17): each tab a pill, active = raised white card.
|
||
// Spread full-width via justify-between (chair request) — wide gaps with few
|
||
// tabs, tightening as more are added; gap-2 is the no-overlap floor.
|
||
const tabsList = (
|
||
<TabsList
|
||
variant="line"
|
||
className="!h-auto w-full gap-2 p-0 justify-between"
|
||
>
|
||
{tabDefs.map(([value, label, Icon]) => (
|
||
<TabsTrigger
|
||
key={value}
|
||
value={value}
|
||
className="flex-none gap-1.5 rounded-lg border border-transparent px-3.5 py-1.5 text-[0.9rem] font-medium text-ink-soft after:hidden hover:bg-parchment/60 data-active:bg-surface data-active:text-navy data-active:font-semibold data-active:border-rule data-active:shadow-sm [&_svg]:text-ink-light data-active:[&_svg]:text-gold-deep"
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{label}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
);
|
||
|
||
const bandActions = (
|
||
<>
|
||
{data && <CaseEditDialog data={data} />}
|
||
<UploadSheet caseNumber={caseNumber} />
|
||
{canStartWorkflow && (
|
||
<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>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<AppShell>
|
||
<Tabs value={tab} onValueChange={setTab} 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>
|
||
) : (
|
||
<CaseHeader caseNumber={caseNumber} data={data} actions={bandActions} tabs={tabsList} />
|
||
)}
|
||
|
||
{/* full-width tab content — status now lives in the always-visible band
|
||
StatusRail (banner A, X17), so every tab keeps the status context. */}
|
||
<div className="min-w-0 mt-6">
|
||
<TabsContent value="overview" className="mt-0">
|
||
{/* documents (wider) + agent-activity preview (mockup 18j) */}
|
||
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr] items-start">
|
||
<DocumentsPanel data={data} />
|
||
<AgentActivityPreview caseNumber={caseNumber} />
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* טיעונים ועמדות — chair positions (editing) over the analyst's
|
||
issues, with the aggregated by-party arguments collapsible below
|
||
(merged from the deleted /compose editor, mockup 18f). */}
|
||
<TabsContent value="arguments" className="mt-0 space-y-4">
|
||
{/* עמדות וטענות — collapsed by default (chair preference #226),
|
||
mirroring the two accordions below. */}
|
||
<Accordion type="single" collapsible>
|
||
<AccordionItem
|
||
value="positions"
|
||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||
>
|
||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||
<span className="flex flex-1 flex-col items-start">
|
||
<span className="text-navy text-[0.95rem] font-bold">
|
||
עמדות וטענות
|
||
</span>
|
||
<span className="text-ink-muted text-[0.78rem]">
|
||
סוגיות-המחלוקת ועמדות הצדדים · עורך עמדת-היו״ר מזין את בלוק י׳
|
||
</span>
|
||
</span>
|
||
</AccordionTrigger>
|
||
<AccordionContent className="px-6 pb-5 pt-0">
|
||
<PositionsPanel caseNumber={caseNumber} />
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
<Accordion type="single" collapsible>
|
||
<AccordionItem
|
||
value="byparty"
|
||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||
>
|
||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||
<span className="flex flex-1 flex-col items-start">
|
||
<span className="text-navy text-[0.95rem] font-bold">
|
||
טיעונים מאוגדים לפי צד
|
||
</span>
|
||
<span className="text-ink-muted text-[0.78rem]">
|
||
תצוגה מסונתזת של הטענות הגולמיות, מקובצת לפי צד וקדימות
|
||
</span>
|
||
</span>
|
||
</AccordionTrigger>
|
||
<AccordionContent className="px-6 pb-5 pt-0">
|
||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
{/* מה קרה בדיון — comparative analysis of the hearing protocol vs.
|
||
the written pleadings (#226). Sits below the aggregated arguments
|
||
because it speaks to those same arguments in their oral gloss.
|
||
Collapsed by default, mirroring the "by-party" accordion above. */}
|
||
<Accordion type="single" collapsible>
|
||
<AccordionItem
|
||
value="hearing"
|
||
className="overflow-hidden rounded-xl border border-rule bg-surface shadow-sm"
|
||
>
|
||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||
<span className="flex flex-1 flex-col items-start">
|
||
<span className="text-navy text-[0.95rem] font-bold">
|
||
מה קרה בדיון
|
||
</span>
|
||
<span className="text-ink-muted text-[0.78rem]">
|
||
השוואת הטענות בכתב מול הדיון בעל-פה — מה התחזק ומה נטען לראשונה
|
||
</span>
|
||
</span>
|
||
</AccordionTrigger>
|
||
<AccordionContent className="px-6 pb-5 pt-0">
|
||
<HearingChangesPanel caseNumber={caseNumber} />
|
||
</AccordionContent>
|
||
</AccordionItem>
|
||
</Accordion>
|
||
</TabsContent>
|
||
|
||
{/* אימות פסיקה — per-argument supporting-precedent verify gate (#154),
|
||
relocated from the deleted /compose editor to a top-level tab. */}
|
||
<TabsContent value="verify" className="mt-0">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<CitationVerificationPanel caseNumber={caseNumber} />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="decision" className="mt-0">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="drafts" className="mt-0 space-y-4">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<CaseFilesBrowser caseNumber={caseNumber} />
|
||
</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>
|
||
</Tabs>
|
||
</AppShell>
|
||
);
|
||
}
|