Edit document doc_type and appraiser side from the case UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
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:
186
web-ui/src/components/cases/document-type-editor.tsx
Normal file
186
web-ui/src/components/cases/document-type-editor.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
APPRAISER_SIDE_LABELS,
|
||||
APPRAISER_SIDE_OPTIONS,
|
||||
DOC_TYPE_OPTIONS,
|
||||
appraiserSideLabel,
|
||||
doctypeLabel,
|
||||
doctypeTone,
|
||||
type AppraiserSide,
|
||||
type DocType,
|
||||
} from "@/lib/doc-types";
|
||||
import { usePatchDocument } from "@/lib/api/documents";
|
||||
|
||||
/*
|
||||
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||
* Popover with two Selects:
|
||||
* 1. doc_type (always shown)
|
||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||
*
|
||||
* The Save button is disabled when doc_type is "appraisal" but no side is
|
||||
* picked — extract_appraiser_facts requires it, so we enforce here too.
|
||||
*/
|
||||
|
||||
export function DocumentTypeEditor({
|
||||
caseNumber,
|
||||
docId,
|
||||
docType,
|
||||
appraiserSide,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
docId: string;
|
||||
docType: string;
|
||||
appraiserSide?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||
const patch = usePatchDocument(caseNumber);
|
||||
|
||||
function reset() {
|
||||
setDraftType(docType || "");
|
||||
setDraftSide(appraiserSide || "");
|
||||
}
|
||||
|
||||
const isAppraisal = draftType === "appraisal";
|
||||
const sideMissing = isAppraisal && !draftSide;
|
||||
const dirty =
|
||||
draftType !== docType ||
|
||||
(isAppraisal && draftSide !== (appraiserSide || ""));
|
||||
|
||||
async function handleSave() {
|
||||
if (sideMissing || !dirty) return;
|
||||
const body: { doc_type?: string; appraiser_side?: string } = {};
|
||||
if (draftType !== docType) body.doc_type = draftType;
|
||||
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||
body.appraiser_side = draftSide;
|
||||
}
|
||||
// If the new type is NOT appraisal but the doc previously had a side,
|
||||
// clear it so it doesn't dangle confusingly in metadata.
|
||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||
|
||||
await patch.mutateAsync({ docId, patch: body });
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
// Build the on-badge label: "שומה · שמאי הוועדה" when both present.
|
||||
const badgeText =
|
||||
docType === "appraisal" && appraiserSide
|
||||
? `${doctypeLabel(docType)} · ${appraiserSideLabel(appraiserSide)}`
|
||||
: doctypeLabel(docType);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) reset();
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-gold/50 rounded-full"
|
||||
title="לחץ לשינוי תיוג"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] ${doctypeTone(docType)} hover:opacity-80`}
|
||||
>
|
||||
{badgeText}
|
||||
</Badge>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 space-y-3" align="end" dir="rtl">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-ink-muted">סוג מסמך</label>
|
||||
<Select value={draftType} onValueChange={setDraftType} dir="rtl">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="בחר סוג" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOC_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isAppraisal && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-ink-muted">צד השמאי</label>
|
||||
<Select value={draftSide} onValueChange={setDraftSide} dir="rtl">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="בחר צד" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{APPRAISER_SIDE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sideMissing && (
|
||||
<p className="text-[0.7rem] text-warn">
|
||||
נדרש לציין את הצד לפני חילוץ עובדות שמאיות.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||
ערכים: {Object.values(APPRAISER_SIDE_LABELS).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{patch.isError && (
|
||||
<p className="text-[0.7rem] text-danger">
|
||||
שמירה נכשלה. נסה שוב.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={patch.isPending}
|
||||
>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || sideMissing || patch.isPending}
|
||||
>
|
||||
{patch.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
שמור
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export types so callers don't need to dual-import.
|
||||
export type { AppraiserSide, DocType };
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
@@ -23,40 +22,19 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/api/client";
|
||||
import { casesKeys } from "@/lib/api/cases";
|
||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||
|
||||
/*
|
||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||
* row shape returned by the FastAPI case_get endpoint — see db.list_documents
|
||||
* and the `documents` schema in legal_mcp/services/db.py:
|
||||
* id · case_id · doc_type · title · file_path · extraction_status ·
|
||||
* page_count · created_at · practice_area · appeal_subtype
|
||||
* page_count · created_at · practice_area · appeal_subtype · metadata
|
||||
*
|
||||
* Doc-type labels and tone classes live in @/lib/doc-types so the upload
|
||||
* sheet, the inline editor, and this panel all stay in sync.
|
||||
*/
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
appeal: "כתב ערר",
|
||||
response: "כתב תשובה",
|
||||
protocol: "פרוטוקול",
|
||||
decision: "החלטת ועדה מקומית",
|
||||
plan: "תכנית",
|
||||
appraisal: "שומה",
|
||||
reference: "חומר רקע",
|
||||
auto: "—",
|
||||
};
|
||||
|
||||
function doctypeLabel(t: string): string {
|
||||
return DOC_TYPE_LABELS[t] ?? t;
|
||||
}
|
||||
|
||||
function doctypeTone(t: string): string {
|
||||
switch (t) {
|
||||
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";
|
||||
default: return "bg-rule-soft text-ink-muted border-rule";
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "בהמתנה",
|
||||
processing: "בעיבוד",
|
||||
@@ -272,12 +250,15 @@ function DocumentRow({
|
||||
</div>
|
||||
</button>
|
||||
{doc.doc_type && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2 py-0.5 text-[0.7rem] shrink-0 ${doctypeTone(doc.doc_type)}`}
|
||||
>
|
||||
{doctypeLabel(doc.doc_type)}
|
||||
</Badge>
|
||||
<DocumentTypeEditor
|
||||
caseNumber={caseNumber}
|
||||
docId={doc.id}
|
||||
docType={doc.doc_type}
|
||||
appraiserSide={
|
||||
(doc.metadata as { appraiser_side?: string } | undefined)
|
||||
?.appraiser_side
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -12,23 +12,22 @@ import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUploadDocument, useProgress, type ProgressEvent } from "@/lib/api/documents";
|
||||
import { DOC_TYPE_OPTIONS } from "@/lib/doc-types";
|
||||
|
||||
/*
|
||||
* Upload sheet — drag-drop zone + doc-type selector, with live SSE
|
||||
* progress for the most-recent upload. Intentionally sequential:
|
||||
* a single file at a time keeps the SSE subscription simple and
|
||||
* matches how the FastAPI processor handles one task_id per file.
|
||||
*
|
||||
* The "auto" option is upload-only — it triggers backend classification.
|
||||
* After upload, the inline DocumentTypeEditor shows the resolved doc_type
|
||||
* and uses DOC_TYPE_OPTIONS directly (no "auto" entry).
|
||||
*/
|
||||
|
||||
const DOC_TYPES: { value: string; label: string }[] = [
|
||||
{ value: "auto", label: "זיהוי אוטומטי" },
|
||||
{ value: "appeal", label: "כתב ערר" },
|
||||
{ value: "response", label: "כתב תשובה" },
|
||||
{ value: "protocol", label: "פרוטוקול דיון" },
|
||||
{ value: "decision", label: "החלטת ועדה מקומית" },
|
||||
{ value: "plan", label: "תכנית" },
|
||||
{ value: "appraisal",label: "שומה" },
|
||||
{ value: "reference",label: "חומר רקע" },
|
||||
{ value: "auto", label: "זיהוי אוטומטי" },
|
||||
...DOC_TYPE_OPTIONS,
|
||||
];
|
||||
|
||||
type UploadRow = {
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
85
web-ui/src/lib/doc-types.ts
Normal file
85
web-ui/src/lib/doc-types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user