Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/tools/slatematch/ScopeReview.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/tools/slatematch/ScopeReview.tsx |
| Original SHA-256 | 18952e43aa24b0d398d81d660cb756dd2b368f191b03db585e0c377a2a4695f2 |
| Git blob SHA | cca05c21d03bcbf626d4668159cbf750346f8a8a |
| Reading-copy SHA-256 | 18952e43aa24b0d398d81d660cb756dd2b368f191b03db585e0c377a2a4695f2 |
Recovered source transcript
Download complete source text ↓461 lines · 19.6 KB
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<ScopeItem | null>(null)
const [editTitle, setEditTitle] = useState('')
const [editDesc, setEditDesc] = useState('')
const [splitting, setSplitting] = useState<ScopeItem | null>(null)
const [splitParts, setSplitParts] = useState<string[]>(['', ''])
const [merging, setMerging] = useState(false)
const [selected, setSelected] = useState<Set<string>>(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<unknown>) => {
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 (
<div className="space-y-6">
<ToolHeader name={slateBrand.name} tagline="Review scope" accent={slateBrand.accent} />
{/* 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 && <p className="text-sm font-semibold text-ink">{opp.data.title}</p>}
<div className="flex flex-wrap items-center justify-between gap-3">
<Link to={`/slate/capture/${id}`} className="inline-flex items-center gap-1.5 text-sm font-semibold text-muted hover:text-ink">
<ArrowLeft size={14} /> Back to the opportunity
</Link>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-300 px-3 py-1.5 text-sm font-semibold text-ink hover:bg-slate-50"
>
<Plus size={14} /> Add manually
</button>
<button
type="button"
onClick={() => {
setMerging((m) => !m)
setSelected(new Set())
}}
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-semibold ${
merging ? 'bg-amber-500 text-white' : 'border border-slate-300 text-ink hover:bg-slate-50'
}`}
>
<GitMerge size={14} /> {merging ? 'Cancel merge' : 'Merge items'}
</button>
<Link
to={`/slate/capture/${id}/requirements`}
className="inline-flex items-center gap-1.5 rounded-md bg-navy px-3 py-1.5 text-sm font-semibold text-white hover:opacity-90"
>
Build requirements
</Link>
</div>
</div>
{err && <p className="rounded bg-rose-50 p-3 text-sm font-semibold text-rose-800">{err}</p>}
{merging && (
<Panel title="Merge">
<p className="text-sm text-muted">
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.
</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div className="min-w-64 flex-1">
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="merge-title">
Combined title
</label>
<input
id="merge-title"
className={inputCls}
value={mergeTitle}
onChange={(e) => setMergeTitle(e.target.value)}
placeholder="What the combined item now covers"
/>
</div>
<button
type="button"
disabled={selected.size < 2 || !mergeTitle.trim() || merge.isPending}
onClick={() => {
const ids = Array.from(selected)
void run(async () => {
await merge.mutateAsync({ itemId: ids[0], absorb_item_ids: ids.slice(1), title: mergeTitle })
setMerging(false)
setSelected(new Set())
setMergeTitle('')
})
}}
className="rounded-md bg-navy px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
>
Merge {selected.size} selected
</button>
</div>
</Panel>
)}
<Panel title={`Queue, ${live.length} item${live.length === 1 ? '' : 's'}`}>
{scope.isLoading && <p className="text-sm text-faint">Loading scope.</p>}
{!scope.isLoading && live.length === 0 && (
<p className="text-sm text-faint">
No scope yet. Upload the package and let extraction run, or add an item manually.
</p>
)}
<ul className="space-y-3">
{live.map((it) => (
<li key={it.id} className="rounded-md border border-slate-200 p-3">
<div className="flex items-start gap-3">
{merging && (
<input
type="checkbox"
className="mt-1 h-4 w-4"
checked={selected.has(it.id)}
onChange={() => toggle(it.id)}
aria-label={`select ${it.title}`}
/>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-ink">{it.title || '(no title)'}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${statusTone(it.review_status)}`}>
{it.review_status}
</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600">
{it.scope_type}
</span>
{STAFFING_SCOPE_TYPES.has(it.scope_type) && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">
staffing
</span>
)}
</div>
{it.description && <p className="mt-1 whitespace-pre-wrap text-sm text-muted">{it.description}</p>}
{/* 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. */}
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-faint">
<span>{it.source_section_label || 'no section label'}</span>
{it.source_page_number > 0 && <span>page {it.source_page_number}</span>}
<span>{it.extraction_method || 'method not recorded'}</span>
<span>{confidenceLabel(it.extraction_confidence)}</span>
{it.source_quote_hash && <span title={it.source_quote_hash}>quote {it.source_quote_hash.slice(0, 10)}</span>}
{!it.source_document_id && !it.source_quote_hash && it.review_status === 'manual' && (
<span className="font-semibold text-violet-700">authored by a human, no citation needed</span>
)}
</div>
{!merging && (
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void run(() => review.mutateAsync({ itemId: it.id, patch: { review_status: 'accepted' } }))}
className="inline-flex items-center gap-1 rounded border border-emerald-300 px-2 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-50"
>
<Check size={12} /> Accept
</button>
<button
type="button"
onClick={() => {
setEditing(it)
setEditTitle(it.title)
setEditDesc(it.description)
}}
className="rounded border border-sky-300 px-2 py-1 text-xs font-semibold text-sky-700 hover:bg-sky-50"
>
Edit and accept
</button>
<button
type="button"
onClick={() => void run(() => review.mutateAsync({ itemId: it.id, patch: { review_status: 'rejected' } }))}
className="inline-flex items-center gap-1 rounded border border-rose-300 px-2 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-50"
>
<X size={12} /> Reject
</button>
<button
type="button"
onClick={() => {
setSplitting(it)
setSplitParts([it.title, ''])
}}
className="inline-flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs font-semibold text-ink hover:bg-slate-50"
>
<Scissors size={12} /> Split
</button>
</div>
)}
</div>
</div>
</li>
))}
</ul>
</Panel>
{retired.length > 0 && (
<Panel title={`Retired, ${retired.length}`}>
<p className="mb-2 text-xs text-faint">
Rejected, split and merged items are kept, never deleted, so the review history stays readable.
</p>
<ul className="space-y-1">
{retired.map((it) => (
<li key={it.id} className="flex flex-wrap items-center gap-2 text-sm text-slate-500">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${statusTone(it.review_status)}`}>
{it.review_status}
</span>
<span className="line-through">{it.title || '(no title)'}</span>
</li>
))}
</ul>
</Panel>
)}
{editing && (
<Modal open title="Edit and accept" onClose={() => setEditing(null)}>
<div className="space-y-3">
<p className="text-xs text-faint">
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.
</p>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="edit-title">
Title
</label>
<input id="edit-title" className={inputCls} value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="edit-desc">
Description
</label>
<textarea
id="edit-desc"
rows={5}
className={inputCls}
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setEditing(null)} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm font-semibold">
Cancel
</button>
<button
type="button"
disabled={!editTitle.trim()}
onClick={() =>
void run(async () => {
await review.mutateAsync({
itemId: editing.id,
patch: { review_status: 'edited', title: editTitle, description: editDesc },
})
setEditing(null)
})
}
className="rounded-md bg-navy px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-40"
>
Save and accept
</button>
</div>
</div>
</Modal>
)}
{splitting && (
<Modal open title="Split into separate items" onClose={() => setSplitting(null)}>
<div className="space-y-3">
<p className="text-xs text-faint">
Each part inherits this item's citation, so every child stays grounded in the same source span. The
original is retired to split and kept.
</p>
{splitParts.map((p, i) => (
<div key={i}>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor={`part-${i}`}>
Part {i + 1}
</label>
<input
id={`part-${i}`}
className={inputCls}
value={p}
onChange={(e) => setSplitParts((arr) => arr.map((x, j) => (j === i ? e.target.value : x)))}
/>
</div>
))}
<button
type="button"
onClick={() => setSplitParts((a) => [...a, ''])}
className="text-sm font-semibold text-amber-700 underline"
>
Add another part
</button>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setSplitting(null)} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm font-semibold">
Cancel
</button>
<button
type="button"
disabled={splitParts.filter((p) => p.trim()).length < 2}
onClick={() =>
void run(async () => {
await split.mutateAsync({
itemId: splitting.id,
parts: splitParts.filter((p) => p.trim()).map((title) => ({ title })),
})
setSplitting(null)
})
}
className="rounded-md bg-navy px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-40"
>
Split into {splitParts.filter((p) => p.trim()).length}
</button>
</div>
</div>
</Modal>
)}
{adding && (
<Modal open title="Add a scope item manually" onClose={() => setAdding(false)}>
<div className="space-y-3">
<p className="text-xs text-faint">
A manual item needs no citation, because you are the source. It is recorded as manual so it is never
mistaken for something extraction found.
</p>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="new-type">
Scope type
</label>
<select id="new-type" className={inputCls} value={newType} onChange={(e) => setNewType(e.target.value)}>
{SCOPE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="new-title">
Title
</label>
<input id="new-title" className={inputCls} value={newTitle} onChange={(e) => setNewTitle(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="new-desc">
Description
</label>
<textarea id="new-desc" rows={4} className={inputCls} value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setAdding(false)} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm font-semibold">
Cancel
</button>
<button
type="button"
disabled={!newTitle.trim()}
onClick={() =>
void run(async () => {
await addManual.mutateAsync({ scope_type: newType, title: newTitle, description: newDesc })
setAdding(false)
setNewTitle('')
setNewDesc('')
})
}
className="rounded-md bg-navy px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-40"
>
Add item
</button>
</div>
</div>
</Modal>
)}
</div>
)
}