diff --git a/src/components-claims.tsx b/src/components-claims.tsx index bfa92e8..fca1456 100644 --- a/src/components-claims.tsx +++ b/src/components-claims.tsx @@ -3,8 +3,8 @@ // unreviewed claims. Tiers surfaced raw, never scored. Tombstone re-open // via the general claims list. Props in, callbacks out. // =========================================================================== -import { type FC, type ReactNode } from "react"; -import { AlertTriangle, ArrowUpRight, Check, Inbox, RotateCcw, X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type FC, type ReactNode } from "react"; +import { AlertTriangle, ArrowUpRight, Check, Inbox, Loader2, RotateCcw, X } from "lucide-react"; import type { Claim } from "./types"; import { Badge, TierBadge, cn, formatDate, renderValue } from "./_internal"; @@ -20,38 +20,72 @@ export interface ClaimActions { export interface ClaimCardProps extends ClaimActions { claim: Claim; busy?: boolean; + /** Show a selection checkbox (bulk review). */ + selectable?: boolean; + selected?: boolean; + onToggleSelect?: (id: string) => void; className?: string; } -export const ClaimCard: FC = ({ claim, busy, className, onConfirm, onReject, onReopen, onOpenSource }) => { +export const ClaimCard: FC = ({ + claim, + busy, + selectable, + selected, + onToggleSelect, + className, + onConfirm, + onReject, + onReopen, + onOpenSource, +}) => { const rejected = claim.review_state === "rejected"; return ( -
- -
- {rejected ? ( - <> - Rejected {formatDate(claim.updated_at)} - {onReopen && ( - onReopen(claim.claim_id)} busy={busy} icon={}> - Re-open - - )} - - ) : ( - <> - {onConfirm && ( - onConfirm(claim.claim_id)} busy={busy} icon={}> - {claim.status === "proposed" ? "Confirm" : "Mark reviewed"} - - )} - {onReject && ( - onReject(claim.claim_id)} busy={busy} icon={}> - Reject - - )} - +
+
+ {selectable && ( + onToggleSelect?.(claim.claim_id)} + aria-label={`Select claim: ${claim.subject} ${claim.predicate}`} + className="mt-1 size-4 shrink-0 accent-[var(--primary)]" + /> )} +
+ +
+ {rejected ? ( + <> + Rejected {formatDate(claim.updated_at)} + {onReopen && ( + onReopen(claim.claim_id)} busy={busy} icon={}> + Re-open + + )} + + ) : ( + <> + {onConfirm && ( + onConfirm(claim.claim_id)} busy={busy} icon={}> + {claim.status === "proposed" ? "Confirm" : "Mark reviewed"} + + )} + {onReject && ( + onReject(claim.claim_id)} busy={busy} icon={}> + Reject + + )} + + )} +
+
); @@ -178,6 +212,11 @@ export interface ClaimsReviewQueueProps { onKeep?: (winnerId: string, supersedeIds: string[]) => void; onDismissConflict?: (id: string) => void; onOpenSource?: ClaimActions["onOpenSource"]; + /** Providing either enables bulk selection on non-conflict claims. The queue + * owns the selection UI; the app runs the batch and refreshes. Conflicts are + * never bulk-actionable — picking a winner is a per-case human call. */ + onBulkConfirm?: (ids: string[]) => void | Promise; + onBulkReject?: (ids: string[]) => void | Promise; emptyState?: ReactNode; className?: string; } @@ -192,6 +231,8 @@ export const ClaimsReviewQueue: FC = ({ onKeep, onDismissConflict, onOpenSource, + onBulkConfirm, + onBulkReject, emptyState, className, }) => { @@ -207,19 +248,73 @@ export const ClaimsReviewQueue: FC = ({ // (a singleton group, not rendered as a pair) rendered nowhere at all. const rest = claims.filter((c) => c.status !== "proposed" && !inConflict.has(c.claim_id)); + // Bulk selection covers non-conflict, non-rejected claims only. + const bulkEnabled = !!(onBulkConfirm || onBulkReject); + const selectableIds = useMemo( + () => [...proposed, ...rest.filter((c) => c.review_state !== "rejected")].map((c) => c.claim_id), + // eslint-disable-next-line react-hooks/exhaustive-deps + [claims], + ); + const [selected, setSelected] = useState>(new Set()); + const [pending, setPending] = useState(null); + const [running, setRunning] = useState(false); + + // Drop ids that no longer exist after a refresh, so a stale selection can't + // carry a just-actioned claim into the next batch. + useEffect(() => { + setSelected((prev) => { + const live = new Set(selectableIds); + const next = new Set([...prev].filter((id) => live.has(id))); + return next.size === prev.size ? prev : next; + }); + setPending(null) + }, [selectableIds]); + + const toggle = (id: string) => + setSelected((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.has(id)); + const someSelected = selected.size > 0 && !allSelected; + const toggleAll = () => setSelected(allSelected ? new Set() : new Set(selectableIds)); + + async function runBulk(kind: "confirm" | "reject") { + const ids = [...selected]; + if (ids.length === 0) return; + setRunning(true); + try { + await (kind === "confirm" ? onBulkConfirm : onBulkReject)?.(ids); + setSelected(new Set()); + setPending(null); + } finally { + setRunning(false); + } + } + if (claims.length === 0) { return <>{emptyState ?? }; } + const selectProps = (c: Claim) => + bulkEnabled && c.review_state !== "rejected" + ? { selectable: true, selected: selected.has(c.claim_id), onToggleSelect: toggle } + : {}; + return ( -
+
+ {bulkEnabled && selectableIds.length > 0 && ( + + )} +
{proposed.map((c) => ( - + ))}
-
+
{conflictGroups.map((group) => ( c.claim_id).join("+")} @@ -235,13 +330,130 @@ export const ClaimsReviewQueue: FC = ({
{rest.map((c) => ( - + ))}
+ + {bulkEnabled && selected.size > 0 && ( + setPending(null)} + onRun={runBulk} + onClear={() => setSelected(new Set())} + /> + )}
); }; +const SelectAll: FC<{ checked: boolean; indeterminate: boolean; count: number; onToggle: () => void }> = ({ + checked, + indeterminate, + count, + onToggle, +}) => { + const ref = useRef(null); + useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate; + }, [indeterminate]); + return ( + + ); +}; + +const BulkBar: FC<{ + count: number; + pending: null | "confirm" | "reject"; + running: boolean; + canConfirm: boolean; + canReject: boolean; + onAsk: (kind: "confirm" | "reject") => void; + onCancel: () => void; + onRun: (kind: "confirm" | "reject") => void; + onClear: () => void; +}> = ({ count, pending, running, canConfirm, canReject, onAsk, onCancel, onRun, onClear }) => ( +
+
+ {pending ? ( + <> + + {pending === "confirm" ? "Confirm" : "Reject"} {count} claim{count === 1 ? "" : "s"}? + + onRun(pending)}> + {running ? : pending === "confirm" ? : } + {running ? "Working…" : "Yes"} + + + Cancel + + + ) : ( + <> + + {count} selected + + {canConfirm && ( + onAsk("confirm")} data-action="knowledge-claims-bulk-confirm"> + Confirm + + )} + {canReject && ( + onAsk("reject")} data-action="knowledge-claims-bulk-reject"> + Reject + + )} + Clear + + )} +
+
+); + +const BarBtn: FC<{ + children: ReactNode; + onClick: () => void; + primary?: boolean; + danger?: boolean; + disabled?: boolean; + "data-action"?: string; +}> = ({ children, onClick, primary, danger, disabled, ...rest }) => ( + +); + function groupConflicts(claims: Claim[], byId: Map): Claim[][] { const seen = new Set(); const groups: Claim[][] = [];