Trigger appraiser-facts extraction from the UI
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 36s

Extraction is expensive (multi-minute LLM calls) and runs across every
appraisal in the case at once, so we don't kick it off silently on every
tag save. The chair tags the appraisals, then runs extraction once when
they're ready.

- New POST /api/cases/{n}/extract-appraiser-facts endpoint returns the
  extractor's summary as-is: status=completed with fact counts and
  conflicts, or status=sides_missing with the list of still-untagged
  appraisal docs.
- DocumentTypeEditor now has a two-phase popover. After a successful
  save on an appraisal doc, the body switches to a confirmation view
  with a "חלץ עובדות שמאיות עכשיו" button. The result (completed /
  sides_missing / no_appraisals / error) renders in the same popover
  so the chair sees exactly which appraisals still need tagging
  without closing and reopening anything.
- useExtractAppraiserFacts React-Query mutation invalidates the case
  detail on success so downstream views (conflict rendering in
  block-tet context) pick up the new facts.
This commit is contained in:
2026-04-19 09:42:49 +00:00
parent c536ed0e63
commit eac7784b87
3 changed files with 294 additions and 69 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -26,7 +26,11 @@ import {
type AppraiserSide,
type DocType,
} from "@/lib/doc-types";
import { usePatchDocument } from "@/lib/api/documents";
import {
useExtractAppraiserFacts,
usePatchDocument,
type ExtractAppraiserFactsResponse,
} from "@/lib/api/documents";
/*
* Inline editor for a document's tags. Renders a colored Badge that opens a
@@ -34,8 +38,10 @@ import { usePatchDocument } from "@/lib/api/documents";
* 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.
* After a successful save we swap the Popover body to a confirmation view
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
* never run it silently on save; the chair clicks this when they're done
* tagging all appraisals in the case.
*/
export function DocumentTypeEditor({
@@ -52,11 +58,19 @@ export function DocumentTypeEditor({
const [open, setOpen] = useState(false);
const [draftType, setDraftType] = useState<string>(docType || "");
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
const [saved, setSaved] = useState(false);
const [extractResult, setExtractResult] =
useState<ExtractAppraiserFactsResponse | null>(null);
const patch = usePatchDocument(caseNumber);
const extract = useExtractAppraiserFacts(caseNumber);
function reset() {
setDraftType(docType || "");
setDraftSide(appraiserSide || "");
setSaved(false);
setExtractResult(null);
patch.reset();
extract.reset();
}
const isAppraisal = draftType === "appraisal";
@@ -77,7 +91,12 @@ export function DocumentTypeEditor({
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
await patch.mutateAsync({ docId, patch: body });
setOpen(false);
setSaved(true);
}
async function handleExtract() {
const result = await extract.mutateAsync();
setExtractResult(result);
}
// Build the on-badge label: "שומה · שמאי הוועדה" when both present.
@@ -108,75 +127,89 @@ export function DocumentTypeEditor({
</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>
<PopoverContent className="w-80 space-y-3" align="end" dir="rtl">
{saved ? (
<PostSaveView
isAppraisal={isAppraisal}
extract={extract}
extractResult={extractResult}
onExtract={handleExtract}
onClose={() => setOpen(false)}
/>
) : (
<>
<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">
נדרש לציין את הצד לפני חילוץ עובדות שמאיות.
{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>
)}
<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>
</>
)}
<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>
);
@@ -184,3 +217,113 @@ export function DocumentTypeEditor({
// Re-export types so callers don't need to dual-import.
export type { AppraiserSide, DocType };
/* ── Post-save view: saved confirmation + extract action ─────────────
*
* Extraction is case-scoped (runs across every appraisal tagged in the
* case), so the button sits here rather than per-doc. If the chair is
* mid-tagging, clicking extract may return sides_missing with the list
* of still-untagged appraisals — we surface that list instead of a
* generic error.
*/
function PostSaveView({
isAppraisal,
extract,
extractResult,
onExtract,
onClose,
}: {
isAppraisal: boolean;
extract: ReturnType<typeof useExtractAppraiserFacts>;
extractResult: ExtractAppraiserFactsResponse | null;
onExtract: () => void;
onClose: () => void;
}) {
const pending = extract.isPending;
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="w-4 h-4" />
<span className="text-sm font-medium">התיוג נשמר</span>
</div>
{isAppraisal && !extractResult && (
<p className="text-[0.72rem] text-ink-muted leading-relaxed">
ההתגיה עודכנה. אם סיימת לתייג את כל השומות בתיק, הפעל חילוץ
עובדות שמאיות (תכניות + היתרים + סתירות בין שמאים).
</p>
)}
{extractResult?.status === "completed" && (
<div className="rounded-md border border-success/30 bg-success-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-0.5">
<p>
<strong>הושלם.</strong> חולצו {extractResult.total_facts} עובדות
מ-{extractResult.appraisal_count} שומות.
</p>
{extractResult.conflicts.length > 0 && (
<p>זוהו {extractResult.conflicts.length} סתירות בין שמאים.</p>
)}
</div>
)}
{extractResult?.status === "no_appraisals" && (
<p className="text-[0.72rem] text-ink-muted">
אין בתיק מסמכים מתויגים כ-שומה.
</p>
)}
{extractResult?.status === "sides_missing" && (
<div className="rounded-md border border-warn/40 bg-warn-bg px-2.5 py-2 text-[0.72rem] text-ink space-y-1">
<p>
נותרו {extractResult.missing.length} שומות ללא תיוג צד. תייג אותן
לפני הפעלת החילוץ:
</p>
<ul className="list-disc pr-4 space-y-0.5">
{extractResult.missing.map((m) => (
<li key={m.document_id} className="truncate">
{m.title}
</li>
))}
</ul>
</div>
)}
{extract.isError && !extractResult && (
<p className="text-[0.72rem] text-danger">
החילוץ נכשל. נסה שוב.
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
סגור
</Button>
{isAppraisal && (
<Button
type="button"
size="sm"
onClick={onExtract}
disabled={pending}
>
{pending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
{extractResult ? "הפעל שוב" : "חלץ עובדות שמאיות עכשיו"}
</Button>
)}
</div>
{pending && (
<p className="text-[0.68rem] text-ink-muted leading-tight">
החילוץ יכול להימשך כמה דקות שומות ארוכות עוברות ניתוח פסקה אחר
פסקה ע"י המודל.
</p>
)}
</div>
);
}

View File

@@ -136,6 +136,61 @@ export function usePatchDocument(caseNumber: string) {
}
// ── Extract appraiser facts (on-demand, per case) ──────────────────
export type ExtractAppraiserFactsResponse =
| {
status: "completed";
appraisal_count: number;
total_facts: number;
conflicts: unknown[];
by_document?: unknown[];
}
| {
status: "no_appraisals";
appraisal_count: 0;
total_facts: 0;
conflicts: unknown[];
}
| {
status: "sides_missing";
appraisal_count: number;
missing: { document_id: string; title: string; current_side: string }[];
message: string;
};
async function extractAppraiserFacts(
caseNumber: string,
): Promise<ExtractAppraiserFactsResponse> {
const res = await fetch(
`/api/cases/${encodeURIComponent(caseNumber)}/extract-appraiser-facts`,
{ method: "POST" },
);
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(
`Extraction failed with ${res.status}`,
res.status,
parsed,
);
}
return parsed as ExtractAppraiserFactsResponse;
}
export function useExtractAppraiserFacts(caseNumber: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => extractAppraiserFacts(caseNumber),
onSuccess: () => {
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
},
});
}
export function useProgress(taskId: string | null) {
const [event, setEvent] = useState<ProgressEvent | null>(null);