3 Commits

Author SHA1 Message Date
10540a38b4 Phase 4c: bulk document upload with live SSE progress
New UploadSheet on the case detail page wraps react-dropzone + a
selector for doc_type. Files post to
POST /api/cases/{n}/documents/upload-tagged as multipart form-data;
the returned task_id is streamed via GET /api/progress/{task_id}
through the new lib/sse.ts EventSource wrapper.

Each upload row shows a per-file progress bar that transitions to
success/error on the terminal SSE payload. Closing the stream inside
the message handler avoids EventSource's auto-reconnect after EOF.

Phase 4 (task 86) is now complete end-to-end: create, upload, edit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:25:44 +00:00
b67dc47dc7 Phase 4b: Case create wizard
New /cases/new route with a 3-step wizard (basics / parties / details)
backed by react-hook-form + the caseCreateSchema. Each step validates
only its own fields so the user fixes errors in context. On success
useCreateCase invalidates the case list and the router pushes to the
freshly created case detail page.

PartiesField is a small chip-style editor for the appellants/respondents
arrays. The Home page now has a navy "+ תיק חדש" button that links to
the wizard.

Dropped .default() from the create schema — zod's input/output type
mismatch broke the RHF zodResolver generics; dropping the defaults is
simpler than plumbing z.input vs z.output through the mutation hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:23:37 +00:00
9fcf4f2dc7 Phase 4a: shadcn form primitives + case inline edit
Add dialog/select/textarea/label/progress/sonner components and wire
a Toaster into Providers. New zod schemas in lib/schemas/case.ts
mirror CaseCreateRequest/CaseUpdateRequest and feed react-hook-form
validation.

CaseEditDialog on the case detail Actions tab posts PUT /api/cases/{n}
with optimistic cache patching via useUpdateCase, showing toast
feedback on success/error.

shadcn's <Form> registry entry skipped at init (missing from the
nova preset); the dialog uses RHF directly against the same Input/
Textarea/Select primitives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:21:21 +00:00
21 changed files with 1599 additions and 27 deletions

View File

