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:
@@ -880,12 +880,26 @@
|
|||||||
],
|
],
|
||||||
"priority": "medium",
|
"priority": "medium",
|
||||||
"subtasks": []
|
"subtasks": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "90",
|
||||||
|
"title": "Phase 4.5 — Practice area integration",
|
||||||
|
"description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md",
|
||||||
|
"details": "",
|
||||||
|
"testStrategy": "",
|
||||||
|
"status": "in-progress",
|
||||||
|
"dependencies": [
|
||||||
|
"86"
|
||||||
|
],
|
||||||
|
"priority": "high",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-04-11T17:13:13.931Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-04-11T16:25:55.570Z",
|
"lastModified": "2026-04-11T17:13:13.932Z",
|
||||||
"taskCount": 58,
|
"taskCount": 59,
|
||||||
"completedCount": 53,
|
"completedCount": 53,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { StatusBadge } from "@/components/cases/status-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";
|
import type { CaseDetail } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string | null) {
|
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="flex items-start justify-between gap-6 flex-wrap">
|
||||||
<div className="space-y-2">
|
<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">
|
<span className="font-display text-[2rem] font-black text-navy leading-none tabular-nums">
|
||||||
ערר {data?.case_number ?? "—"}
|
ערר {data?.case_number ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
{data?.status && <StatusBadge status={data.status} />}
|
{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>
|
</div>
|
||||||
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
<h1 className="text-navy text-xl font-bold leading-snug max-w-2xl mb-0">
|
||||||
{data?.title ?? "טוען…"}
|
{data?.title ?? "טוען…"}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { StatusBadge } from "@/components/cases/status-badge";
|
import { StatusBadge } from "@/components/cases/status-badge";
|
||||||
|
import { APPEAL_SUBTYPE_LABELS } from "@/lib/practice-area";
|
||||||
import type { Case } from "@/lib/api/cases";
|
import type { Case } from "@/lib/api/cases";
|
||||||
|
|
||||||
function formatDate(iso?: string) {
|
function formatDate(iso?: string) {
|
||||||
@@ -59,6 +60,15 @@ const columns: ColumnDef<Case>[] = [
|
|||||||
header: "סטטוס",
|
header: "סטטוס",
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
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",
|
accessorKey: "document_count",
|
||||||
header: "מסמכים",
|
header: "מסמכים",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -19,6 +19,10 @@ import {
|
|||||||
caseCreateSchema, expectedOutcomes,
|
caseCreateSchema, expectedOutcomes,
|
||||||
type CaseCreateInput,
|
type CaseCreateInput,
|
||||||
} from "@/lib/schemas/case";
|
} from "@/lib/schemas/case";
|
||||||
|
import {
|
||||||
|
PRACTICE_AREAS, APPEAL_SUBTYPES, deriveSubtype,
|
||||||
|
type AppealSubtype,
|
||||||
|
} from "@/lib/practice-area";
|
||||||
|
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ key: "basics", label: "פרטי יסוד" },
|
{ 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
|
/* 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. */
|
* before moving forward, instead of surfacing all errors from page 1. */
|
||||||
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
|
const STEP_FIELDS: Record<StepKey, (keyof CaseCreateInput)[]> = {
|
||||||
basics: ["case_number", "title"],
|
basics: ["case_number", "title", "practice_area", "appeal_subtype"],
|
||||||
parties: ["appellants", "respondents"],
|
parties: ["appellants", "respondents"],
|
||||||
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
|
details: ["subject", "hearing_date", "expected_outcome", "notes", "property_address", "permit_number"],
|
||||||
};
|
};
|
||||||
@@ -60,9 +64,28 @@ export function CaseWizard() {
|
|||||||
hearing_date: "",
|
hearing_date: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
expected_outcome: "",
|
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 stepIndex = STEPS.findIndex((s) => s.key === step);
|
||||||
const isLast = stepIndex === STEPS.length - 1;
|
const isLast = stepIndex === STEPS.length - 1;
|
||||||
|
|
||||||
@@ -136,6 +159,64 @@ export function CaseWizard() {
|
|||||||
<Input id="title" {...form.register("title")} className="mt-1" />
|
<Input id="title" {...form.register("title")} className="mt-1" />
|
||||||
<FieldError message={form.formState.errors.title?.message} />
|
<FieldError message={form.formState.errors.title?.message} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "./client";
|
import { apiRequest } from "./client";
|
||||||
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
|
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
|
||||||
|
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||||
|
|
||||||
export type CaseStatus =
|
export type CaseStatus =
|
||||||
| "new"
|
| "new"
|
||||||
@@ -35,6 +36,9 @@ export type Case = {
|
|||||||
expected_outcome?: string | null;
|
expected_outcome?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
/* Multi-tenant axis — populated by backfill + server-side derive */
|
||||||
|
practice_area?: PracticeArea;
|
||||||
|
appeal_subtype?: AppealSubtype;
|
||||||
/* Present when loaded with detail=true */
|
/* Present when loaded with detail=true */
|
||||||
document_count?: number;
|
document_count?: number;
|
||||||
processing_count?: number;
|
processing_count?: number;
|
||||||
|
|||||||
72
web-ui/src/lib/practice-area.ts
Normal file
72
web-ui/src/lib/practice-area.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Client-side mirror of mcp-server/src/legal_mcp/services/practice_area.py.
|
||||||
|
*
|
||||||
|
* Keep the enum values and derivation logic in sync with the backend — the
|
||||||
|
* server is the authority, but the UI needs the labels and derivation for
|
||||||
|
* UX (auto-fill, badges, filters). If the server adds a new practice_area
|
||||||
|
* or subtype, extend the arrays below.
|
||||||
|
*
|
||||||
|
* See also: legal-ai/docs/practice-area-separation.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PracticeArea =
|
||||||
|
| "appeals_committee"
|
||||||
|
| "national_insurance"
|
||||||
|
| "labor_law";
|
||||||
|
|
||||||
|
export type AppealSubtype =
|
||||||
|
| "building_permit"
|
||||||
|
| "betterment_levy"
|
||||||
|
| "compensation_197"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export const PRACTICE_AREAS: ReadonlyArray<{
|
||||||
|
value: PracticeArea;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}> = [
|
||||||
|
{ value: "appeals_committee", label: "ועדת ערר", enabled: true },
|
||||||
|
{ value: "national_insurance", label: "ביטוח לאומי", enabled: false },
|
||||||
|
{ value: "labor_law", label: "דיני עבודה", enabled: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const APPEAL_SUBTYPES: ReadonlyArray<{
|
||||||
|
value: AppealSubtype;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "building_permit", label: "רישוי ובנייה" },
|
||||||
|
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||||
|
{ value: "compensation_197", label: "פיצויים (ס' 197)" },
|
||||||
|
{ value: "unknown", label: "לא ידוע" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRACTICE_AREA_LABELS: Record<PracticeArea, string> =
|
||||||
|
Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
|
||||||
|
PracticeArea,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const APPEAL_SUBTYPE_LABELS: Record<AppealSubtype, string> =
|
||||||
|
Object.fromEntries(APPEAL_SUBTYPES.map((s) => [s.value, s.label])) as Record<
|
||||||
|
AppealSubtype,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Derive the appeal_subtype from a case number. Mirrors the Python
|
||||||
|
* `derive_subtype` in practice_area.py. The convention is the case-number
|
||||||
|
* first digit: 1xxx → building_permit, 8xxx → betterment_levy,
|
||||||
|
* 9xxx → compensation_197. Everything else, including non-appeals_committee
|
||||||
|
* domains, returns 'unknown'.
|
||||||
|
*/
|
||||||
|
export function deriveSubtype(
|
||||||
|
caseNumber: string,
|
||||||
|
practiceArea: PracticeArea = "appeals_committee",
|
||||||
|
): AppealSubtype {
|
||||||
|
if (practiceArea !== "appeals_committee") return "unknown";
|
||||||
|
const first = caseNumber.trim().match(/^(\d)/)?.[1];
|
||||||
|
if (first === "1") return "building_permit";
|
||||||
|
if (first === "8") return "betterment_levy";
|
||||||
|
if (first === "9") return "compensation_197";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
|
||||||
|
|
||||||
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
|
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
|
||||||
* Two accepted formats, both hyphen-separated:
|
* Two accepted formats, both hyphen-separated:
|
||||||
@@ -60,6 +61,17 @@ export const caseCreateSchema = z.object({
|
|||||||
expected_outcome: z.enum(
|
expected_outcome: z.enum(
|
||||||
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
|
expectedOutcomes.map((o) => o.value) as [string, ...string[]],
|
||||||
),
|
),
|
||||||
|
practice_area: z.enum([
|
||||||
|
"appeals_committee",
|
||||||
|
"national_insurance",
|
||||||
|
"labor_law",
|
||||||
|
] as const satisfies readonly PracticeArea[]),
|
||||||
|
appeal_subtype: z.enum([
|
||||||
|
"building_permit",
|
||||||
|
"betterment_levy",
|
||||||
|
"compensation_197",
|
||||||
|
"unknown",
|
||||||
|
] as const satisfies readonly AppealSubtype[]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
|
export type CaseCreateInput = z.infer<typeof caseCreateSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user