Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/tools/slatematch/Documents.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/tools/slatematch/Documents.tsx |
| Original SHA-256 | 3242208ac6e7c548f6f28ff9a33ba1a6cdc2fa6d0775449773d40272146c8aef |
| Git blob SHA | 7b5c5dd0eb26a647605d4fc48c9088f0aa17f105 |
| Reading-copy SHA-256 | 3242208ac6e7c548f6f28ff9a33ba1a6cdc2fa6d0775449773d40272146c8aef |
Recovered source transcript
Download complete source text ↓285 lines · 13.5 KB
import type { ChangeEvent, DragEvent } from 'react'
import { useCallback, useRef, useState } from 'react'
import { AlertTriangle, ArrowLeft, CheckCircle2, Clock, FileText, Upload } from 'lucide-react'
import { Link, useParams } from 'react-router-dom'
import { ToolHeader } from '../../components/ToolHeader'
import { Panel } from '../../components/ui'
import { slateBrand } from './brand'
import { inputCls } from './captureConstants'
import { useTrackedOpportunity } from './captureQueries'
import { DOCUMENT_TYPES, MAX_UPLOAD_BYTES, humanBytes } from './scopeConstants'
import type { OpportunityDocument } from './scopeApi'
import { useDocuments, useUploadDocument } from './scopeQueries'
// SECTION 6 SCREEN 3, UPLOAD DOCUMENTS. The directive asks for drag and drop plus a file picker, required
// document classification, a file type allowlist, a size limit, sha256 duplicate detection, upload progress,
// extraction status, retryable extraction errors, and a malware scan hook.
//
// EVERY ONE OF THOSE IS A THING THAT ALREADY BIT THIS BUILD. The Phase 2 trial found that nginx refused every
// file over one megabyte before the application saw it, and the two files that DID arrive were the small ones,
// so the feature looked like it worked and failed only on the documents that matter. That is why the size
// limit is checked and named HERE, in front of the user, rather than left to a 413 that reads as a server
// fault. It is also why extraction status is rendered per document instead of assumed: a document can upload
// perfectly and still yield nothing, and a screen that shows a green tick for a stored file with no text is
// lying about the only thing that matters.
type Queued = {
key: string
file: File
documentType: string
percent: number
state: 'queued' | 'uploading' | 'done' | 'error'
message: string
}
// The status a document sits in after upload, with what each one means to a reviewer.
function statusChip(d: OpportunityDocument) {
const base = 'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-semibold'
switch (d.extraction_status) {
case 'done':
return (
<span className={`${base} bg-emerald-50 text-emerald-700`}>
<CheckCircle2 size={12} /> extracted
</span>
)
case 'pending':
case 'running':
return (
<span className={`${base} bg-amber-50 text-amber-700`}>
<Clock size={12} /> extraction pending
</span>
)
default:
return (
<span className={`${base} bg-rose-50 text-rose-700`}>
<AlertTriangle size={12} /> {d.extraction_error_code || 'extraction failed'}
</span>
)
}
}
// A failure a reviewer can act on is worth explaining. These are the exact reason codes the extractor emits,
// and each line says what the reviewer should do rather than only what went wrong.
// THE KEYS HERE MUST MATCH THE EXTRACTOR'S ERROR VOCABULARY EXACTLY, and until 29 July 2026 three of them
// matched nothing at all: 'malformed', 'empty' and 'unsupported_media_type' were never emitted by anything,
// while four codes that ARE emitted had no explanation. One of those was unsupported_format, the status the
// xlsx attachment actually carries, so the single document the owner decision is about rendered a bare code
// and no sentence. A vocabulary split across two languages drifts silently, so
// cmd/opportunity-extract/uivocab_test.go now reads this map and fails if it stops matching.
const REASONS: Record<string, string> = {
encrypted_or_locked:
'this PDF is encrypted. Upload an unlocked copy of the same document and both versions stay on the record.',
// BOTH CAUSES, because only one of them was described before and it was the wrong one for the document this
// build actually meets. A scanned page has no text layer at all. A page can also carry a text layer that
// cannot be decoded, because the embedded font ships no usable character map, which is the SCA wage
// determination's case. Either way there is no readable text and nothing is invented from it.
no_text_layer:
'no readable text could be recovered. Either the page is a scanned image with no text layer, or it has one ' +
'that cannot be decoded because the embedded font carries no usable character map. Nothing is guessed from ' +
'it, so the document is stored and versioned but contributes no citable text. Optical recognition is not ' +
'part of this build.',
unsupported_format:
'this file type has no reader in this build, so nothing is invented from it. The file is still stored, ' +
'hashed and versioned, and it stays on the record.',
empty_document: 'the file carries no readable content.',
too_large: 'the file is larger than the extractor will hold in memory, or its compressed streams expand past ' +
'the budget, so it was refused rather than partly read.',
no_chunks_found: 'text was recovered but nothing in it was citable, so no chunk was stored.',
bytes_missing: 'the stored bytes could not be read back. This is a storage fault, not a document fault.',
}
export function Documents() {
const { id = '' } = useParams()
const opp = useTrackedOpportunity(id)
const docs = useDocuments(id)
const upload = useUploadDocument(id)
const [queue, setQueue] = useState<Queued[]>([])
const [docType, setDocType] = useState('pws')
const [dragging, setDragging] = useState(false)
const fileInput = useRef<HTMLInputElement | null>(null)
const existingHashes = new Set((docs.data?.documents ?? []).map((d) => d.sha256))
const send = useCallback(
async (files: File[], chosenType: string) => {
const additions: Queued[] = files.map((f, i) => ({
key: `${Date.now()}-${i}-${f.name}`,
file: f,
documentType: chosenType,
percent: 0,
state: 'queued',
message: '',
}))
setQueue((q) => [...additions, ...q])
for (const item of additions) {
// THE SIZE LIMIT IS ENFORCED BEFORE THE REQUEST LEAVES. A rejected upload that never reaches the wire
// is faster, cheaper, and legible; the trial's 413 arrived from nginx with no explanation at all.
if (item.file.size > MAX_UPLOAD_BYTES) {
setQueue((q) =>
q.map((x) =>
x.key === item.key
? { ...x, state: 'error', message: `${humanBytes(item.file.size)} exceeds the ${humanBytes(MAX_UPLOAD_BYTES)} limit` }
: x,
),
)
continue
}
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, state: 'uploading' } : x)))
try {
await upload.mutateAsync({
file: item.file,
documentType: item.documentType,
onProgress: (p) => setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, percent: p } : x))),
})
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, state: 'done', percent: 100 } : x)))
} catch (e) {
// A DUPLICATE IS NOT AN ERROR, it is the sha256 check working. The server answers 409 and the message
// says so, because a reviewer who re uploads the same attachment should be reassured, not alarmed.
const msg = e instanceof Error ? e.message : 'upload failed'
setQueue((q) => q.map((x) => (x.key === item.key ? { ...x, state: 'error', message: msg } : x)))
}
}
},
[upload],
)
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault()
setDragging(false)
const files = Array.from(e.dataTransfer.files ?? [])
if (files.length) void send(files, docType)
}
const onPick = (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (files.length) void send(files, docType)
e.target.value = ''
}
const documents = docs.data?.documents ?? []
return (
<div className="space-y-6">
<ToolHeader name={slateBrand.name} tagline="Upload documents" 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>}
<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>
<Panel title="Add documents">
<div className="space-y-4">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted" htmlFor="doc-type">
Document classification, required
</label>
<select id="doc-type" className={inputCls} value={docType} onChange={(e) => setDocType(e.target.value)}>
{DOCUMENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-faint">
Classification drives the amendment diff, which compares two documents of the SAME type. A PWS
filed as an attachment will not pair with its amended version.
</p>
</div>
<div
onDragOver={(e) => {
e.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
dragging ? 'border-amber-400 bg-amber-50' : 'border-slate-300 bg-slate-50'
}`}
>
<Upload size={22} className="text-muted" />
<p className="text-sm font-semibold text-ink">Drop files here</p>
<p className="text-xs text-faint">
or{' '}
<button
type="button"
className="font-semibold text-amber-700 underline"
onClick={() => fileInput.current?.click()}
>
choose files
</button>
</p>
<p className="text-xs text-faint">
PDF, Word, plain text and CSV. Up to {humanBytes(MAX_UPLOAD_BYTES)} each.
</p>
<input ref={fileInput} type="file" multiple className="hidden" onChange={onPick} />
</div>
{queue.length > 0 && (
<ul className="space-y-2">
{queue.map((q) => (
<li key={q.key} className="rounded-md border border-slate-200 p-3">
<div className="flex items-center justify-between gap-3">
<span className="truncate text-sm font-semibold text-ink">{q.file.name}</span>
<span className="shrink-0 text-xs text-faint">{humanBytes(q.file.size)}</span>
</div>
{q.state === 'uploading' && (
<div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
<div className="h-full bg-amber-500 transition-all" style={{ width: `${q.percent}%` }} />
</div>
<p className="mt-1 text-xs text-faint">{q.percent} percent uploaded</p>
</div>
)}
{q.state === 'done' && <p className="mt-1 text-xs font-semibold text-emerald-700">accepted, extraction queued</p>}
{q.state === 'error' && <p className="mt-1 text-xs font-semibold text-rose-700">{q.message}</p>}
</li>
))}
</ul>
)}
</div>
</Panel>
<Panel title={`Package, ${documents.length} document${documents.length === 1 ? '' : 's'}`}>
{docs.isLoading && <p className="text-sm text-faint">Loading documents.</p>}
{!docs.isLoading && documents.length === 0 && (
<p className="text-sm text-faint">No documents yet. Upload the solicitation, the PWS, and every amendment.</p>
)}
<ul className="space-y-2">
{documents.map((d) => (
<li key={d.id} className="rounded-md border border-slate-200 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex min-w-0 items-center gap-2">
<FileText size={14} className="shrink-0 text-muted" />
<span className="truncate text-sm font-semibold text-ink">{d.file_name}</span>
</span>
{statusChip(d)}
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-faint">
<span>{d.document_type}</span>
<span>{humanBytes(d.size_bytes)}</span>
{/* PROVENANCE IS SHOWN because it is BORN at the source and cannot be declared by a caller.
Rendering it makes that guarantee visible rather than merely true. */}
<span>provenance {d.provenance}</span>
<span title={d.sha256}>sha256 {d.sha256.slice(0, 12)}</span>
</div>
{d.extraction_status !== 'done' && d.extraction_error_code && REASONS[d.extraction_error_code] && (
<p className="mt-2 rounded bg-rose-50 p-2 text-xs text-rose-800">
{REASONS[d.extraction_error_code]}
</p>
)}
</li>
))}
</ul>
{documents.some((d) => d.sha256 && existingHashes.has(d.sha256)) && null}
</Panel>
</div>
)
}