Full code review of the case-detail page (14 components) surfaced these,
all fixed here:
Logic bugs
- case-edit-dialog: form.reset ran on the 5s useCase refetch while the dialog
was open, clobbering in-progress edits. Now resets only on open→true.
- status-changer: `selected` never synced to async/external `currentStatus`
(stale dropdown). Reworked to track currentStatus until an explicit pick;
resets to tracking after save.
- decision-blocks-panel: `block.content`/`word_count` accessed without null
guards (endpoint has no response model) → potential render crash. Coerced
with `?? ""` / `?? 0`. `STATUS_LABELS[status]` now falls back to the raw
status instead of rendering literal "undefined".
- document-type-editor: `await mutateAsync()` in async click handlers without
try/catch → unhandled promise rejection. Wrapped (errors still surface via
isError).
Resilience / hygiene
- page.tsx: a transient 5xx on the background poll flipped the WHOLE page to
the error card and discarded loaded data. Now gated on `!data`, plus a
"נסה שוב" retry.
- cases.ts useUpdateCase: invalidated casesKeys.all, which re-invalidated the
detail it had just optimistically patched. Scoped to the list prefix.
RTL correctness (logical properties)
- agent-status-widget `mr-auto`→`ms-auto`; agent-activity-feed `mr-auto`→
`me-auto`, icon `ml-1/ml-2`→`me-1/me-2`, required `*` `mr-1`→`ms-1`;
document-type-editor list `pr-4`→`ps-4`.
Minors
- drafts-panel: `<a href>`→`next/link` (operations + citation links) for SPA
nav. agent-activity-feed: issueMap memoized; comment Textarea aria-label.
upload-sheet: `unknown` status no longer shown as green success (neutral
icon + "רענן לאישור"). citations.ts: case_name typed `string | null`.
Design gate: visual-touching items (RTL gap side, retry button, neutral
upload icon) were chair-authorized via the reviewed-findings approval ("fix
all"); none alter an approved page layout — they are correctness fixes.
tsc clean; eslint clean (1 pre-existing form.watch warning, untouched line).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
8.5 KiB
TypeScript
213 lines
8.5 KiB
TypeScript
"use client";
|
||
|
||
import { use } 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 { 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 { WorkflowTimeline } from "@/components/cases/workflow-timeline";
|
||
import { StatusGuide } from "@/components/cases/status-guide";
|
||
import { StatusChanger } from "@/components/cases/status-changer";
|
||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||
import { DecisionBlocksPanel } from "@/components/cases/decision-blocks-panel";
|
||
import { LegalArgumentsPanel } from "@/components/cases/legal-arguments-panel";
|
||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||
import { AgentActivityPreview } from "@/components/cases/agent-activity-preview";
|
||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||
import { toast } from "sonner";
|
||
import { Play, Loader2 } from "lucide-react";
|
||
|
||
const EXPECTED_OUTCOME_LABELS: Record<string, string> = Object.fromEntries(
|
||
expectedOutcomes.map((o) => [o.value, o.label]),
|
||
);
|
||
|
||
/*
|
||
* 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 { data, isPending, error, refetch } = useCase(caseNumber);
|
||
const startWorkflow = useStartWorkflow(caseNumber);
|
||
const canStartWorkflow = data?.status === "new" || data?.status === "documents_ready";
|
||
const expectedOutcomeLabel = data?.expected_outcome
|
||
? EXPECTED_OUTCOME_LABELS[data.expected_outcome] ?? data.expected_outcome
|
||
: null;
|
||
|
||
// 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>
|
||
);
|
||
}
|
||
|
||
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} />
|
||
<Button asChild className="bg-gold text-white hover:bg-gold-deep border-transparent">
|
||
<Link href={`/cases/${caseNumber}/compose`}>פתח עורך החלטה</Link>
|
||
</Button>
|
||
{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 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>
|
||
) : (
|
||
<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">
|
||
<DocumentsPanel data={data} />
|
||
|
||
<AgentActivityPreview caseNumber={caseNumber} />
|
||
{/* decision-editor CTA moved to the band actions (visible on all tabs) */}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="arguments" className="mt-0">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<LegalArgumentsPanel caseNumber={caseNumber} />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="decision" className="mt-0">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<DecisionBlocksPanel caseNumber={caseNumber} />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="drafts" className="mt-0">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<DraftsPanel caseNumber={caseNumber} status={data?.status} />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="agents" className="mt-0">
|
||
<Card className="bg-surface border-rule shadow-sm">
|
||
<CardContent className="px-6 py-5">
|
||
<AgentActivityFeed caseNumber={caseNumber} />
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
</div>
|
||
|
||
{/* rail — status timeline + status controls (mockup .rail) */}
|
||
<div className="space-y-5">
|
||
<Card className="bg-surface border-rule shadow-sm overflow-hidden p-0 h-fit">
|
||
<div className="px-5 py-3.5 border-b border-rule-soft bg-parchment text-[0.92rem] font-semibold text-navy">
|
||
סטטוס התיק
|
||
</div>
|
||
<CardContent className="px-5 py-4 space-y-4">
|
||
<AgentStatusWidget caseNumber={caseNumber} />
|
||
<WorkflowTimeline status={data?.status} />
|
||
{expectedOutcomeLabel && (
|
||
<div className="border-t border-rule-soft pt-3">
|
||
<dl className="flex justify-between items-center text-sm">
|
||
<dt className="text-ink-muted">תוצאה צפויה</dt>
|
||
<dd className="rounded-full bg-warn-bg text-warn text-[0.75rem] font-semibold px-3 py-0.5">
|
||
{expectedOutcomeLabel}
|
||
</dd>
|
||
</dl>
|
||
</div>
|
||
)}
|
||
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
|
||
<StatusGuide />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</Tabs>
|
||
</AppShell>
|
||
);
|
||
}
|