import { useState } from 'react' import { ArrowLeft, Check, GitMerge, Plus, Scissors, X } from 'lucide-react' import { Link, useParams } from 'react-router-dom' import { ToolHeader } from '../../components/ToolHeader' import { Modal, Panel } from '../../components/ui' import { slateBrand } from './brand' import { inputCls } from './captureConstants' import { useTrackedOpportunity } from './captureQueries' import { SCOPE_TYPES, STAFFING_SCOPE_TYPES } from './scopeConstants' import type { ScopeItem } from './scopeApi' import { useAddManualScope, useMergeScopeItems, useReviewScopeItem, useScope, useSplitScopeItem, } from './scopeQueries' // SECTION 6 SCREEN 4, REVIEW SCOPE. For every extracted item show the text, document name, page or section, // extraction method, and confidence, with accept, edit and accept, reject, split, merge, and add manually. // // THE CITATION IS THE PRODUCT. Every machine produced row on this screen carries the span it came from, and // the schema refuses one that does not. So the citation is rendered on every row, not tucked behind a hover: // a reviewer accepting an item is vouching for it, and they cannot do that against text they cannot see. // // NOTHING HERE CAN REACH THE SCORING ENGINE. Every action on this screen writes scope, and scope becomes a // requirement only through the promotion gate on the next screen, which is held one capability higher. That // separation is the whole safety design and the screen says so out loud. const ACTIVE = new Set(['extracted', 'accepted', 'edited', 'manual']) function confidenceLabel(c: number): string { if (c >= 0.999) return 'certain, literal copy' if (c >= 0.8) return `${Math.round(c * 100)} percent` return `${Math.round(c * 100)} percent, low` } function statusTone(s: string): string { switch (s) { case 'accepted': return 'bg-emerald-50 text-emerald-700' case 'edited': return 'bg-sky-50 text-sky-700' case 'manual': return 'bg-violet-50 text-violet-700' case 'rejected': return 'bg-slate-100 text-slate-500' case 'split': case 'merged': return 'bg-slate-100 text-slate-500' default: return 'bg-amber-50 text-amber-700' } } export function ScopeReview() { const { id = '' } = useParams() const opp = useTrackedOpportunity(id) const scope = useScope(id) const review = useReviewScopeItem(id) const split = useSplitScopeItem(id) const merge = useMergeScopeItems(id) const addManual = useAddManualScope(id) const [editing, setEditing] = useState(null) const [editTitle, setEditTitle] = useState('') const [editDesc, setEditDesc] = useState('') const [splitting, setSplitting] = useState(null) const [splitParts, setSplitParts] = useState(['', '']) const [merging, setMerging] = useState(false) const [selected, setSelected] = useState>(new Set()) const [mergeTitle, setMergeTitle] = useState('') const [adding, setAdding] = useState(false) const [newType, setNewType] = useState('task') const [newTitle, setNewTitle] = useState('') const [newDesc, setNewDesc] = useState('') const [err, setErr] = useState('') const items = scope.data ?? [] const live = items.filter((i) => ACTIVE.has(i.review_status)) const retired = items.filter((i) => !ACTIVE.has(i.review_status)) const run = async (fn: () => Promise) => { setErr('') try { await fn() } catch (e) { setErr(e instanceof Error ? e.message : 'the action failed') } } const toggle = (itemId: string) => { setSelected((s) => { const next = new Set(s) if (next.has(itemId)) next.delete(itemId) else next.add(itemId) return next }) } return (
{/* Which opportunity this is. Every one of these screens is scoped to a single pursuit, and a reviewer arriving from a link needs to know which one before they start accepting things. */} {opp.data?.title &&

{opp.data.title}

}
Back to the opportunity
Build requirements
{err &&

{err}

} {merging && (

Select two or more items. The FIRST one you selected survives and keeps its own citation; the others are retired to merged and point at it. Nothing is deleted.

setMergeTitle(e.target.value)} placeholder="What the combined item now covers" />
)} {scope.isLoading &&

Loading scope.

} {!scope.isLoading && live.length === 0 && (

No scope yet. Upload the package and let extraction run, or add an item manually.

)}
    {live.map((it) => (
  • {merging && ( toggle(it.id)} aria-label={`select ${it.title}`} /> )}
    {it.title || '(no title)'} {it.review_status} {it.scope_type} {STAFFING_SCOPE_TYPES.has(it.scope_type) && ( staffing )}
    {it.description &&

    {it.description}

    } {/* THE CITATION. Rendered on every row because a reviewer cannot vouch for text they cannot see, and because the schema refuses a machine item that has none. */}
    {it.source_section_label || 'no section label'} {it.source_page_number > 0 && page {it.source_page_number}} {it.extraction_method || 'method not recorded'} {confidenceLabel(it.extraction_confidence)} {it.source_quote_hash && quote {it.source_quote_hash.slice(0, 10)}} {!it.source_document_id && !it.source_quote_hash && it.review_status === 'manual' && ( authored by a human, no citation needed )}
    {!merging && (
    )}
  • ))}
{retired.length > 0 && (

Rejected, split and merged items are kept, never deleted, so the review history stays readable.

    {retired.map((it) => (
  • {it.review_status} {it.title || '(no title)'}
  • ))}
)} {editing && ( setEditing(null)}>

An edit that changes the text records the item as edited rather than accepted, so a later reader can tell what the machine said from what a human rewrote. The citation does not change.

setEditTitle(e.target.value)} />