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 ( extracted ) case 'pending': case 'running': return ( extraction pending ) default: return ( {d.extraction_error_code || 'extraction failed'} ) } } // 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 = { 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([]) const [docType, setDocType] = useState('pws') const [dragging, setDragging] = useState(false) const fileInput = useRef(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) => { e.preventDefault() setDragging(false) const files = Array.from(e.dataTransfer.files ?? []) if (files.length) void send(files, docType) } const onPick = (e: ChangeEvent) => { const files = Array.from(e.target.files ?? []) if (files.length) void send(files, docType) e.target.value = '' } const documents = docs.data?.documents ?? [] 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

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.

{ 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' }`} >

Drop files here

or{' '}

PDF, Word, plain text and CSV. Up to {humanBytes(MAX_UPLOAD_BYTES)} each.

{queue.length > 0 && (
    {queue.map((q) => (
  • {q.file.name} {humanBytes(q.file.size)}
    {q.state === 'uploading' && (

    {q.percent} percent uploaded

    )} {q.state === 'done' &&

    accepted, extraction queued

    } {q.state === 'error' &&

    {q.message}

    }
  • ))}
)}
{docs.isLoading &&

Loading documents.

} {!docs.isLoading && documents.length === 0 && (

No documents yet. Upload the solicitation, the PWS, and every amendment.

)}
    {documents.map((d) => (
  • {d.file_name} {statusChip(d)}
    {d.document_type} {humanBytes(d.size_bytes)} {/* 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. */} provenance {d.provenance} sha256 {d.sha256.slice(0, 12)}
    {d.extraction_status !== 'done' && d.extraction_error_code && REASONS[d.extraction_error_code] && (

    {REASONS[d.extraction_error_code]}

    )}
  • ))}
{documents.some((d) => d.sha256 && existingHashes.has(d.sha256)) && null}
) }