Phase 4.5: practice area integration in the Next.js UI

Backend commit 26d09d6 introduced a multi-tenant axis (practice_area +
appeal_subtype) that the vanilla UI picked up but the new Next.js
rewrite did not. Close the gap in the screens we already shipped so
future search/filter work in Phase 5 has the right data on screen.

- lib/practice-area.ts — new: enum + label maps + deriveSubtype(),
  mirrors mcp-server/src/legal_mcp/services/practice_area.py.
- lib/schemas/case.ts — two new z.enum fields on caseCreateSchema.
- lib/api/cases.ts — Case / CaseDetail gain practice_area and
  appeal_subtype as optional (cached pre-migration responses still
  parse cleanly).
- wizard/case-wizard.tsx — basics step gains a practice_area dropdown
  (future domains disabled with "(בקרוב)") and an appeal_subtype
  dropdown with auto-fill effect tracking a userTouchedSubtype ref,
  same behaviour as wireSubtypeAutofill() in the vanilla UI.
- cases/case-header.tsx — gold badge next to the status shows
  "ועדת ערר · רישוי ובנייה" when both fields are populated.
- cases/cases-table.tsx — new "תחום" column showing subtype label
  (dash for unknown). No filter yet — that's phase 5 when a second
  domain actually exists.

Plan: ~/.claude/plans/woolly-cooking-graham.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 17:15:48 +00:00
parent e483eba1a9
commit ac0a5ee30b
7 changed files with 214 additions and 5 deletions

View File

@@ -1,6 +1,11 @@
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/cases/status-badge";
import {
PRACTICE_AREA_LABELS,
APPEAL_SUBTYPE_LABELS,
} from "@/lib/practice-area";
import type { CaseDetail } from "@/lib/api/cases";
function formatDate(iso?: string | null) {
@@ -30,11 +35,22 @@ export function CaseHeader({ data }: { data?: CaseDetail }) {
<div className="flex items-start justify-between gap-6 flex-wrap">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
ערר {data?.case_number ?? "—"}
</span>
{data?.status && <StatusBadge status={data.status} />}
{data?.practice_area && (
<Badge
variant="outline"
className="rounded-full px-2.5 py-0.5 text-[0.72rem] font-medium bg-gold-wash text-gold-deep border-gold/40"
>
{PRACTICE_AREA_LABELS[data.practice_area]}
{data.appeal_subtype && data.appeal_subtype !== "unknown" && (
<> · {APPEAL_SUBTYPE_LABELS[data.appeal_subtype]}</>
)}
</Badge>
)}
</div>
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
{data?.title ?? "טוען…"}

View File

@@ -17,6 +17,7 @@ import {
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { StatusBadge } from "@/components/cases/status-badge";
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
import type { Case } from "@/lib/api/cases";
function formatDate(iso?: string) {
@@ -59,6 +60,15 @@ const columns: ColumnDef<Case>[] = [
header: "סטטוס",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
accessorKey: "appeal_subtype",
header: "תחום",
cell: ({ row }) => {
const s = row.original.appeal_subtype;
if (!s || s === "unknown") return <span className="text-ink-muted"></span>;
return <span className="text-ink-soft text-sm">{APPEAL_SUBTYPE_LABELS[s]}</span>;
},
},
{
accessorKey: "document_count",
header: "מסמכים",

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -19,6 +19,10 @@ import {
caseCreateSchema, expectedOutcomes,
type CaseCreateInput,
} from "@/lib/schemas/case";
import {
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
type AppealSubtype,
} from "@/lib/practice-area";
const STEPS = [
{ key: "basics", label: "פרטי יסוד" },
@@ -31,7 +35,7 @@ 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"],
basics: ["case_number", "title", "practice_area", "appeal_subtype"],
parties: ["appellants", "respondents"],
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
};
@@ -60,9 +64,28 @@ export function CaseWizard() {
hearing_date: "",
notes: "",
expected_outcome: "",
practice_area: "appeals_committee",
appeal_subtype: "unknown",
},
});
/*
* Auto-fill appeal_subtype from the case number as the user types, but
* stop the moment they manually pick a value from the dropdown. Mirrors
* the wireSubtypeAutofill() behaviour of the vanilla UI
* (legal-ai/web/static/index.html around line 2770).
*/
const userTouchedSubtype = useRef(false);
const caseNumber = form.watch("case_number");
const practiceArea = form.watch("practice_area");
useEffect(() => {
if (userTouchedSubtype.current) return;
const derived = deriveSubtype(caseNumber, practiceArea);
if (derived !== form.getValues("appeal_subtype")) {
form.setValue("appeal_subtype", derived, { shouldValidate: false });
}
}, [caseNumber, practiceArea, form]);
const stepIndex = STEPS.findIndex((s) => s.key === step);
const isLast = stepIndex === STEPS.length - 1;
@@ -136,6 +159,64 @@ export function CaseWizard() {
<Input id="title" {...form.register("title")} className="mt-1" />
<FieldError message={form.formState.errors.title?.message} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-navy">תחום משפטי</Label>
<Controller
control={form.control}
name="practice_area"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange} dir="rtl">
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRACTICE_AREAS.map((p) => (
<SelectItem
key={p.value}
value={p.value}
disabled={!p.enabled}
>
{p.label}{!p.enabled && " (בקרוב)"}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div>
<Label className="text-navy">סוג ערר</Label>
<Controller
control={form.control}
name="appeal_subtype"
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
userTouchedSubtype.current = true;
field.onChange(v as AppealSubtype);
}}
dir="rtl"
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{APPEAL_SUBTYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-[0.7rem] text-ink-muted mt-1">
מזוהה אוטומטית ממספר התיק
</p>
</div>
</div>
</div>
)}