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

@@ -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;

View 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";
}

View File

@@ -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<typeof caseCreateSchema>;