feat: fix wizard step-skip bug + extend case edit with all fields + Paperclip title sync
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m38s

- Fix keyboard navigation bug: React was reusing the submit button DOM element
  when transitioning "הבא" → "צור תיק", retaining focus and causing Enter to
  auto-submit step 3. Added key props to force element replacement.

- CaseEditDialog now covers all wizard fields: appellants, respondents,
  property_address, permit_number (in addition to existing title, subject,
  hearing_date, expected_outcome, notes).

- When case title changes, Paperclip project name is updated in background
  via new update_project_name() in paperclip_client.py.

- Extended CaseUpdateRequest, case_update MCP tool, and caseUpdateSchema
  to carry the new fields end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 10:55:45 +00:00
parent 8dc7a40fa2
commit 83b6ff51b7
8 changed files with 562 additions and 8 deletions

View File

@@ -271,6 +271,10 @@ async def case_update(
decision_date: str = "", decision_date: str = "",
tags: list[str] | None = None, tags: list[str] | None = None,
expected_outcome: str = "", expected_outcome: str = "",
appellants: list[str] | None = None,
respondents: list[str] | None = None,
property_address: str = "",
permit_number: str = "",
) -> str: ) -> str:
"""עדכון פרטי תיק. """עדכון פרטי תיק.
@@ -284,6 +288,10 @@ async def case_update(
decision_date: תאריך החלטה (YYYY-MM-DD) decision_date: תאריך החלטה (YYYY-MM-DD)
tags: תגיות tags: תגיות
expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy) expected_outcome: תוצאה צפויה (rejection/partial_acceptance/full_acceptance/betterment_levy)
appellants: רשימת עוררים חדשה
respondents: רשימת משיבים חדשה
property_address: כתובת נכס חדשה
permit_number: מספר תכנית/בקשה חדש
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -322,6 +330,14 @@ async def case_update(
fields["tags"] = tags fields["tags"] = tags
if expected_outcome: if expected_outcome:
fields["expected_outcome"] = expected_outcome fields["expected_outcome"] = expected_outcome
if appellants is not None:
fields["appellants"] = appellants
if respondents is not None:
fields["respondents"] = respondents
if property_address:
fields["property_address"] = property_address
if permit_number:
fields["permit_number"] = permit_number
updated = await db.update_case(UUID(case["id"]), **fields) updated = await db.update_case(UUID(case["id"]), **fields)

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -15,14 +15,15 @@ import { Label } from "@/components/ui/label";
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { PartiesField } from "@/components/wizard/parties-field";
import { useUpdateCase } from "@/lib/api/cases"; import { useUpdateCase } from "@/lib/api/cases";
import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case"; import { caseUpdateSchema, expectedOutcomes, type CaseUpdateInput } from "@/lib/schemas/case";
import type { CaseDetail } from "@/lib/api/cases"; import type { CaseDetail } from "@/lib/api/cases";
/* /*
* Inline edit dialog for core case fields. Uses react-hook-form + zod * Inline edit dialog for all case fields set at creation time.
* directly (shadcn's <Form> registry entry wasn't available at init * Uses react-hook-form + zod directly (shadcn's <Form> registry entry
* time, so the styling is reproduced by hand in a lean form layout). * wasn't available at init time, so the styling is reproduced by hand).
*/ */
function FieldError({ message }: { message?: string }) { function FieldError({ message }: { message?: string }) {
@@ -42,6 +43,10 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
hearing_date: data.hearing_date ?? "", hearing_date: data.hearing_date ?? "",
notes: "", notes: "",
expected_outcome: data.expected_outcome ?? "", expected_outcome: data.expected_outcome ?? "",
appellants: data.appellants ?? [],
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
}, },
}); });
@@ -54,6 +59,10 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
hearing_date: data.hearing_date ?? "", hearing_date: data.hearing_date ?? "",
notes: "", notes: "",
expected_outcome: data.expected_outcome ?? "", expected_outcome: data.expected_outcome ?? "",
appellants: data.appellants ?? [],
respondents: data.respondents ?? [],
property_address: data.property_address ?? "",
permit_number: data.permit_number ?? "",
}); });
}, [open, data, form]); }, [open, data, form]);
@@ -74,11 +83,11 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
עריכת פרטי תיק עריכת פרטי תיק
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-lg" dir="rtl"> <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle> <DialogTitle>עריכת פרטי תיק {data.case_number}</DialogTitle>
<DialogDescription className="text-ink-muted"> <DialogDescription className="text-ink-muted">
השינויים נשמרים ישירות ל-FastAPI. השדות הריקים נשארים ללא שינוי. השינויים נשמרים ישירות ל-DB. שינוי כותרת יסנכרן גם ל-Paperclip.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -95,6 +104,55 @@ export function CaseEditDialog({ data }: { data: CaseDetail }) {
<FieldError message={form.formState.errors.subject?.message} /> <FieldError message={form.formState.errors.subject?.message} />
</div> </div>
<div className="h-px bg-rule" />
<Controller
control={form.control}
name="appellants"
render={({ field, fieldState }) => (
<PartiesField
label="עוררים"
value={field.value ?? []}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<Controller
control={form.control}
name="respondents"
render={({ field, fieldState }) => (
<PartiesField
label="משיבים"
value={field.value ?? []}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
<div className="h-px bg-rule" />
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="property_address" className="text-navy">כתובת הנכס</Label>
<Input
id="property_address"
{...form.register("property_address")}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="permit_number" className="text-navy">מס׳ תכנית/בקשה</Label>
<Input
id="permit_number"
{...form.register("permit_number")}
className="mt-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label> <Label htmlFor="hearing_date" className="text-navy">תאריך דיון</Label>

View File

@@ -321,6 +321,7 @@ export function CaseWizard() {
</Button> </Button>
{isLast ? ( {isLast ? (
<Button <Button
key="submit-btn"
type="submit" type="submit"
disabled={mutate.isPending} disabled={mutate.isPending}
className="bg-navy hover:bg-navy-soft text-parchment" className="bg-navy hover:bg-navy-soft text-parchment"
@@ -329,6 +330,7 @@ export function CaseWizard() {
</Button> </Button>
) : ( ) : (
<Button <Button
key="next-btn"
type="button" type="button"
onClick={goNext} onClick={goNext}
className="bg-navy hover:bg-navy-soft text-parchment" className="bg-navy hover:bg-navy-soft text-parchment"

View File

@@ -50,6 +50,10 @@ export type Case = {
processing_count?: number; processing_count?: number;
committee_type?: string | null; committee_type?: string | null;
hearing_date?: string | null; hearing_date?: string | null;
appellants?: string[] | null;
respondents?: string[] | null;
property_address?: string | null;
permit_number?: string | null;
}; };
export type CaseDocument = { export type CaseDocument = {

View File

@@ -432,6 +432,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/cases/stale": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Stale Cases
* @description Return cases that haven't been updated in N days and are not in a terminal/waiting status.
*/
get: operations["api_stale_cases_api_cases_stale_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cases/{case_number}/archive": { "/api/cases/{case_number}/archive": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1927,6 +1947,26 @@ export interface paths {
patch: operations["api_resolve_feedback_api_feedback__feedback_id__resolve_patch"]; patch: operations["api_resolve_feedback_api_feedback__feedback_id__resolve_patch"];
trace?: never; trace?: never;
}; };
"/api/chair-feedback/weekly-summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Api Chair Feedback Weekly Summary
* @description Return chair feedback from the last N days as a text summary for the CEO agent.
*/
get: operations["api_chair_feedback_weekly_summary_api_chair_feedback_weekly_summary_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/precedent-library/upload": { "/api/precedent-library/upload": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2019,6 +2059,40 @@ export interface paths {
patch: operations["precedent_library_update_api_precedent_library__case_law_id__patch"]; patch: operations["precedent_library_update_api_precedent_library__case_law_id__patch"];
trace?: never; trace?: never;
}; };
"/api/precedent-library/{case_law_id}/relations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Precedent Add Relation */
post: operations["precedent_add_relation_api_precedent_library__case_law_id__relations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/precedent-library/{case_law_id}/relations/{related_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Precedent Remove Relation */
delete: operations["precedent_remove_relation_api_precedent_library__case_law_id__relations__related_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/precedent-library/{case_law_id}/request-metadata": { "/api/precedent-library/{case_law_id}/request-metadata": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2030,8 +2104,8 @@ export interface paths {
put?: never; put?: never;
/** /**
* Precedent Request Metadata * Precedent Request Metadata
* @description Stamp the case_law row as needing metadata extraction. The local * @description Stamp the case_law row as needing metadata extraction AND wake the
* MCP worker (`precedent_process_pending_metadata`) will pick it up. * Paperclip CEO so extraction runs automatically — same flow as upload.
*/ */
post: operations["precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post"]; post: operations["precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post"];
delete?: never; delete?: never;
@@ -2081,6 +2155,69 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/internal-decisions/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Internal Decisions Upload
* @description Upload a planning appeals-committee decision to the internal corpus.
*/
post: operations["internal_decisions_upload_api_internal_decisions_upload_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/internal-decisions/migrate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Internal Decisions Migrate
* @description Migrate existing data to the internal committee corpus.
*
* source: 'style_corpus' | 'external_corpus' | 'both'
* dry_run: if true, only report what would be done (no writes)
*/
post: operations["internal_decisions_migrate_api_internal_decisions_migrate_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/internal-decisions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Internal Decisions List
* @description List internal committee decisions with optional filters.
*/
get: operations["internal_decisions_list_api_internal_decisions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/halachot": { "/api/halachot": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2194,6 +2331,63 @@ export interface components {
*/ */
title: string; title: string;
}; };
/** Body_internal_decisions_upload_api_internal_decisions_upload_post */
Body_internal_decisions_upload_api_internal_decisions_upload_post: {
/** File */
file: string;
/** Case Number */
case_number: string;
/**
* Case Name
* @default
*/
case_name: string;
/**
* Court
* @default
*/
court: string;
/**
* Decision Date
* @default
*/
decision_date: string;
/**
* Chair Name
* @default
*/
chair_name: string;
/**
* District
* @default
*/
district: string;
/**
* Practice Area
* @default
*/
practice_area: string;
/**
* Appeal Subtype
* @default
*/
appeal_subtype: string;
/**
* Subject Tags
* @default []
*/
subject_tags: string;
/**
* Is Binding
* @default true
*/
is_binding: boolean;
/**
* Summary
* @default
*/
summary: string;
};
/** Body_precedent_library_upload_api_precedent_library_upload_post */ /** Body_precedent_library_upload_api_precedent_library_upload_post */
Body_precedent_library_upload_api_precedent_library_upload_post: { Body_precedent_library_upload_api_precedent_library_upload_post: {
/** File */ /** File */
@@ -2536,6 +2730,16 @@ export interface components {
*/ */
pdf_document_id: string; pdf_document_id: string;
}; };
/** PrecedentRelationRequest */
PrecedentRelationRequest: {
/** Related Id */
related_id: string;
/**
* Relation Type
* @default same_case_chain
*/
relation_type: string;
};
/** PrecedentUpdateRequest */ /** PrecedentUpdateRequest */
PrecedentUpdateRequest: { PrecedentUpdateRequest: {
/** Case Name */ /** Case Name */
@@ -3183,6 +3387,37 @@ export interface operations {
}; };
}; };
}; };
api_stale_cases_api_cases_stale_get: {
parameters: {
query?: {
days?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
api_archive_case_api_cases__case_number__archive_post: { api_archive_case_api_cases__case_number__archive_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5510,6 +5745,38 @@ export interface operations {
}; };
}; };
}; };
api_chair_feedback_weekly_summary_api_chair_feedback_weekly_summary_get: {
parameters: {
query?: {
days?: number;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
precedent_library_upload_api_precedent_library_upload_post: { precedent_library_upload_api_precedent_library_upload_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5551,6 +5818,7 @@ export interface operations {
precedent_level?: string; precedent_level?: string;
source_type?: string; source_type?: string;
search?: string; search?: string;
source_kind?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
}; };
@@ -5735,6 +6003,73 @@ export interface operations {
}; };
}; };
}; };
precedent_add_relation_api_precedent_library__case_law_id__relations_post: {
parameters: {
query?: never;
header?: never;
path: {
case_law_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PrecedentRelationRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
precedent_remove_relation_api_precedent_library__case_law_id__relations__related_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
case_law_id: string;
related_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post: { precedent_request_metadata_api_precedent_library__case_law_id__request_metadata_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -5829,6 +6164,105 @@ export interface operations {
}; };
}; };
}; };
internal_decisions_upload_api_internal_decisions_upload_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_internal_decisions_upload_api_internal_decisions_upload_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
internal_decisions_migrate_api_internal_decisions_migrate_post: {
parameters: {
query?: {
source?: string;
dry_run?: boolean;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
internal_decisions_list_api_internal_decisions_get: {
parameters: {
query?: {
district?: string;
chair_name?: string;
practice_area?: string;
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
halachot_list_api_halachot_get: { halachot_list_api_halachot_get: {
parameters: { parameters: {
query?: { query?: {

View File

@@ -89,6 +89,14 @@ export const caseUpdateSchema = z.object({
.enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]]) .enum(expectedOutcomes.map((o) => o.value) as [string, ...string[]])
.optional(), .optional(),
status: z.string().optional(), status: z.string().optional(),
appellants: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.optional(),
respondents: z
.array(z.string().trim().min(1).refine((v) => hebrewPartyRe.test(v), "שם לא תקין"))
.optional(),
property_address: z.string().trim().max(200).optional(),
permit_number: z.string().trim().max(100).optional(),
}); });
export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>; export type CaseUpdateInput = z.infer<typeof caseUpdateSchema>;

View File

@@ -1240,6 +1240,10 @@ class CaseUpdateRequest(BaseModel):
decision_date: str = "" decision_date: str = ""
tags: list[str] | None = None tags: list[str] | None = None
expected_outcome: str = "" expected_outcome: str = ""
appellants: list[str] | None = None
respondents: list[str] | None = None
property_address: str = ""
permit_number: str = ""
@app.post("/api/cases/create") @app.post("/api/cases/create")
@@ -1383,12 +1387,25 @@ async def api_case_update(case_number: str, req: CaseUpdateRequest, background_t
decision_date=req.decision_date, decision_date=req.decision_date,
tags=req.tags, tags=req.tags,
expected_outcome=req.expected_outcome, expected_outcome=req.expected_outcome,
appellants=req.appellants,
respondents=req.respondents,
property_address=req.property_address,
permit_number=req.permit_number,
) )
try: try:
parsed = json.loads(result) parsed = json.loads(result)
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException(404, result) raise HTTPException(404, result)
# Paperclip sync: update project name when title changes (fire-and-forget).
old_title = (existing or {}).get("title", "")
if req.title and req.title != old_title:
background_tasks.add_task(
paperclip_client.update_project_name,
case_number=case_number,
new_title=req.title,
)
# Emit webhook when status changes (fire-and-forget via BackgroundTasks). # Emit webhook when status changes (fire-and-forget via BackgroundTasks).
new_status = req.status new_status = req.status
if new_status and old_status != new_status: if new_status and old_status != new_status:

View File

@@ -231,6 +231,21 @@ async def restore_project(case_number: str) -> dict:
await conn.close() await conn.close()
async def update_project_name(case_number: str, new_title: str) -> None:
"""Update the Paperclip project name when a case title changes."""
project_name = f"ערר {case_number}{new_title}"[:200]
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
try:
await conn.execute(
"UPDATE projects SET name = $1, updated_at = now() WHERE name LIKE $2",
project_name, f"%{case_number}%",
)
except Exception:
logger.warning("Failed to update Paperclip project name for case %s", case_number)
finally:
await conn.close()
async def _ensure_default_workspace( async def _ensure_default_workspace(
conn: asyncpg.Connection, conn: asyncpg.Connection,
project_id: str, project_id: str,