Add Scene 7 claim report workflow
This commit is contained in:
+108
-15
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { BookOpen, Building2, CalendarClock, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||
import { AdminPanel } from './admin'
|
||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||
import { documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||
@@ -20,7 +20,7 @@ function screenshotFile(file: File, index: number) {
|
||||
}
|
||||
function briefAcknowledgementKey(levelId: string) { return `gupi-osint-board:brief-acknowledged:${levelId}` }
|
||||
function documentSearchText(document: CaseDocument) {
|
||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.fileName, document.mimeType,
|
||||
return [document.title, document.fileType, document.publishedAt, document.capturedAt, document.sourceCitation,document.sourceUri,document.fileName, document.mimeType,
|
||||
...document.body, ...document.regions.flatMap(region => [region.label, region.excerpt, region.date]),
|
||||
...Object.entries(document.metadata).flatMap(([key, value]) => [key, value])].filter(Boolean).join('\n').toLocaleLowerCase()
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export function App() {
|
||||
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
|
||||
const [newPartyDraft, setNewPartyDraft] = useState<PartyExhibit | null>(null)
|
||||
const [briefOpen, setBriefOpen] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const [playerName, setPlayerName] = useState('Player')
|
||||
const [editingBrief, setEditingBrief] = useState(false)
|
||||
const [editingTimeline, setEditingTimeline] = useState(false)
|
||||
const [recentlyCreatedExhibitId, setRecentlyCreatedExhibitId] = useState<string | null>(null)
|
||||
@@ -98,7 +100,9 @@ export function App() {
|
||||
useEffect(() => {
|
||||
if (adminRoute) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false))
|
||||
fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => {
|
||||
setIsAdmin(Boolean(session?.isAdmin)); setPlayerName(String(session?.playerName || 'Player'))
|
||||
}).catch(() => setIsAdmin(false))
|
||||
|
||||
const pathLevel = window.location.pathname.match(/^\/level\/(.+)$/)
|
||||
const deepLinkLevel = params.get('level') || (pathLevel ? decodeURIComponent(pathLevel[1]) : null)
|
||||
@@ -271,7 +275,7 @@ export function App() {
|
||||
setLinkFrom(null); setThreadDraft(existing); setStatus('THREAD ALREADY EXISTS · EDITING TAG')
|
||||
return
|
||||
}
|
||||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||
setThreadDraft({ id: uid('connection'), fromExhibitId: linkFrom, toExhibitId: targetId, label:'Proof that…',tightness: 65, tagStyle: 'luggage', tagPosition: 50, tagOffset: 0 })
|
||||
setLinkFrom(null)
|
||||
if (caseState.exhibits.some(item => item.id === targetId)) setSelected(targetId)
|
||||
}
|
||||
@@ -386,13 +390,13 @@ export function App() {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ documentIds: [document.id] }),
|
||||
})
|
||||
const deterministicCompletion = analysis.goals.find(goal => goal.newlyCompleted)
|
||||
if (deterministicCompletion) setCompletedGoal(deterministicCompletion)
|
||||
if (deterministicCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(deterministicCompletion)
|
||||
if (analysis.awardedFlags.length) {
|
||||
window.clearTimeout(saveTimer.current)
|
||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||
await loadLevelBySlug(caseState.id, editQuery)
|
||||
setSelected(document.id)
|
||||
setStatus(deterministicCompletion ? 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||
setStatus(deterministicCompletion ? caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE' : `EVIDENCE MATCHED · ${analysis.awardedFlags.join(', ').toUpperCase()} · NEW MATERIAL UNLOCKED`)
|
||||
} else if (analysis.matchedFlags.length) setStatus('EVIDENCE MATCHED · ACHIEVEMENT ALREADY RECORDED')
|
||||
else if (analysis.extractionStatus === 'succeeded' && analysis.goals.some(goal => goal.status === 'pending')) {
|
||||
setStatus('TEXT EXTRACTED · CHECKING SOURCE CLAIM…')
|
||||
@@ -401,13 +405,13 @@ export function App() {
|
||||
const semantic = await judgeResponse.json() as DocumentSemanticAnalysis
|
||||
const semanticCompletion = semantic.goals.find(goal => goal.newlyCompleted)
|
||||
if (semanticCompletion || semantic.awardedFlags.length) {
|
||||
if (semanticCompletion) setCompletedGoal(semanticCompletion)
|
||||
if (semanticCompletion && !caseState.report?.requiredForCompletion) setCompletedGoal(semanticCompletion)
|
||||
window.clearTimeout(saveTimer.current)
|
||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||
await loadLevelBySlug(caseState.id, editQuery)
|
||||
setSelected(document.id)
|
||||
}
|
||||
if (semanticCompletion) setStatus('SOURCE VERIFIED · OBJECTIVE COMPLETE')
|
||||
if (semanticCompletion) setStatus(caseState.report?.requiredForCompletion ? 'SOURCE VERIFIED · CONNECT IT TO THE CLAIM AND FILE YOUR REPORT' : 'SOURCE VERIFIED · OBJECTIVE COMPLETE')
|
||||
else if (semantic.subject === 'related' && semantic.supportsClaim) setStatus('RELATED DISCOVERY FOUND · STILL NEED PROOF ABOUT NILS')
|
||||
else if (semantic.status === 'unavailable') setStatus('SCREENSHOT SAVED · AUTOMATIC CLAIM REVIEW UNAVAILABLE')
|
||||
else if (semantic.status === 'pending') setStatus('SCREENSHOT SAVED · CLAIM REVIEW STILL RUNNING')
|
||||
@@ -437,6 +441,42 @@ export function App() {
|
||||
} finally { setAdvancing(false) }
|
||||
}
|
||||
|
||||
const openCaseReport = async () => {
|
||||
if (!caseState?.report) return
|
||||
window.clearTimeout(saveTimer.current)
|
||||
const editQuery = requestedEditMode && caseState.editingAllowed ? '?edit=1' : ''
|
||||
setStatus('COMPILING CASE REPORT…')
|
||||
const saved = await fetch(`/api/levels/${encodeURIComponent(caseState.id)}${editQuery}`, {
|
||||
method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(caseState),
|
||||
})
|
||||
if (saved.ok) await loadLevelBySlug(caseState.id,editQuery)
|
||||
setReportOpen(true)
|
||||
setStatus(saved.ok ? 'CASE REPORT COMPILED FROM BOARD' : 'CASE REPORT OPEN · LATEST SERVER COPY')
|
||||
}
|
||||
|
||||
const submitReport = async (input:CaseReportSubmissionInput) => {
|
||||
if (!caseState?.report) throw new Error('Case report unavailable')
|
||||
setStatus('SUBMITTING CASE REPORT…')
|
||||
const response=await fetch(`/api/levels/${encodeURIComponent(caseState.id)}/report/submissions`,{
|
||||
method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(input),
|
||||
})
|
||||
if (!response.ok) { const body=await response.json().catch(() => ({})); throw new Error(body.error || 'Report submission failed') }
|
||||
const report=await response.json() as CaseReport
|
||||
const reportEvidence=report.claims.flatMap(claim => claim.evidence)
|
||||
const byDocument=new Map(reportEvidence.map(item => [item.documentExhibitId,item]))
|
||||
const byConnection=new Map(reportEvidence.map(item => [item.connectionId,item]))
|
||||
setCaseState(current => current ? {
|
||||
...current,report,
|
||||
exhibits:current.exhibits.map(exhibit => exhibit.type === 'document' && byDocument.has(exhibit.id)
|
||||
? { ...exhibit,publishedAt:byDocument.get(exhibit.id)!.publishedAt,sourceCitation:byDocument.get(exhibit.id)!.sourceCitation,sourceUri:byDocument.get(exhibit.id)!.sourceUri,displayNumber:byDocument.get(exhibit.id)!.displayNumber }
|
||||
: exhibit),
|
||||
connections:current.connections.map(connection => byConnection.has(connection.id)
|
||||
? { ...connection,label:byConnection.get(connection.id)!.relationText || undefined } : connection),
|
||||
} : current)
|
||||
setStatus(report.status === 'accepted' ? 'CASE REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED FOR REVISION' : 'REPORT NEEDS SUPPORTING EVIDENCE')
|
||||
return report
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!caseState) return
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
@@ -464,6 +504,7 @@ export function App() {
|
||||
const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
|
||||
const pendingGoalCount = caseState.goals.filter(goal => goal.status === 'pending').length
|
||||
const briefAttentionCount = unresolvedConceptCount + pendingGoalCount
|
||||
const reportAttentionCount = caseState.report?.requiredForCompletion && caseState.report.status !== 'accepted' ? 1 : 0
|
||||
const activeGoal = caseState.goals.find(goal => goal.status === 'pending') || caseState.goals[0]
|
||||
const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed)
|
||||
const temporalItems: TemporalItem[] = caseState.exhibits.flatMap(exhibit => exhibitWidget(exhibit.type).temporalFacts(exhibit).map(fact => {
|
||||
@@ -484,6 +525,7 @@ export function App() {
|
||||
<nav>
|
||||
<button className={docsOpen ? 'active' : ''} onClick={() => setDocsOpen(true)}>EVIDENCE</button>
|
||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
||||
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||
@@ -511,7 +553,7 @@ export function App() {
|
||||
<div className="doc-list">
|
||||
{filteredDocuments.map((doc, index) => <button className={`doc-row ${selected === doc.id ? 'selected' : ''} ${arrivingExhibitIds.includes(doc.id) ? 'arriving' : ''}`} data-document-row-id={doc.id} data-temporal-id={`document:${doc.id}`} key={doc.id} title="Click to locate on board · double-click to open" onDoubleClick={() => setOpenDoc(doc)} onClick={() => setSelected(current => current === doc.id ? null : doc.id)}>
|
||||
<div className={`doc-icon tint-${index % 3}`}><FileText size={24}/><b>{doc.fileType.slice(0, 3)}</b></div>
|
||||
<div><strong>{doc.title}</strong><span>{documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||
<div><strong>{doc.title}</strong><span>EXHIBIT {doc.displayNumber || index + 1} · {documentWidget(doc.fileType).label} · {doc.publishedAt?.slice(0, 10) || 'UNDATED'}</span></div><ChevronRight size={16}/>
|
||||
</button>)}
|
||||
{normalizedDocumentQuery && filteredDocuments.length === 0 && <div className="no-document-results"><Search size={20}/><b>NO MATCHING DOCUMENTS</b><span>Searches titles, contents, extracts, and metadata.</span></div>}
|
||||
</div>
|
||||
@@ -519,7 +561,7 @@ export function App() {
|
||||
</aside>
|
||||
|
||||
<div className="board-shell" onDragEnter={e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) void uploadFiles(e.dataTransfer.files) }}>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<div className="case-heading"><div><small>{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}</small><h1>{caseState.title}</h1><p>{caseState.subtitle || caseState.id.toUpperCase()}</p>{activeGoal && <button className={`active-goal ${activeGoal.status}`} onClick={() => reportAttentionCount ? void openCaseReport() : setBriefOpen(true)}><span>{activeGoal.status === 'complete' ? reportAttentionCount ? 'SOURCE VERIFIED · REPORT REQUIRED' : 'OBJECTIVE VERIFIED' : 'CURRENT OBJECTIVE'}</span><b>{activeGoal.title}</b></button>}</div><div className="case-number">{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}<br/><b>{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}</b></div></div>
|
||||
<Board state={caseState} selected={selected} locatorDocumentId={docsOpen && documents.some(document => document.id === selected) ? selected : null} linkFrom={linkFrom} recentlyCreatedExhibitId={recentlyCreatedExhibitId} arrivingExhibitIds={arrivingExhibitIds} recentlyCreatedConnectionId={recentlyCreatedConnectionId} tool={boardTool} boardRef={boardRef} update={update} onCardClick={handleCardClick} onConnectionTarget={completeThread} onEditConnection={connection => setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, exhibits: state.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'document' ? { ...exhibit, metadata: { ...exhibit.metadata, memory_cue: cue } } : exhibit) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} />
|
||||
{briefOpen && <BriefPanel
|
||||
brief={caseState.brief}
|
||||
@@ -659,6 +701,10 @@ export function App() {
|
||||
onSave={saveThread}
|
||||
onRemove={() => removeThread(threadDraft.id)}
|
||||
/>}
|
||||
{reportOpen && caseState.report && <CaseReportPanel key={`${caseState.id}:${caseState.report.status}:${caseState.report.claims.flatMap(claim => claim.evidence).length}`}
|
||||
report={caseState.report} defaultInvestigator={caseState.report.investigatorName || playerName} hasNext={Boolean(activePlaythroughId)}
|
||||
onClose={() => setReportOpen(false)} onSubmit={submitReport}
|
||||
onContinue={() => activePlaythroughId ? void continueAfterGoal() : setReportOpen(false)}/>}
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)}/>}
|
||||
{flagsOpen && <LevelFlagsEditor levelId={caseState.id} onClose={() => setFlagsOpen(false)} onChanged={async () => { await loadLevelBySlug(caseState.id) }} />}
|
||||
{matchRulesOpen && <EvidenceMatchRulesEditor levelId={caseState.id} onClose={() => setMatchRulesOpen(false)}/>}
|
||||
@@ -812,7 +858,10 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
||||
setDraggingThreadTagId(null)
|
||||
setDraggingWidget(false)
|
||||
setTrashActive(false)
|
||||
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) onDiscardExhibit(completedDrag.id)
|
||||
if (completedDrag?.kind === 'widget' && completedDrag.id && completedDrag.moved && trashTarget.current) {
|
||||
const exhibit=byId.get(completedDrag.id)
|
||||
if (exhibit && exhibitWidget(exhibit.type).capabilities.discardable) onDiscardExhibit(completedDrag.id)
|
||||
}
|
||||
trashTarget.current = false
|
||||
}
|
||||
const toggleFolder = (id: string) => update(s => ({ ...s, exhibits: s.exhibits.map(exhibit => exhibit.id === id && exhibit.type === 'folder' ? { ...exhibit, isOpen: !exhibit.isOpen } : exhibit) }))
|
||||
@@ -879,7 +928,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left, top, width: document.width, height: document.height, rotate: `${document.rotation}deg`, zIndex: document.zIndex }}
|
||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||
<header><span>{definition.label.toUpperCase()}</span><i>{String((membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<header><span>{definition.label.toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||
<strong>{document.title}</strong><time>{document.publishedAt?.slice(0, 10) || 'UNDATED'}</time>
|
||||
<div className="source-file-actions"><button onClick={event => { event.stopPropagation(); onOpenSource(document.id) }}><BookOpen size={12}/> OPEN</button><button onClick={event => { event.stopPropagation(); onEditFile(document.id) }}><Pencil size={12}/> METADATA</button></div>
|
||||
@@ -999,6 +1048,50 @@ function TimelineRangeEditor({ range, dates, onClose, onSave }: { range?: Timeli
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function CaseReportPanel({ report, defaultInvestigator, hasNext, onClose, onSubmit, onContinue }: {
|
||||
report:CaseReport;defaultInvestigator:string;hasNext:boolean;onClose:()=>void
|
||||
onSubmit:(input:CaseReportSubmissionInput)=>Promise<CaseReport>;onContinue:()=>void
|
||||
}) {
|
||||
const [investigatorName,setInvestigatorName]=useState(report.investigatorName || defaultInvestigator)
|
||||
const [evidence,setEvidence]=useState<CaseReportSubmissionInput['evidence']>(() => report.claims.flatMap(claim => claim.evidence.map(item => ({
|
||||
connectionId:item.connectionId,documentExhibitId:item.documentExhibitId,relationText:item.relationText || 'Proof that…',
|
||||
publishedAt:item.publishedAt?.slice(0,10),sourceCitation:item.sourceCitation,sourceUri:item.sourceUri,
|
||||
}))))
|
||||
const [busy,setBusy]=useState(false)
|
||||
const [error,setError]=useState('')
|
||||
const updateEvidence=(connectionId:string,patch:Partial<CaseReportSubmissionInput['evidence'][number]>) => setEvidence(items => items.map(item => item.connectionId === connectionId ? { ...item,...patch } : item))
|
||||
const submit=async (event:React.FormEvent) => {
|
||||
event.preventDefault();setBusy(true);setError('')
|
||||
try { await onSubmit({ investigatorName,evidence }) }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : 'Report submission failed') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const draftByConnection=new Map(evidence.map(item => [item.connectionId,item]))
|
||||
return <div className="modal-shade case-report-shade"><form className="window case-report" onSubmit={event => void submit(event)}>
|
||||
<header><ClipboardCheck size={16}/><b>{report.title}</b><span/><button type="button" aria-label="Close case report" onClick={onClose}><X size={14}/></button></header>
|
||||
<div className="case-report-paper"><div className="case-report-letterhead"><small>GLITCH UNIVERSITY · PRINCIPAL INVESTIGATOR PROGRAMME</small><h2>CASE REPORT</h2><span>FORM GUPI–7 / EVIDENTIARY FINDING</span></div>
|
||||
{report.claims.map((claim,claimIndex) => <section className="report-claim" key={claim.claimExhibitId}>
|
||||
<h3><span>CLAIM {claimIndex + 1}</span>{claim.statement}</h3>
|
||||
<h4>EVIDENCE</h4>
|
||||
{claim.evidence.length === 0 ? <div className="report-empty-evidence">No source evidence is connected to this claim. Return to the board and use red thread to attach a document.</div>
|
||||
: claim.evidence.map(item => { const draft=draftByConnection.get(item.connectionId)!; return <article className={`report-evidence ${item.evidenceAccepted ? 'verified' : ''}`} key={item.connectionId}>
|
||||
<div className="report-evidence-heading"><b>Exhibit {item.displayNumber}</b><span>{item.fileType.replaceAll('_',' ')} · {item.documentTitle}</span>{item.evidenceAccepted && <em>CONTENT VERIFIED</em>}</div>
|
||||
<label><span>EVIDENTIARY STATEMENT</span><textarea aria-label={`Evidence statement for Exhibit ${item.displayNumber}`} rows={2} value={draft.relationText} onChange={event => updateEvidence(item.connectionId,{ relationText:event.target.value })}/></label>
|
||||
<div className="report-fields"><label><span>DATED</span><input aria-label={`Date for Exhibit ${item.displayNumber}`} type="date" value={draft.publishedAt || ''} onChange={event => updateEvidence(item.connectionId,{ publishedAt:event.target.value || undefined })}/></label>
|
||||
<label><span>SOURCE / PUBLICATION</span><input aria-label={`Source for Exhibit ${item.displayNumber}`} value={draft.sourceCitation || ''} placeholder="e.g. Google Patents · GB695913A" onChange={event => updateEvidence(item.connectionId,{ sourceCitation:event.target.value })}/></label></div>
|
||||
<label><span>SOURCE LINK · IF AVAILABLE</span><input aria-label={`Source link for Exhibit ${item.displayNumber}`} type="url" value={draft.sourceUri || ''} placeholder="https://…" onChange={event => updateEvidence(item.connectionId,{ sourceUri:event.target.value })}/></label>
|
||||
</article> })}
|
||||
</section>)}
|
||||
<label className="report-investigator"><span>INVESTIGATOR</span><input aria-label="Investigator name" value={investigatorName} maxLength={300} onChange={event => setInvestigatorName(event.target.value)}/></label>
|
||||
{report.status !== 'draft' && <section className={`report-verdict ${report.status}`} role="status"><small>{report.status === 'accepted' ? 'REPORT ACCEPTED' : report.status === 'evidence_accepted_report_incomplete' ? 'EVIDENCE PASSED · REPORT RETURNED' : 'EVIDENCE NOT ESTABLISHED'}</small><p>{report.feedback}</p></section>}
|
||||
{error && <p className="flag-error">{error}</p>}
|
||||
<div className="case-report-actions"><button type="button" onClick={onClose}>RETURN TO BOARD</button>{report.status === 'accepted'
|
||||
? <button className="primary" type="button" onClick={onContinue}>{hasNext ? 'CONTINUE' : 'CLOSE CASE'} <span>▸</span></button>
|
||||
: <button className="primary" type="submit" disabled={busy}>{busy ? 'FILING…' : 'SUBMIT CASE REPORT'}</button>}</div>
|
||||
</div>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSave, onRemove }: { connection: Connection; sourceName: string; targetName: string; isNew: boolean; onClose: () => void; onSave: (connection: Connection) => void; onRemove: () => void }) {
|
||||
const [label, setLabel] = useState(connection.label || '')
|
||||
const [tightness, setTightness] = useState(connection.tightness ?? 65)
|
||||
@@ -1012,8 +1105,8 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
|
||||
return <div className="modal-shade"><form className="window thread-editor" onSubmit={event => { event.preventDefault(); save() }}>
|
||||
<header><Link2 size={16}/><b>{isNew ? 'Add relation tag' : 'Edit red thread'}</b><span/><button type="button" aria-label="Close thread editor" onClick={onClose}><X size={14}/></button></header>
|
||||
<div><small>RED THREAD · INVESTIGATOR RELATION</small><div className="thread-endpoints"><b>{sourceName}</b><i/><b>{targetName}</b></div>
|
||||
<p>What does this connection mean? Add a short tag if the thread represents a specific claim.</p>
|
||||
<label className="field"><span>RELATION TAG · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="e.g. Proof Elias is the driver" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
||||
<p>What does this connection prove? Complete the sentence on the luggage tag; it will also appear in the Case Report.</p>
|
||||
<label className="field"><span>EVIDENTIARY STATEMENT · OPTIONAL</span><input aria-label="Thread tag" autoFocus placeholder="Proof that…" value={label} onChange={event => setLabel(event.target.value)}/></label>
|
||||
<fieldset className="tag-style-picker"><legend>TAG PRESENTATION</legend><label className={tagStyle === 'luggage' ? 'selected' : ''}><input type="radio" name="tag-style" value="luggage" checked={tagStyle === 'luggage'} onChange={() => setTagStyle('luggage')}/><span className="tag-style-luggage"><i/>LUGGAGE</span><small>Expressive · rotates to read</small></label><label className={tagStyle === 'compact' ? 'selected' : ''}><input type="radio" name="tag-style" value="compact" checked={tagStyle === 'compact'} onChange={() => setTagStyle('compact')}/><span className="tag-style-compact"><i/>COMPACT</span><small>Quiet · less board clutter</small></label></fieldset>
|
||||
<label className="field thread-position-control"><span>TAG POSITION <output>{tagPosition}%</output></span><input aria-label="Tag position" type="range" min="5" max="95" step="1" value={tagPosition} onChange={event => setTagPosition(Number(event.target.value))}/><small>Drag the tag on the board for along-thread position and tension-limited lateral play.</small></label>
|
||||
<label className="field thread-tightness"><span>THREAD TIGHTNESS <output>{tightness}%</output></span><input aria-label="Thread tightness" type="range" min="0" max="100" step="5" value={tightness} onChange={event => setTightness(Number(event.target.value))}/><small><span>SLACK</span><span>TAUT</span></small></label>
|
||||
|
||||
Reference in New Issue
Block a user