feat(ui): אינדיקטור התקדמות לחילוץ מטא-דאטה + מתג-מקטעים בספריית הפסיקה
שתי בעיות UX בדף /precedents:
1. חילוץ מטא-דאטה לא נתן שום אינדיקציה שהוא רץ. בניגוד לחילוץ טקסט/הלכות
(extraction_status / halacha_extraction_status) למטא-דאטה היתה רק חותמת-זמן
metadata_extraction_requested_at — אין מצב "processing", לכן StatusPill לא
הציג כלום. נוספה עמודת metadata_extraction_status ('pending'|'processing'|
'completed'|'failed') במתכונת העמודות הקיימות, וה-worker
(process_pending_extractions + reextract_metadata) מעדכן אותה: processing
בתחילת פריט, completed בסיום (מנקה גם את החותמת), pending בכשל (לריטריי).
ה-UI מציג תג "מחלץ מטא-דאטה" + באנר מונה-אצווה עם אחוז התקדמות (high-water-mark
של עומק-התור) שמתעדכן אוטומטית דרך ה-polling הקיים (5ש').
2. שתי טבלאות מוערמות (בתי משפט / ועדות ערר) חייבו גלילה ארוכה. הוחלפו במתג-
מקטעים — טבלה אחת בכל פעם, עם שמירה על העמודות הייעודיות לכל סוג.
Invariants: G2 (מרחיב מנגנון-סטטוס קיים, לא מסלול מקביל), INV-TOOL4/GAP-45
(המשך חשיפת תור-החילוץ הסמוי). אין נגיעה בתוכן משפטי (G11).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Trash2, Plus, Pencil, Wand2 } from "lucide-react";
|
||||
import { Trash2, Plus, Pencil, Wand2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -87,6 +89,28 @@ function StatusPill({ p }: { p: Precedent }) {
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Metadata extraction (orthogonal one-off re-extract). Surface it while
|
||||
// active so the chair sees the queued button actually ran on something.
|
||||
if (p.metadata_extraction_status === "processing") {
|
||||
return <ActivePill label="מחלץ מטא-דאטה" />;
|
||||
}
|
||||
if (p.metadata_extraction_status === "failed") {
|
||||
return (
|
||||
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
|
||||
מטא-דאטה נכשל
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (
|
||||
p.metadata_extraction_requested_at &&
|
||||
p.metadata_extraction_status !== "completed"
|
||||
) {
|
||||
return (
|
||||
<Badge variant="outline" className="bg-rule-soft text-ink-muted">
|
||||
ממתין למטא-דאטה
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (p.halacha_extraction_status === "processing") {
|
||||
return <ActivePill label="מחלץ הלכות" />;
|
||||
}
|
||||
@@ -325,6 +349,92 @@ function CommitteeRow({ p, onEdit }: { p: Precedent; onEdit: (id: string) => voi
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live banner for the metadata-extraction queue. The local-MCP worker
|
||||
* (`precedent_process_pending kind=metadata`) drains stamped rows serially;
|
||||
* while it runs, rows carry metadata_extraction_status='processing' (current
|
||||
* item) or a request timestamp (still queued). We surface a real, advancing
|
||||
* percentage by tracking the session high-water-mark of the queue depth:
|
||||
* percent = (peak - remaining) / peak. Hidden when the queue is empty.
|
||||
*/
|
||||
function MetadataQueueBanner({ items }: { items: Precedent[] }) {
|
||||
const processing = items.filter(
|
||||
(p) => p.metadata_extraction_status === "processing",
|
||||
).length;
|
||||
const pending = items.filter(
|
||||
(p) =>
|
||||
p.metadata_extraction_status !== "processing" &&
|
||||
p.metadata_extraction_requested_at !== null &&
|
||||
p.metadata_extraction_status !== "completed" &&
|
||||
p.metadata_extraction_status !== "failed",
|
||||
).length;
|
||||
const remaining = processing + pending;
|
||||
|
||||
// Session high-water-mark of the queue depth, using React's documented
|
||||
// "adjust state during render" pattern (no effect, no ref-in-render). React
|
||||
// re-renders immediately without committing the intermediate UI. Resets to 0
|
||||
// when the queue drains, so the next batch starts a fresh 0→100% sweep.
|
||||
const [peak, setPeak] = useState(0);
|
||||
if (remaining === 0) {
|
||||
if (peak !== 0) setPeak(0);
|
||||
} else if (remaining > peak) {
|
||||
setPeak(remaining);
|
||||
}
|
||||
|
||||
if (remaining === 0) return null;
|
||||
const done = Math.max(0, peak - remaining);
|
||||
const percent = peak > 0 ? Math.round((done / peak) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gold/40 bg-gold-wash/50 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-gold-deep">
|
||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
||||
<span>
|
||||
חילוץ מטא-דאטה פעיל — מעבד {processing}, נותרו {pending} בתור
|
||||
{peak > 0 ? ` · ${done}/${peak}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percent} className="h-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SegButton({
|
||||
active, onClick, label, count,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-4 py-1.5 text-sm font-semibold transition-colors",
|
||||
active
|
||||
? "bg-surface text-navy shadow-sm"
|
||||
: "text-ink-muted hover:text-navy",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{count !== undefined && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[0.72rem]",
|
||||
active ? "border-navy/30 text-navy" : "text-ink-muted",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TableSkeleton({ cols }: { cols: number }) {
|
||||
return (
|
||||
<>
|
||||
@@ -344,6 +454,9 @@ export function LibraryListPanel() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
// Which of the two corpora to show. Both queries stay mounted (counts +
|
||||
// cross-table polling), but only the active table renders — no long scroll.
|
||||
const [view, setView] = useState<"court" | "committee">("court");
|
||||
|
||||
const sharedFilters = {
|
||||
practiceArea: practiceArea || undefined,
|
||||
@@ -354,6 +467,12 @@ export function LibraryListPanel() {
|
||||
const courts = usePrecedents({ ...sharedFilters, sourceKind: "external_upload", sourceType: "court_ruling" });
|
||||
const committee = usePrecedents({ ...sharedFilters, sourceKind: "all_committees" });
|
||||
|
||||
// Metadata-queue banner draws on both corpora (a stamped row can be either).
|
||||
const allItems = [
|
||||
...(courts.data?.items ?? []),
|
||||
...(committee.data?.items ?? []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Shared filters */}
|
||||
@@ -385,16 +504,28 @@ export function LibraryListPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata-extraction queue progress (hidden when idle) */}
|
||||
<MetadataQueueBanner items={allItems} />
|
||||
|
||||
{/* Segmented control — show one corpus at a time */}
|
||||
<div className="inline-flex rounded-lg border border-rule bg-rule-soft/40 p-1 gap-1">
|
||||
<SegButton
|
||||
active={view === "court"}
|
||||
onClick={() => setView("court")}
|
||||
label="פסיקת בתי משפט"
|
||||
count={courts.data?.count}
|
||||
/>
|
||||
<SegButton
|
||||
active={view === "committee"}
|
||||
onClick={() => setView("committee")}
|
||||
label="החלטות ועדות ערר"
|
||||
count={committee.data?.count}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table 1 — Court rulings */}
|
||||
{view === "court" && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="text-base font-semibold text-navy">פסיקת בתי משפט</h3>
|
||||
{courts.data && (
|
||||
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||||
{courts.data.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{courts.error ? (
|
||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||
{courts.error.message}
|
||||
@@ -433,17 +564,11 @@ export function LibraryListPanel() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Table 2 — Appeals committee decisions */}
|
||||
{view === "committee" && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="text-base font-semibold text-navy">החלטות ועדות ערר</h3>
|
||||
{committee.data && (
|
||||
<Badge variant="outline" className="text-ink-muted text-[0.72rem]">
|
||||
{committee.data.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{committee.error ? (
|
||||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-4 text-danger text-center text-sm">
|
||||
{committee.error.message}
|
||||
@@ -483,6 +608,7 @@ export function LibraryListPanel() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<PrecedentUploadSheet open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
<PrecedentEditSheet
|
||||
|
||||
@@ -51,6 +51,7 @@ export type Precedent = {
|
||||
citation_formatted: string;
|
||||
extraction_status: string;
|
||||
halacha_extraction_status: string;
|
||||
metadata_extraction_status: string;
|
||||
metadata_extraction_requested_at: string | null;
|
||||
halacha_extraction_requested_at: string | null;
|
||||
created_at: string;
|
||||
@@ -232,12 +233,14 @@ export function isPrecedentActive(p: Precedent): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Metadata extraction has no status column — only the timestamp.
|
||||
// Treat as active only when extraction hasn't yet fully completed
|
||||
// (otherwise stale timestamps linger after success).
|
||||
// Metadata extraction now has its own status column. Active while the
|
||||
// worker is processing the row, or while it's queued (timestamp set) and
|
||||
// hasn't reached a terminal state yet.
|
||||
if (p.metadata_extraction_status === "processing") return true;
|
||||
if (
|
||||
p.metadata_extraction_requested_at !== null &&
|
||||
p.extraction_status !== "completed"
|
||||
p.metadata_extraction_status !== "completed" &&
|
||||
p.metadata_extraction_status !== "failed"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user