diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json
index 16dc144..6f332ab 100644
--- a/.taskmaster/tasks/tasks.json
+++ b/.taskmaster/tasks/tasks.json
@@ -880,12 +880,26 @@
],
"priority": "medium",
"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": {
"version": "1.0.0",
- "lastModified": "2026-04-11T16:25:55.570Z",
- "taskCount": 58,
+ "lastModified": "2026-04-11T17:13:13.932Z",
+ "taskCount": 59,
"completedCount": 53,
"tags": [
"master"
diff --git a/web-ui/src/components/cases/case-header.tsx b/web-ui/src/components/cases/case-header.tsx
index 2a9c137..2fd4a29 100644
--- a/web-ui/src/components/cases/case-header.tsx
+++ b/web-ui/src/components/cases/case-header.tsx
@@ -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 }) {
-
+
ערר {data?.case_number ?? "—"}
{data?.status && }
+ {data?.practice_area && (
+
+ {PRACTICE_AREA_LABELS[data.practice_area]}
+ {data.appeal_subtype && data.appeal_subtype !== "unknown" && (
+ <> · {APPEAL_SUBTYPE_LABELS[data.appeal_subtype]}>
+ )}
+
+ )}
{data?.title ?? "טוען…"}
diff --git a/web-ui/src/components/cases/cases-table.tsx b/web-ui/src/components/cases/cases-table.tsx
index d372bb3..ddc4bf6 100644
--- a/web-ui/src/components/cases/cases-table.tsx
+++ b/web-ui/src/components/cases/cases-table.tsx
@@ -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[] = [
header: "סטטוס",
cell: ({ row }) => ,
},
+ {
+ accessorKey: "appeal_subtype",
+ header: "תחום",
+ cell: ({ row }) => {
+ const s = row.original.appeal_subtype;
+ if (!s || s === "unknown") return —;
+ return {APPEAL_SUBTYPE_LABELS[s]};
+ },
+ },
{
accessorKey: "document_count",
header: "מסמכים",
diff --git a/web-ui/src/components/wizard/case-wizard.tsx b/web-ui/src/components/wizard/case-wizard.tsx
index 1cbee72..f4a4bd8 100644
--- a/web-ui/src/components/wizard/case-wizard.tsx
+++ b/web-ui/src/components/wizard/case-wizard.tsx
@@ -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 = {
- 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() {
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
(
+
+ )}
+ />
+
+ מזוהה אוטומטית ממספר התיק
+
+
+
)}
diff --git a/web-ui/src/lib/api/cases.ts b/web-ui/src/lib/api/cases.ts
index eb73a02..d6dc20e 100644
--- a/web-ui/src/lib/api/cases.ts
+++ b/web-ui/src/lib/api/cases.ts
@@ -11,6 +11,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import type { CaseCreateInput, CaseUpdateInput } from "@/lib/schemas/case";
+import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
export type CaseStatus =
| "new"
@@ -35,6 +36,9 @@ export type Case = {
expected_outcome?: string | null;
created_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 */
document_count?: number;
processing_count?: number;
diff --git a/web-ui/src/lib/practice-area.ts b/web-ui/src/lib/practice-area.ts
new file mode 100644
index 0000000..aad8800
--- /dev/null
+++ b/web-ui/src/lib/practice-area.ts
@@ -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
=
+ Object.fromEntries(PRACTICE_AREAS.map((p) => [p.value, p.label])) as Record<
+ PracticeArea,
+ string
+ >;
+
+export const APPEAL_SUBTYPE_LABELS: Record =
+ 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";
+}
diff --git a/web-ui/src/lib/schemas/case.ts b/web-ui/src/lib/schemas/case.ts
index 7427929..e439f49 100644
--- a/web-ui/src/lib/schemas/case.ts
+++ b/web-ui/src/lib/schemas/case.ts
@@ -10,6 +10,7 @@
*/
import { z } from "zod";
+import type { PracticeArea, AppealSubtype } from "@/lib/practice-area";
/* Appeal numbers follow the 1xxx / 8xxx / 9xxx convention from CLAUDE.md.
* Two accepted formats, both hyphen-separated:
@@ -60,6 +61,17 @@ export const caseCreateSchema = z.object({
expected_outcome: z.enum(
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;