Files
legal-ai/web-ui/src/lib/schemas/case.ts
Chaim ac0a5ee30b 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>
2026-04-11 17:15:48 +00:00

95 lines
3.6 KiB
TypeScript

/**
* 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";
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:
* NNNN-YY → "1033-25" (case sequence + 2-digit year)
* NNNN-MM-YY → "1000-04-26" (case sequence + 2-digit month + year)
*
* Slashes are deliberately forbidden: FastAPI path routing can't capture
* a `/` inside a {case_number} segment even when URL-encoded as %2F, so
* any case with a slash becomes unreachable at
* GET /api/cases/{case_number}/details. */
const caseNumberRe = /^[1-9]\d{3}(?:-\d{2}){1,2}$/;
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 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, "פורמט: NNNN-YY או NNNN-MM-YY (למשל 1033-25 או 1000-04-26)"),
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),
hearing_date: dateString,
notes: z.string().trim().max(2000),
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>;
/* 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>;