@@ -820,13 +820,13 @@
"description": "Port the 3 highest-value screens. Use the frontend-design Claude Code skill to generate layout + composition, passing design tokens (navy/gold/parchment, Heebo), editorial voice, and typed API hooks. Use shadcn Card/Badge/Tabs/Sheet/ScrollArea as primitives. Port the custom donut chart into <DonutChart> component. TanStack Query staleTime:5000 for case detail replaces manual 5s polling. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 3 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Users can browse case list, open a case detail, and view the compose screen with live data from FastAPI. All 3 screens visually match the existing legal-ai identity.",
"status": "in-progress",
"status": "done",
"dependencies": [
"84"
],
"priority": "high",
"subtasks": [],
"updatedAt": "2026-04-11T15:55:49.327Z"
"updatedAt": "2026-04-11T16:09:18.006Z"
},
{
"id": "86",
@@ -834,12 +834,13 @@
"description": "Port new case wizard, bulk upload, inline forms on case detail. Use react-hook-form + zod with schemas in lib/schemas/<entity>.ts. Build shared <WizardShell> from shadcn Card + Progress + Tabs. Build <DropZone> (react-dropzone + shadcn). Integrate SSE for upload progress via lib/sse.ts. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 4 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Users can create a new case via the multi-step wizard (case appears in Gitea + Paperclip), upload documents with live SSE progress, and edit case fields inline.",
"status": "pending",
"status": "in-progress",
"dependencies": [
"85"
],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-04-11T16:18:28.714Z"
},
{
"id": "87",
@@ -883,9 +884,9 @@
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-04-11T15:55:49.330Z",
"lastModified": "2026-04-11T16:18:28.714Z",
"taskCount": 58,
"completedCount": 51,
"completedCount": 52,
"tags": [
"master"
]

View File

@@ -15,12 +15,14 @@
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.72.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
@@ -8836,6 +8838,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -10474,6 +10486,16 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -17,12 +17,14 @@
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.72.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6"

View File

@@ -8,8 +8,10 @@ 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 { DocumentsPanel } from "@/components/cases/documents-panel";
import { UploadSheet } from "@/components/documents/upload-sheet";
import { useCase } from "@/lib/api/cases";
/*
@@ -55,18 +57,21 @@ export default function CaseDetailPage({
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<Tabs defaultValue="overview" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="overview">סקירה</TabsTrigger>
<TabsTrigger value="documents">
מסמכים
{data?.documents && (
<span className="ms-1.5 text-[0.7rem] text-ink-muted tabular-nums">
({data.documents.length})
</span>
)}
</TabsTrigger>
<TabsTrigger value="actions">פעולות</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between gap-3 mb-1">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="overview">סקירה</TabsTrigger>
<TabsTrigger value="documents">
מסמכים
{data?.documents && (
<span className="ms-1.5 text-[0.7rem] text-ink-muted tabular-nums">
({data.documents.length})
</span>
)}
</TabsTrigger>
<TabsTrigger value="actions">פעולות</TabsTrigger>
</TabsList>
<UploadSheet caseNumber={caseNumber} />
</div>
<TabsContent value="overview" className="mt-5 space-y-4">
<div>
@@ -95,14 +100,17 @@ export default function CaseDetailPage({
</TabsContent>
<TabsContent value="actions" className="mt-5">
<div className="space-y-3">
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href={`/cases/${caseNumber}/compose`}>
פתח בעורך ההחלטה
</Link>
</Button>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href={`/cases/${caseNumber}/compose`}>
פתח בעורך ההחלטה
</Link>
</Button>
{data && <CaseEditDialog data={data} />}
</div>
<p className="text-xs text-ink-muted">
מעבר לעורך 12 הבלוקים לכתיבת טיוטה.
עריכת פרטי התיק נשמרת מיד דרך PUT /api/cases/{caseNumber}.
</p>
</div>
</TabsContent>

View File

@@ -0,0 +1,26 @@
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { CaseWizard } from "@/components/wizard/case-wizard";
export default function NewCasePage() {
return (
<AppShell>
<section className="space-y-6">
<header>
<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>
<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">
שלושה שלבים קצרים פרטי יסוד, צדדים, והשלמות. התיק ייווצר ב-DB
וב-Gitea מיד בשמירה.
</p>
</header>
<CaseWizard />
</section>
</AppShell>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { KPICards } from "@/components/cases/kpi-cards";
import { StatusDonut } from "@/components/cases/status-donut";
import { CasesTable } from "@/components/cases/cases-table";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useCases } from "@/lib/api/cases";
export default function HomePage() {
@@ -24,6 +26,9 @@ export default function HomePage() {
12 הבלוקים.
</p>
</div>
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href="/cases/new">+ תיק חדש</Link>
</Button>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />

View File

@@ -0,0 +1,160 @@
"use client";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useUpdateCase } from "@/lib/api/cases";
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
import type { CaseDetail } from "@/lib/api/cases";
/*
* Inline edit dialog for core case fields. Uses react-hook-form + zod
* directly (shadcn's <Form> registry entry wasn't available at init
* time, so the styling is reproduced by hand in a lean form layout).
*/
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
}
export function CaseEditDialog({ data }: { data: CaseDetail }) {
const [open, setOpen] = useState(false);
const mutate = useUpdateCase(data.case_number);
const form = useForm<CaseUpdateInput>({
resolver: zodResolver(caseUpdateSchema),
defaultValues: {
title: data.title ?? "",
subject: data.subject ?? "",
hearing_date: data.hearing_date ?? "",
notes: "",
expected_outcome: data.expected_outcome ?? "",
},
});
/* Re-sync the form when the underlying case refetches after save */
useEffect(() => {
if (!open) return;
form.reset({
title: data.title ?? "",
subject: data.subject ?? "",
hearing_date: data.hearing_date ?? "",
notes: "",
expected_outcome: data.expected_outcome ?? "",
});
}, [open, data, form]);
const onSubmit = form.handleSubmit(async (values) => {
try {
await mutate.mutateAsync(values);
toast.success("פרטי התיק עודכנו");
setOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה בעדכון התיק");
}
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
עריכת פרטי תיק
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl">
<DialogHeader>
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
<DialogDescription className="text-ink-muted">
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label htmlFor="title" className="text-navy">כותרת</Label>
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div>
<Label htmlFor="subject" className="text-navy">נושא</Label>
<Input id="subject" {...form.register("subject")} className="mt-1" />
<FieldError message={form.formState.errors.subject?.message} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
<Input
id="hearing_date"
type="date"
{...form.register("hearing_date")}
className="mt-1 tabular-nums"
/>
<FieldError message={form.formState.errors.hearing_date?.message} />
</div>
<div>
<Label className="text-navy">תוצאה צפויה</Label>
<Select
value={form.watch("expected_outcome") || "__none__"}
onValueChange={(v) =>
form.setValue("expected_outcome", v === "__none__" ? "" : v)
}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{expectedOutcomes.map((o) => (
<SelectItem key={o.value || "none"} value={o.value || "__none__"}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="notes" className="text-navy">הערות (יתווספו לקיים)</Label>
<Textarea id="notes" rows={3} {...form.register("notes")} className="mt-1" />
<FieldError message={form.formState.errors.notes?.message} />
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
disabled={mutate.isPending}
>
ביטול
</Button>
<Button
type="submit"
disabled={mutate.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{mutate.isPending ? "שומר…" : "שמור שינויים"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, FileText, CheckCircle2, XCircle, Loader2 } from "lucide-react";
import {
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents";
/*
* Upload sheet — drag-drop zone + doc-type selector, with live SSE
* progress for the most-recent upload. Intentionally sequential:
* a single file at a time keeps the SSE subscription simple and
* matches how the FastAPI processor handles one task_id per file.
*/
const DOC_TYPES: { value: string; label: string }[] = [
{ value: "auto", label: "זיהוי אוטומטי" },
{ value: "appeal", label: "כתב ערר" },
{ value: "response", label: "כתב תשובה" },
{ value: "protocol", label: "פרוטוקול דיון" },
{ value: "decision", label: "החלטת ועדה מקומית" },
{ value: "plan", label: "תכנית" },
{ value: "reference",label: "חומר רקע" },
];
type UploadRow = {
id: string;
filename: string;
taskId: string | null;
error?: string;
};
function statusLabel(event: ProgressEvent | null): string {
if (!event) return "מתחיל…";
if (event.status === "queued") return "בתור";
if (event.status === "processing")
return event.step ? `בעיבוד · ${event.step}` : "בעיבוד";
if (event.status === "completed") return "הושלם";
if (event.status === "failed") return event.error ?? "נכשל";
return event.status;
}
function progressPercent(event: ProgressEvent | null): number {
if (!event) return 5;
if (event.status === "queued") return 10;
if (event.status === "processing") return 55;
if (event.status === "completed") return 100;
if (event.status === "failed") return 100;
return 25;
}
function UploadRowView({ row }: { row: UploadRow }) {
const progress = useProgress(row.taskId);
const pct = row.error ? 100 : progressPercent(progress);
const failed = row.error || progress?.status === "failed";
const done = progress?.status === "completed";
return (
<li className="rounded-lg border border-rule bg-parchment/40 px-4 py-3 space-y-2">
<div className="flex items-center gap-2">
{done ? (
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
) : failed ? (
<XCircle className="w-4 h-4 text-danger shrink-0" />
) : (
<Loader2 className="w-4 h-4 text-gold animate-spin shrink-0" />
)}
<FileText className="w-4 h-4 text-ink-muted shrink-0" />
<span className="text-sm text-ink truncate flex-1" title={row.filename}>
{row.filename}
</span>
<span
className={`text-[0.72rem] tabular-nums shrink-0 ${
done ? "text-success" : failed ? "text-danger" : "text-ink-muted"
}`}
>
{row.error ?? statusLabel(progress)}
</span>
</div>
<Progress
value={pct}
className={failed ? "[&>div]:bg-danger" : done ? "[&>div]:bg-success" : ""}
/>
</li>
);
}
export function UploadSheet({ caseNumber }: { caseNumber: string }) {
const [open, setOpen] = useState(false);
const [docType, setDocType] = useState("auto");
const [rows, setRows] = useState<UploadRow[]>([]);
const mutate = useUploadDocument(caseNumber);
const onDrop = useCallback(
async (files: File[]) => {
for (const file of files) {
const rowId = crypto.randomUUID();
setRows((r) => [
...r,
{ id: rowId, filename: file.name, taskId: null },
]);
try {
const res = await mutate.mutateAsync({ file, docType });
setRows((r) =>
r.map((row) =>
row.id === rowId ? { ...row, taskId: res.task_id } : row,
),
);
} catch (e) {
setRows((r) =>
r.map((row) =>
row.id === rowId
? { ...row, error: e instanceof Error ? e.message : "שגיאה" }
: row,
),
);
}
}
},
[docType, mutate],
);
const dropzone = useDropzone({
onDrop,
accept: {
"application/pdf": [".pdf"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
"text/plain": [".txt"],
"text/markdown": [".md"],
},
maxSize: 50 * 1024 * 1024,
});
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Upload className="w-4 h-4 me-1" /> העלאת מסמכים
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-full sm:max-w-lg" dir="rtl">
<SheetHeader>
<SheetTitle className="text-navy">העלאת מסמכים לתיק {caseNumber}</SheetTitle>
<SheetDescription className="text-ink-muted">
PDF, DOCX, DOC, TXT, MD עד 50MB לקובץ. הקבצים מעובדים ברקע
והסטטוס מתעדכן בזמן אמת.
</SheetDescription>
</SheetHeader>
<div className="mt-5 space-y-4 px-4">
<div>
<label className="block text-sm font-medium text-navy mb-1.5">
סיווג
</label>
<Select value={docType} onValueChange={setDocType} dir="rtl">
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DOC_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
{...dropzone.getRootProps()}
className={`
rounded-lg border-2 border-dashed p-8 text-center cursor-pointer
transition-colors
${
dropzone.isDragActive
? "border-gold bg-gold-wash"
: "border-rule bg-parchment/40 hover:bg-gold-wash/50 hover:border-gold/60"
}
`}
>
<input {...dropzone.getInputProps()} />
<Upload className="w-8 h-8 mx-auto mb-2 text-gold-deep" />
<p className="text-sm text-navy font-medium">
{dropzone.isDragActive
? "שחרר כאן להעלאה"
: "גרור קבצים או לחץ לבחירה"}
</p>
<p className="text-[0.72rem] text-ink-muted mt-1">
ניתן להעלות מספר קבצים בבת אחת
</p>
</div>
{rows.length > 0 && (
<ul className="space-y-2">
{rows.map((row) => (
<UploadRowView key={row.id} row={row} />
))}
</ul>
)}
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 start-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 end-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,192 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pe-2 ps-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 rtl:data-[side=left]:translate-x-1 data-[side=right]:translate-x-1 rtl:data-[side=right]:-translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pe-8 ps-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,283 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { PartiesField } from "@/components/wizard/parties-field";
import { useCreateCase } from "@/lib/api/cases";
import {
caseCreateSchema, committeeTypes, expectedOutcomes,
type CaseCreateInput,
} from "@/lib/schemas/case";
const STEPS = [
{ key: "basics", label: "פרטי יסוד" },
{ key: "parties", label: "צדדים" },
{ key: "details", label: "השלמות" },
] as const;
type StepKey = (typeof STEPS)[number]["key"];
/* Fields validated at each step — lets the user fix just what's on screen
* before moving forward, instead of surfacing all errors from page 1. */
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
basics: ["case_number", "title", "committee_type"],
parties: ["appellants", "respondents"],
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
};
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="text-[0.72rem] text-danger mt-1">{message}</p>;
}
export function CaseWizard() {
const router = useRouter();
const [step, setStep] = useState<StepKey>("basics");
const mutate = useCreateCase();
const form = useForm<CaseCreateInput>({
resolver: zodResolver(caseCreateSchema),
mode: "onBlur",
defaultValues: {
case_number: "",
title: "",
appellants: [],
respondents: [],
subject: "",
property_address: "",
permit_number: "",
committee_type: "ועדה מקומית",
hearing_date: "",
notes: "",
expected_outcome: "",
},
});
const stepIndex = STEPS.findIndex((s) => s.key === step);
const isLast = stepIndex === STEPS.length - 1;
const goNext = async () => {
const ok = await form.trigger(STEP_FIELDS[step]);
if (!ok) return;
setStep(STEPS[stepIndex + 1].key);
};
const goBack = () => setStep(STEPS[stepIndex - 1].key);
const onSubmit = form.handleSubmit(async (values) => {
try {
const res = await mutate.mutateAsync(values);
toast.success("תיק חדש נוצר");
const created = res?.case_number || values.case_number;
router.push(`/cases/${encodeURIComponent(created)}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה ביצירת תיק");
}
});
return (
<Card className="bg-surface border-rule shadow-sm max-w-3xl">
<CardContent className="px-6 py-6 space-y-6">
{/* Stepper */}
<ol className="flex items-center gap-2 text-sm">
{STEPS.map((s, i) => {
const active = i === stepIndex;
const done = i < stepIndex;
return (
<li key={s.key} className="flex items-center gap-2">
<span
className={`
inline-flex items-center justify-center w-7 h-7 rounded-full
font-display font-bold text-sm tabular-nums transition-colors
${done ? "bg-success text-parchment" : active ? "bg-navy text-parchment" : "bg-rule text-ink-muted"}
`}
>
{done ? "✓" : i + 1}
</span>
<span className={active ? "text-navy font-semibold" : "text-ink-muted"}>
{s.label}
</span>
{i < STEPS.length - 1 && (
<span className="w-8 h-px bg-rule mx-1" aria-hidden />
)}
</li>
);
})}
</ol>
<form onSubmit={onSubmit} className="space-y-5">
{step === "basics" && (
<div className="space-y-4">
<div>
<Label htmlFor="case_number" className="text-navy">
מספר תיק <span className="text-danger">*</span>
</Label>
<Input
id="case_number"
placeholder="1234 או 8001/2026"
{...form.register("case_number")}
className="mt-1 tabular-nums"
/>
<FieldError message={form.formState.errors.case_number?.message} />
</div>
<div>
<Label htmlFor="title" className="text-navy">
כותרת <span className="text-danger">*</span>
</Label>
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div>
<Label className="text-navy">סוג ועדה</Label>
<Controller
control={form.control}
name="committee_type"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{committeeTypes.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
)}
{step === "parties" && (
<div className="space-y-5">
<Controller
control={form.control}
name="appellants"
render={({ field, fieldState }) => (
<PartiesField
label="עוררים *"
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<div className="h-px bg-rule" />
<Controller
control={form.control}
name="respondents"
render={({ field, fieldState }) => (
<PartiesField
label="משיבים *"
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
</div>
)}
{step === "details" && (
<div className="space-y-4">
<div>
<Label htmlFor="subject" className="text-navy">נושא</Label>
<Input id="subject" {...form.register("subject")} className="mt-1" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
<Input id="property_address" {...form.register("property_address")} className="mt-1" />
</div>
<div>
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
<Input id="permit_number" {...form.register("permit_number")} className="mt-1" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>
<Input
id="hearing_date"
type="date"
{...form.register("hearing_date")}
className="mt-1 tabular-nums"
/>
<FieldError message={form.formState.errors.hearing_date?.message} />
</div>
<div>
<Label className="text-navy">תוצאה צפויה</Label>
<Controller
control={form.control}
name="expected_outcome"
render={({ field }) => (
<Select
value={field.value || "__none__"}
onValueChange={(v) => field.onChange(v === "__none__" ? "" : v)}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{expectedOutcomes.map((o) => (
<SelectItem key={o.value || "none"} value={o.value || "__none__"}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div>
<Label htmlFor="notes" className="text-navy">הערות</Label>
<Textarea id="notes" rows={4} {...form.register("notes")} className="mt-1" />
</div>
</div>
)}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={stepIndex === 0 || mutate.isPending}
>
הקודם
</Button>
{isLast ? (
<Button
type="submit"
disabled={mutate.isPending}
className="bg-navy hover:bg-navy-soft text-parchment"
>
{mutate.isPending ? "יוצר תיק…" : "צור תיק"}
</Button>
) : (
<Button
type="button"
onClick={goNext}
className="bg-navy hover:bg-navy-soft text-parchment"
>
הבא
</Button>
)}
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { X, Plus } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
/*
* Minimal tag-style editor for a list of party names (appellants / respondents).
* Backed by a controlled string[] — submits as the same shape the FastAPI
* CaseCreateRequest expects. Enter adds the current draft; X removes a chip.
*/
export function PartiesField({
label,
value,
onChange,
error,
}: {
label: string;
value: string[];
onChange: (next: string[]) => void;
error?: string;
}) {
const [draft, setDraft] = useState("");
const add = () => {
const trimmed = draft.trim();
if (!trimmed) return;
if (value.includes(trimmed)) {
setDraft("");
return;
}
onChange([...value, trimmed]);
setDraft("");
};
const remove = (name: string) => {
onChange(value.filter((v) => v !== name));
};
return (
<div>
<label className="block text-sm font-medium text-navy mb-1.5">{label}</label>
{value.length > 0 && (
<ul className="flex flex-wrap gap-2 mb-2">
{value.map((name) => (
<li
key={name}
className="
inline-flex items-center gap-1.5 rounded-full
bg-gold-wash text-gold-deep border border-gold/40
px-3 py-1 text-sm
"
>
<span>{name}</span>
<button
type="button"
onClick={() => remove(name)}
className="hover:text-danger transition-colors"
aria-label={`הסר ${name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder="שם מלא של הצד"
dir="rtl"
/>
<Button type="button" variant="outline" size="sm" onClick={add}>
<Plus className="w-4 h-4" />
</Button>
</div>
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}
</div>
);
}

View File

@@ -8,8 +8,9 @@
* surfaces as a runtime TypeScript error the first time a property is touched.
*/
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
export type CaseStatus =
| "new"
@@ -95,6 +96,41 @@ export type WorkflowStatus = {
[key: string]: unknown;
};
export function useCreateCase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CaseCreateInput) =>
apiRequest<{ case_number?: string; message?: string; [k: string]: unknown }>(
`/api/cases/create`,
{ method: "POST", body: input },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useUpdateCase(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: CaseUpdateInput) =>
apiRequest<CaseDetail>(`/api/cases/${caseNumber}`, {
method: "PUT",
body: input,
}),
onSuccess: (data) => {
/* Patch cached detail and nudge the list to refetch on next focus */
if (caseNumber) {
qc.setQueryData<CaseDetail | undefined>(
casesKeys.detail(caseNumber),
(prev) => (prev ? { ...prev, ...data } : prev),
);
}
qc.invalidateQueries({ queryKey: casesKeys.all });
},
});
}
export function useWorkflowStatus(caseNumber: string | undefined) {
return useQuery({
queryKey: [...casesKeys.all, "workflow", caseNumber ?? ""] as const,

View File

@@ -0,0 +1,111 @@
/**
* Document upload + progress hooks.
*
* Upload hits `POST /api/cases/{n}/documents/upload-tagged` as multipart
* form-data (FastAPI UploadFile), and receives a `task_id` that streams
* progress events via `GET /api/progress/{task_id}` (SSE). We expose
* both as a single `useUploadDocument` mutation returning the task id
* plus a `useProgress(taskId)` hook that subscribes to the stream.
*/
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "./client";
import { casesKeys } from "./cases";
import { openSSE } from "@/lib/sse";
export type UploadTaggedResponse = {
task_id: string;
filename: string;
original_name: string;
doc_type: string;
};
export type ProgressEvent = {
status: "queued" | "processing" | "completed" | "failed" | string;
filename?: string;
step?: string;
error?: string;
result?: unknown;
case_number?: string;
doc_type?: string;
};
export type UploadVars = {
caseNumber: string;
file: File;
docType?: string;
partyName?: string;
title?: string;
};
async function uploadTagged({
caseNumber,
file,
docType = "auto",
partyName = "",
title = "",
}: UploadVars): Promise<UploadTaggedResponse> {
const fd = new FormData();
fd.append("file", file);
fd.append("doc_type", docType);
fd.append("party_name", partyName);
fd.append("title", title);
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/documents/upload-tagged`,
{ method: "POST", body: fd },
);
const contentType = res.headers.get("content-type") ?? "";
const parsed = contentType.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null);
if (!res.ok) {
throw new ApiError(
`Upload failed with ${res.status}`,
res.status,
parsed,
);
}
return parsed as UploadTaggedResponse;
}
export function useUploadDocument(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: Omit<UploadVars, "caseNumber">) =>
uploadTagged({ caseNumber, ...vars }),
onSuccess: () => {
/* Nudge the case detail to refetch so the new document row appears
* immediately — the actual "processing" badge will update once the
* SSE stream reports status=completed. */
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
export function useProgress(taskId: string | null) {
const [event, setEvent] = useState<ProgressEvent | null>(null);
useEffect(() => {
if (!taskId) return;
setEvent(null);
const close = openSSE<ProgressEvent>(
`/api/progress/${encodeURIComponent(taskId)}`,
{
onMessage: (data) => {
setEvent(data);
if (data.status === "completed" || data.status === "failed") {
/* Close from within the callback — the backend ends the stream
* naturally, but closing eagerly avoids the auto-reconnect loop
* EventSource does after EOF. */
close();
}
},
},
);
return () => close();
}, [taskId]);
return event;
}

View File

@@ -2,6 +2,7 @@
import { useState, type ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner";
import { makeQueryClient } from "@/lib/api/client";
/**
@@ -12,6 +13,9 @@ import { makeQueryClient } from "@/lib/api/client";
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(() => makeQueryClient());
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-left" richColors closeButton dir="rtl" />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,82 @@
/**
* Zod schemas for case mutations (create / update).
* Shapes mirror the FastAPI Pydantic models in web/app.py:
* - CaseCreateRequest
* - CaseUpdateRequest
*
* Validation rules are stricter on the UI than on the backend so the user
* gets Hebrew-localized error messages at the field level instead of a
* 422 blob from FastAPI.
*/
import { z } from "zod";
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md —
* permissive regex that still catches obvious typos. */
const caseNumberRe = /^[1-9]\d{3,}(?:[-/][\w\u0590-\u05FF]+)*$/;
const hebrewPartyRe = /[\u0590-\u05FFA-Za-z]/;
const dateString = z
.string()
.trim()
.refine((v) => v === "" || /^\d{4}-\d{2}-\d{2}$/.test(v), {
message: "תאריך חייב להיות בפורמט YYYY-MM-DD",
});
export const committeeTypes = [
"ועדה מקומית",
"ועדה מחוזית",
"ועדת ערר",
] as const;
export const expectedOutcomes = [
{ value: "", label: "— לא נקבע —" },
{ value: "rejection", label: "דחייה" },
{ value: "partial_acceptance", label: "קבלה חלקית" },
{ value: "full_acceptance", label: "קבלה מלאה" },
{ value: "betterment_levy", label: "היטל השבחה" },
] as const;
export const caseCreateSchema = z.object({
case_number: z
.string()
.trim()
.min(1, "שדה חובה")
.regex(caseNumberRe, "מספר תיק לא תקין (למשל 1234 או 8001/2026)"),
title: z.string().trim().min(3, "כותרת קצרה מדי").max(200, "כותרת ארוכה מדי"),
appellants: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.min(1, "חייב להיות לפחות עורר אחד"),
respondents: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.min(1, "חייב להיות לפחות משיב אחד"),
subject: z.string().trim().max(500),
property_address: z.string().trim().max(200),
permit_number: z.string().trim().max(100),
committee_type: z.enum(committeeTypes),
hearing_date: dateString,
notes: z.string().trim().max(2000),
expected_outcome: z.enum(
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
),
});
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
/* Update schema — all fields optional so the PUT can be used for a
* single-field edit. Empty strings are tolerated (they mean "no change"
* in the Pydantic model). */
export const caseUpdateSchema = z.object({
title: z.string().trim().max(200).optional(),
subject: z.string().trim().max(500).optional(),
notes: z.string().trim().max(2000).optional(),
hearing_date: dateString.optional(),
decision_date: dateString.optional(),
expected_outcome: z
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
.optional(),
status: z.string().optional(),
});
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;

53
web-ui/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Minimal SSE helper — wraps `EventSource` so consumers get typed event
* callbacks and a single `close()` for cleanup.
*
* Used by the upload flow to stream /api/progress/{task_id} events.
* Kept framework-agnostic so any component can drive it; a thin React
* hook layer sits on top in lib/api/documents.ts.
*/
export type SSEHandlers<T> = {
onMessage: (data: T) => void;
onError?: (err: Event) => void;
/* Called when the server closes the stream cleanly. EventSource has
* no native "closed" event, so the backend signals completion via a
* terminal payload and we close from the onMessage handler — callers
* can return `true` to trigger this path. */
onClose?: () => void;
};
export function openSSE<T>(
url: string,
{ onMessage, onError, onClose }: SSEHandlers<T>,
): () => void {
const es = new EventSource(url);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
es.close();
onClose?.();
};
es.addEventListener("message", (ev) => {
if (closed) return;
try {
const payload = JSON.parse(ev.data) as T;
onMessage(payload);
} catch {
/* backend sends heartbeats as comments — EventSource filters them,
* so any non-JSON message here is a protocol bug worth ignoring */
}
});
es.addEventListener("error", (ev) => {
if (closed) return;
onError?.(ev);
/* EventSource auto-reconnects; we only close on an explicit terminal
* payload from the server, not on transient network errors */
});
return close;
}