Edit document doc_type and appraiser side from the case UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s

Until now changing a document's doc_type required a manual SQL update.
Adds an inline editor on the document badge so the chair can retag
without leaving the case page, and threads an appraiser_side tag
(committee / appellant / deciding) through the appraisal pipeline so
betterment-levy cases — which usually have 2-3 appraisers — render
conflicts with the deciding appraiser's view marked as governing.

Backend
- New appraiser_facts.appraiser_side column (V5.1) populated from
  documents.metadata.appraiser_side at extraction time.
- extract_appraiser_facts now returns status='sides_missing' with the
  list of untagged appraisals instead of running with empty side
  labels — chair must tag every appraisal first via the UI.
- Conflict detection orders entries committee → appellant → deciding so
  the deciding appraiser appears last; block-tet's prompt instructs the
  writer to phrase the deciding appraiser's view as the governing
  factual finding ("ואולם, השמאי המכריע קבע...").
- New PATCH /api/cases/{n}/documents/{doc_id} (Pydantic model with
  whitelist validation) and matching document_update MCP tool. Both
  merge appraiser_side into metadata JSONB instead of touching the
  schema.

UI
- New shared doc-types module exports the canonical 11 doc_type
  options plus the 3 appraiser-side options; both upload-sheet and
  the document badge now read from it instead of duplicating Hebrew
  labels.
- New DocumentTypeEditor renders a Popover off the doc-type Badge
  with two Selects. The save button stays disabled while doc_type is
  appraisal but no side has been picked, mirroring the backend
  enforcement so the user finds out before triggering extraction.
- usePatchDocument React-Query mutation invalidates the case detail
  on success so the badge updates without a manual refresh.
This commit is contained in:
2026-04-19 06:26:51 +00:00
parent 110901a66c
commit c536ed0e63
12 changed files with 655 additions and 67 deletions

View File

@@ -61,6 +61,8 @@ export type CaseDocument = {
created_at: string;
practice_area?: PracticeArea;
appeal_subtype?: AppealSubtype;
/** Free-form JSONB. Known keys: appraiser_side, is_post_hearing, references. */
metadata?: Record<string, unknown>;
};
export type CaseDetail = Case & {

View File

@@ -84,6 +84,58 @@ export function useUploadDocument(caseNumber: string) {
});
}
// ── PATCH document tags ───────────────────────────────────────────
export type DocumentPatch = {
doc_type?: string;
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
};
export type PatchDocumentResponse = {
status: "completed" | "noop";
document: {
id: string;
doc_type: string;
title: string;
metadata?: Record<string, unknown>;
};
};
async function patchDocument(
caseNumber: string,
docId: string,
patch: DocumentPatch,
): Promise<PatchDocumentResponse> {
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/documents/${encodeURIComponent(docId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
},
);
const contentType = res.headers.get("content-type") ?? "";
const parsed = contentType.includes("application/json")
? await res.json().catch(() => null)
: await res.text().catch(() => null);
if (!res.ok) {
throw new ApiError(`Patch failed with ${res.status}`, res.status, parsed);
}
return parsed as PatchDocumentResponse;
}
export function usePatchDocument(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ docId, patch }: { docId: string; patch: DocumentPatch }) =>
patchDocument(caseNumber, docId, patch),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
export function useProgress(taskId: string | null) {
const [event, setEvent] = useState<ProgressEvent | null>(null);

View File

@@ -0,0 +1,85 @@
/**
* Canonical document tagging metadata. Mirrors web/app.py:DOC_TYPE_NAMES and
* the validation lists in mcp-server/src/legal_mcp/tools/documents.py.
*
* If you add a doc_type or appraiser_side here, add the matching value on the
* backend too — the API rejects anything not on its whitelist.
*/
export type DocType =
| "appeal"
| "response"
| "protocol"
| "plan"
| "decision"
| "court_decision"
| "permit"
| "appraisal"
| "exhibit"
| "objection"
| "reference";
export const DOC_TYPE_LABELS: Record<DocType, string> = {
appeal: "כתב ערר",
response: "כתב תשובה",
protocol: "פרוטוקול",
plan: "תכנית",
decision: "החלטת ועדה מקומית",
court_decision: "פסק דין",
permit: "היתר",
appraisal: "שומה",
exhibit: "נספח",
objection: "התנגדות",
reference: "חומר רקע",
};
/** Display order for editors and selects. */
export const DOC_TYPE_OPTIONS: { value: DocType; label: string }[] = [
{ value: "appeal", label: DOC_TYPE_LABELS.appeal },
{ value: "response", label: DOC_TYPE_LABELS.response },
{ value: "protocol", label: DOC_TYPE_LABELS.protocol },
{ value: "plan", label: DOC_TYPE_LABELS.plan },
{ value: "decision", label: DOC_TYPE_LABELS.decision },
{ value: "court_decision", label: DOC_TYPE_LABELS.court_decision },
{ value: "permit", label: DOC_TYPE_LABELS.permit },
{ value: "appraisal", label: DOC_TYPE_LABELS.appraisal },
{ value: "exhibit", label: DOC_TYPE_LABELS.exhibit },
{ value: "objection", label: DOC_TYPE_LABELS.objection },
{ value: "reference", label: DOC_TYPE_LABELS.reference },
];
export function doctypeLabel(value: string): string {
return (DOC_TYPE_LABELS as Record<string, string>)[value] ?? value;
}
export function doctypeTone(value: string): string {
switch (value) {
case "appeal": return "bg-info-bg text-info border-info/40";
case "response": return "bg-gold-wash text-gold-deep border-gold/40";
case "decision": return "bg-success-bg text-success border-success/40";
case "protocol": return "bg-warn-bg text-warn border-warn/40";
case "appraisal": return "bg-info-bg text-info border-info/40";
case "court_decision": return "bg-success-bg text-success border-success/40";
default: return "bg-rule-soft text-ink-muted border-rule";
}
}
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
export type AppraiserSide = "committee" | "appellant" | "deciding";
export const APPRAISER_SIDE_LABELS: Record<AppraiserSide, string> = {
committee: "שמאי הוועדה המקומית",
appellant: "שמאי העורר",
deciding: "שמאי מכריע",
};
export const APPRAISER_SIDE_OPTIONS: { value: AppraiserSide; label: string }[] = [
{ value: "committee", label: APPRAISER_SIDE_LABELS.committee },
{ value: "appellant", label: APPRAISER_SIDE_LABELS.appellant },
{ value: "deciding", label: APPRAISER_SIDE_LABELS.deciding },
];
export function appraiserSideLabel(value: string): string {
return (APPRAISER_SIDE_LABELS as Record<string, string>)[value] ?? value;
}