feat: create parties outside brief concepts

This commit is contained in:
2026-08-14 18:17:31 +02:00
parent 57c8c91f21
commit 5d59af1dd9
3 changed files with 48 additions and 10 deletions
+37 -7
View File
@@ -41,6 +41,7 @@ export function App() {
const [editingFileId, setEditingFileId] = useState<string | null>(null)
const [editingEventId, setEditingEventId] = useState<string | null>(null)
const [editingPartyId, setEditingPartyId] = useState<string | null>(null)
const [newPartyDraft, setNewPartyDraft] = useState<Evidence | null>(null)
const [briefOpen, setBriefOpen] = useState(false)
const [editingBrief, setEditingBrief] = useState(false)
const [editingTimeline, setEditingTimeline] = useState(false)
@@ -147,6 +148,15 @@ export function App() {
setSelected(event.id); setRecentlyCreatedExhibitId(event.id); setEditingEventId(event.id)
}
const addParty = () => {
if (!caseState) return
const { viewport } = caseState
const position = nextOpenBoardPosition(caseState.evidence, {
x: Math.max(100, (670 - viewport.x) / viewport.zoom), y: Math.max(100, (310 - viewport.y) / viewport.zoom),
}, { width: 280 })
setNewPartyDraft({ id: uid('party'), type: 'party', partyKind: 'person', title: '', content: '', aliases: [], relatedEvidenceIds: [], ...position, width: 280 })
}
const classifyConcept = (conceptId: string, partyKind: PartyKind) => {
if (!caseState) return
const concept = caseState.brief.concepts.find(item => item.id === conceptId)
@@ -330,6 +340,7 @@ export function App() {
onClose={closeBrief}
onEdit={() => setEditingBrief(true)}
onClassify={classifyConcept}
onNewParty={addParty}
onLocate={focusEvidence}
onEditParty={setEditingPartyId}
/>}
@@ -341,6 +352,7 @@ export function App() {
<span />
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
<span />
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
@@ -377,6 +389,21 @@ export function App() {
setStatus('PARTY DOSSIER UPDATED')
}}
/>}
{newPartyDraft && <PartyEditor
key={newPartyDraft.id}
party={newPartyDraft}
evidence={caseState.evidence}
documents={caseState.documents}
creating
onClose={() => setNewPartyDraft(null)}
onSave={party => {
update(state => ({ ...state, evidence: [...state.evidence, party] }))
setNewPartyDraft(null)
setSelected(party.id)
setRecentlyCreatedExhibitId(party.id)
setStatus(`${party.partyKind === 'person' ? 'PERSON' : 'ORGANIZATION'} DOSSIER CREATED`)
}}
/>}
{editingBrief && <BriefEditor
brief={caseState.brief}
onClose={() => setEditingBrief(false)}
@@ -665,12 +692,13 @@ function ThreadEditor({ connection, sourceName, targetName, isNew, onClose, onSa
</form></div>
}
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
function BriefPanel({ brief, parties, recentlyCreatedExhibitId, canEdit, onClose, onEdit, onClassify, onNewParty, onLocate, onEditParty }: { brief: LevelBrief; parties: Evidence[]; recentlyCreatedExhibitId: string | null; canEdit: boolean; onClose: () => void; onEdit: () => void; onClassify: (id: string, kind: PartyKind) => void; onNewParty: () => void; onLocate: (id: string) => void; onEditParty: (id: string) => void }) {
const partyById = new Map(parties.map(party => [party.id, party]))
const unresolved = brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length
return <aside className="brief-panel"><header><div><small>LEVEL BRIEF · {unresolved} UNRESOLVED</small><b>CONCEPT CLASSIFICATION</b></div><button aria-label="Close brief" onClick={onClose}><X size={14}/></button></header>
<p>{brief.body || 'No brief has been authored yet.'}</p>
<div className="brief-concepts">{brief.concepts.map(concept => { const resolved = concept.resolvedPartyExhibitId ? partyById.get(concept.resolvedPartyExhibitId) : undefined; return <section className={`${resolved ? 'resolved' : ''} ${resolved?.id === recentlyCreatedExhibitId ? 'just-resolved' : ''}`} key={concept.id}><div><b>{concept.label}</b><span>{concept.context}</span></div>{resolved ? <div className="resolved-actions"><span>{resolved.partyKind === 'person' ? <UserRound size={14}/> : <Building2 size={14}/>} {resolved.partyKind?.toUpperCase()}</span><button onClick={() => onLocate(resolved.id)}>LOCATE</button><button onClick={() => onEditParty(resolved.id)}>EDIT DOSSIER</button></div> : <div className="classify-actions"><button onClick={() => onClassify(concept.id, 'person')}><UserRound size={14}/> PERSON</button><button onClick={() => onClassify(concept.id, 'organization')}><Building2 size={14}/> ORGANIZATION</button></div>}</section> })}</div>
<button className="new-party-from-brief" onClick={onNewParty}><Plus size={13}/> CREATE PARTY NOT LISTED ABOVE</button>
{canEdit && <button className="edit-brief" onClick={onEdit}><Pencil size={13}/> EDIT BRIEF & CONCEPTS</button>}
<button className="dismiss-brief" onClick={onClose}>{unresolved === brief.concepts.length ? 'BEGIN INVESTIGATION' : 'RETURN TO BOARD'}</button>
</aside>
@@ -692,20 +720,22 @@ function BriefEditor({ brief, onClose, onSave }: { brief: LevelBrief; onClose: (
</form></div>
}
function PartyEditor({ party, evidence, documents, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; onClose: () => void; onSave: (party: Evidence) => void }) {
function PartyEditor({ party, evidence, documents, creating = false, onClose, onSave }: { party: Evidence; evidence: Evidence[]; documents: CaseDocument[]; creating?: boolean; onClose: () => void; onSave: (party: Evidence) => void }) {
const [name, setName] = useState(party.title)
const [summary, setSummary] = useState(party.content)
const [partyKind, setPartyKind] = useState<PartyKind>(party.partyKind || 'person')
const [organizationKind, setOrganizationKind] = useState<OrganizationKind>(party.organizationKind || 'business')
const [aliases, setAliases] = useState((party.aliases || []).join('\n'))
const [related, setRelated] = useState(party.relatedEvidenceIds || [])
const candidates = [...evidence.filter(item => item.id !== party.id && item.type !== 'party').map(item => ({ id: item.id, title: item.title, kind: item.type.toUpperCase() })), ...documents.map(item => ({ id: item.id, title: item.title, kind: item.fileType.toUpperCase() }))]
const toggle = (id: string) => setRelated(current => current.includes(id) ? current.filter(item => item !== id) : [...current, id])
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, title: name.trim() || party.title, content: summary.trim(), organizationKind: party.partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}>
<header>{party.partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>Edit {party.partyKind} dossier</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>PARTY EXHIBIT · {party.partyKind?.toUpperCase()}</small>
<label className="field"><span>DISPLAY NAME</span><input aria-label="Party name" value={name} onChange={event => setName(event.target.value)}/></label>
return <div className="modal-shade"><form className="window folder-editor party-editor" onSubmit={submit => { submit.preventDefault(); onSave({ ...party, partyKind, title: name.trim(), content: summary.trim(), organizationKind: partyKind === 'organization' ? organizationKind : undefined, aliases: aliases.split('\n').map(item => item.trim()).filter(Boolean), relatedEvidenceIds: related }) }}>
<header>{partyKind === 'person' ? <UserRound size={16}/> : <Building2 size={16}/>}<b>{creating ? 'Create party dossier' : `Edit ${partyKind} dossier`}</b><span/><button type="button" aria-label="Close party editor" onClick={onClose}><X size={14}/></button></header>
<div className="folder-editor-body"><small>PARTY EXHIBIT · {partyKind.toUpperCase()}</small>
{creating && <label className="field"><span>PARTY TYPE</span><select aria-label="Party type" value={partyKind} onChange={event => setPartyKind(event.target.value as PartyKind)}><option value="person">Person</option><option value="organization">Organization</option></select></label>}
<label className="field"><span>DISPLAY NAME</span><input aria-label="Party name" required value={name} onChange={event => setName(event.target.value)}/></label>
<label className="field"><span>DOSSIER SUMMARY</span><textarea aria-label="Party summary" rows={3} value={summary} onChange={event => setSummary(event.target.value)}/></label>
{party.partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
{partyKind === 'organization' && <label className="field"><span>ORGANIZATION TYPE</span><select aria-label="Organization type" value={organizationKind} onChange={event => setOrganizationKind(event.target.value as OrganizationKind)}><option value="business">Business</option><option value="public_body">Public body</option><option value="association">Association</option><option value="informal_group">Informal group</option><option value="other">Other</option></select></label>}
<label className="field"><span>ALIASES · ONE PER LINE</span><textarea aria-label="Party aliases" rows={2} value={aliases} onChange={event => setAliases(event.target.value)}/></label>
<div className="folder-members-heading"><div><b>ASSOCIATED EVIDENCE</b><small>{related.length} LINKED</small></div><span>PARTY DOSSIER</span></div>
<div className="folder-members">{candidates.map(candidate => { const included = related.includes(candidate.id); return <div className={`folder-member ${included ? 'included' : ''}`} key={candidate.id}><label><input type="checkbox" checked={included} onChange={() => toggle(candidate.id)}/><Network size={16}/><span><b>{candidate.title}</b><small>{candidate.kind}</small></span></label><span className="folder-member-date">{included ? 'ASSOCIATED' : 'NOT LINKED'}</span></div> })}</div>