Merge pull request 'feat(ui): תור-אישור הלכות מאוחד — 2 תצוגות לפי פעולה (#133)' (#227) from worktree-halacha-queue-unified into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 50s
G12 Leak-Guard / leak-guard (push) Successful in 5s

This commit was merged in pull request #227.
This commit is contained in:
2026-06-12 07:30:11 +00:00
2 changed files with 68 additions and 60 deletions

View File

@@ -10,7 +10,8 @@ import { Textarea } from "@/components/ui/textarea";
import { CorroborationBadge } from "./corroboration-badge"; import { CorroborationBadge } from "./corroboration-badge";
import { practiceAreaLabel } from "./practice-area"; import { practiceAreaLabel } from "./practice-area";
import { import {
useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot, type Halacha, useHalachotPending, useHalachotByStatus, useUpdateHalacha, useBatchReviewHalachot,
isExtractionFixItem, type Halacha,
} from "@/lib/api/precedent-library"; } from "@/lib/api/precedent-library";
import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta"; import { AuthorityBadge, ruleTypeLabel } from "./halacha-meta";
@@ -628,23 +629,29 @@ function RestorePanel({
// ─── Pending queue panel (main review flow) ─────────────────────────────────── // ─── Pending queue panel (main review flow) ───────────────────────────────────
function PendingPanel() { function PendingPanel() {
// #84.1 — "clean" = quality-gated + prioritized + clustered review queue; // #133 unified queue — ONE fetch of all pending, split client-side by ACTION:
// "needsfix" = the flagged 'needs extraction fix' bucket. // "judgment" = items the panel deliberated (or clean) → chair approves/rejects;
const [view, setView] = useState<"clean" | "needsfix">("clean"); // "fix" = flagged-but-never-adjudicated → extraction repair. (No empty "תור נקי".)
const { data, isPending, error } = useHalachotPending({ const [view, setView] = useState<"judgment" | "fix">("judgment");
limit: 500, needsFix: view === "needsfix", const { data, isPending, error } = useHalachotPending({ limit: 500 });
});
const update = useUpdateHalacha(); const update = useUpdateHalacha();
const batch = useBatchReviewHalachot(); const batch = useBatchReviewHalachot();
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const groups = useMemo<Group[]>( const allItems = useMemo(() => data?.items ?? [], [data]);
() => buildGroups(data?.items ?? []), const judgmentCount = useMemo(
[data], () => allItems.filter((h) => !isExtractionFixItem(h)).length, [allItems]);
); const fixCount = useMemo(
() => allItems.filter(isExtractionFixItem).length, [allItems]);
const totalCount = data?.items.length ?? 0; const segmentItems = useMemo(
() => allItems.filter((h) =>
view === "fix" ? isExtractionFixItem(h) : !isExtractionFixItem(h)),
[allItems, view],
);
const groups = useMemo<Group[]>(() => buildGroups(segmentItems), [segmentItems]);
const totalCount = segmentItems.length;
const visibleItems = useMemo<ReviewItem[]>(() => { const visibleItems = useMemo<ReviewItem[]>(() => {
const out: ReviewItem[] = []; const out: ReviewItem[] = [];
@@ -766,24 +773,26 @@ function PendingPanel() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visibleItems]); }, [focused, visibleItems]);
const segBtn = (key: "judgment" | "fix", label: string, count: number) => (
<Button
size="sm"
variant={view === key ? "default" : "ghost"}
className={view === key ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView(key)}
>
{label}
<span className={`ms-2 text-[0.7rem] px-1.5 py-0.5 rounded-full tabular-nums
${view === key ? "bg-navy/15" : "bg-black/10 text-ink-muted"}`}>
{count}
</span>
</Button>
);
// #133 — two segments by ACTION (deliberated→judge vs flagged→fix); no empty "תור נקי".
const viewToggle = ( const viewToggle = (
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit"> <div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
<Button {segBtn("judgment", "להכרעתך", judgmentCount)}
size="sm" {segBtn("fix", "דורש תיקון-חילוץ", fixCount)}
variant={view === "clean" ? "default" : "ghost"}
className={view === "clean" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("clean")}
>
תור נקי
</Button>
<Button
size="sm"
variant={view === "needsfix" ? "default" : "ghost"}
className={view === "needsfix" ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setView("needsfix")}
>
דורש תיקון-חילוץ
</Button>
</div> </div>
); );
@@ -804,14 +813,14 @@ function PendingPanel() {
body = ( body = (
<div className="text-center text-ink-muted py-16"> <div className="text-center text-ink-muted py-16">
<p className="text-lg"> <p className="text-lg">
{view === "needsfix" {view === "fix"
? "בקט התיקון ריק — אין הלכות מסומנות-איכות." ? "אין הלכות הממתינות לתיקון-חילוץ."
: "אין הלכות נקיות הממתינות לאישור."} : "אין הלכות הממתינות להכרעתך."}
</p> </p>
<p className="text-sm mt-2"> <p className="text-sm mt-2">
{view === "needsfix" {view === "fix"
? "פריטים שסומנו (ציטוט לא-מאומת, יישום, כפילות-קרובה וכו') יופיעו כאן." ? "פריטים שסומנו בפגם-חילוץ (ציטוט לא-מאומת / קטוע / כפילות) ושלא עברו התלבטות-פאנל יופיעו כאן."
: עלה פסיקה חדשה — ההלכות שיחולצו ממנה יופיעו כאן."} : לכות שהפאנל הכריע או דן בהן יופיעו כאן לאישורך."}
</p> </p>
</div> </div>
); );
@@ -821,8 +830,11 @@ function PendingPanel() {
<div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap"> <div className="flex items-center gap-3 text-sm text-ink-muted flex-wrap">
<span> <span>
<span className="text-navy font-semibold">{totalCount}</span> <span className="text-navy font-semibold">{totalCount}</span>
{view === "needsfix" ? " מסומנות" : " ממתינות"} {view === "fix" ? " לתיקון-חילוץ" : " להכרעה"}
{" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות {" "}ב-<span className="text-navy font-semibold">{groups.length}</span> פסיקות
{view === "judgment" && (
<span className="text-[0.72rem] ms-2">· ממוין: פיצול-פאנל תחילה</span>
)}
</span> </span>
<span className="me-auto text-[0.72rem]"> <span className="me-auto text-[0.72rem]">
ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd> ניווט: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>

View File

@@ -603,37 +603,33 @@ export function useRequestHalachotExtraction() {
/** #84.1/#84.2/#84.3 — the chair review queue. /** #84.1/#84.2/#84.3 — the chair review queue.
* *
* Default ("clean") view: quality-gated (flagged items hidden), priority-ordered * ONE fetch of the whole pending set (#133 unified queue): all pending halachot,
* (most-uncertain/negatively-treated first), and near-duplicate-clustered into * priority-ordered (panel-split first → most-uncertain → oldest, #84.3/FU-3),
* one card. Pass `needsFix: true` for the 'needs extraction fix' bucket — every * near-duplicate-clustered, with the latest panel deliberation attached. The
* pending item carrying a quality flag (filtered client-side). */ * review panel splits this client-side by ACTION — "להכרעה" (has a panel round)
export function useHalachotPending( * vs "תיקון-חילוץ" (flagged, never adjudicated) — instead of the old empty
opts: { limit?: number; needsFix?: boolean } = {}, * clean/needsfix toggle. */
) { export function useHalachotPending(opts: { limit?: number } = {}) {
const { limit = 200, needsFix = false } = opts; const { limit = 200 } = opts;
const qs = needsFix const qs = `review_status=pending_review&exclude_low_quality=false`
? `review_status=pending_review&exclude_low_quality=false`
+ `&include_panel_round=true&limit=${limit}`
: `review_status=pending_review&exclude_low_quality=true`
+ `&order_by_priority=true&cluster=true&include_equivalents=true` + `&order_by_priority=true&cluster=true&include_equivalents=true`
+ `&include_panel_round=true&limit=${limit}`; + `&include_panel_round=true&limit=${limit}`;
return useQuery({ return useQuery({
queryKey: [...libraryKeys.halachotPending(), needsFix ? "needsfix" : "clean"], queryKey: libraryKeys.halachotPending(),
queryFn: async ({ signal }) => { queryFn: ({ signal }) =>
const res = await apiRequest<{ items: Halacha[]; count: number }>( apiRequest<{ items: Halacha[]; count: number }>(`/api/halachot?${qs}`, { signal }),
`/api/halachot?${qs}`,
{ signal },
);
if (!needsFix) return res;
// needs-fix bucket = pending items that carry a quality flag
const items = res.items.filter((h) => (h.quality_flags?.length ?? 0) > 0);
return { items, count: items.length };
},
staleTime: 5_000, staleTime: 5_000,
refetchOnMount: "always", refetchOnMount: "always",
}); });
} }
/** A pending item belongs in the "needs extraction fix" segment when it carries a
* quality flag AND the panel never deliberated it (no round). Everything else —
* deliberated items and clean items — is a chair-judgment item. (#133 unified queue) */
export function isExtractionFixItem(h: Halacha): boolean {
return !h.panel_round && (h.quality_flags?.length ?? 0) > 0;
}
export function useHalachotByStatus(status: string, limit = 300) { export function useHalachotByStatus(status: string, limit = 300) {
return useQuery({ return useQuery({
queryKey: libraryKeys.halachot({ review_status: status, limit: String(limit) }), queryKey: libraryKeys.halachot({ review_status: status, limit: String(limit) }),