281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { api } from '../../api'
|
||
import type { Project } from '../../contracts'
|
||
import { projectWorkError } from '../../projectmeta'
|
||
import { Btn, Collapsible, EmptyState, Icon } from '@ui'
|
||
import {
|
||
SEARCH_FIELDS, accountCandidatePager, accountSearchKey, dutyMatrixPayload, dutyPreviewPayload, dutyLegacyPayload,
|
||
type DutyAccountIds, type MemberMatchMode, type PersonPickItem, type RdmAccountPage, type SearchFieldsChoice,
|
||
} from '../../rdmManagement'
|
||
import {
|
||
absorbServerDocs, dirtyDocs, editCell, savePayload,
|
||
type CellDuty, type DutyDocDraft, type DutyMember,
|
||
} from '../../duties/draft'
|
||
import { DutyByDocumentView, DutyByMemberView, DutyCandidateSearch, DutyChangePreview, DutyMatrixView } from '../../duties/views'
|
||
|
||
type ViewName = 'member' | 'document' | 'matrix'
|
||
|
||
interface MatrixDoc {
|
||
docId: string
|
||
title: string
|
||
code?: string
|
||
typeKey: string
|
||
stage?: string
|
||
dutyVersion: number
|
||
activeRound?: number
|
||
legacySectionOverrides?: number
|
||
}
|
||
interface MatrixOut {
|
||
canAssign: boolean
|
||
docs: MatrixDoc[]
|
||
members: DutyMember[]
|
||
cells: { docId: string; owner: string; duty: string }[]
|
||
nextDocCursor?: string
|
||
nextMemberCursor?: string
|
||
}
|
||
interface PreviewOut {
|
||
ok: boolean
|
||
docs: {
|
||
docId: string
|
||
title?: string
|
||
replacedAuthor?: string
|
||
replacedApprover?: string
|
||
needsConfirm?: boolean
|
||
reasons?: string[]
|
||
}[]
|
||
}
|
||
interface LegacyOut {
|
||
findings: { docId: string; title: string; sectionTitle: string; kind: string }[]
|
||
}
|
||
|
||
export function RdmDutiesSection({ project }: { project: Project }) {
|
||
const projectId = project.id
|
||
const writeError = projectWorkError(project)
|
||
const [view, setView] = useState<ViewName>('member')
|
||
const [docs, setDocs] = useState<DutyDocDraft[]>([])
|
||
const [members, setMembers] = useState<DutyMember[]>([])
|
||
const [member, setMember] = useState('')
|
||
const [docId, setDocId] = useState('')
|
||
const [query, setQuery] = useState('')
|
||
const [stage, setStage] = useState('')
|
||
const [typeKey, setTypeKey] = useState('')
|
||
const [assignment, setAssignment] = useState('')
|
||
const [docCursor, setDocCursor] = useState('')
|
||
const [nextDoc, setNextDoc] = useState('')
|
||
const [canAssign, setCanAssign] = useState(false)
|
||
const [narrow, setNarrow] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [preview, setPreview] = useState<PreviewOut['docs']>([])
|
||
const [confirm, setConfirm] = useState(false)
|
||
const [legacy, setLegacy] = useState<LegacyOut['findings']>([])
|
||
const [legacyOpen, setLegacyOpen] = useState(false)
|
||
const [candMode, setCandMode] = useState<MemberMatchMode>('fuzzy')
|
||
const [candFields, setCandFields] = useState<SearchFieldsChoice>('both')
|
||
const [accountIds, setAccountIds] = useState<DutyAccountIds>({})
|
||
const seq = useRef(0)
|
||
const op = useRef(0)
|
||
const docsRef = useRef(docs)
|
||
docsRef.current = docs
|
||
|
||
const load = async (cursor: string, request = ++seq.current, replaceDirty = false) => {
|
||
const seenMembers: DutyMember[] = []
|
||
const seenCells: MatrixOut['cells'] = []
|
||
let pageDocs: MatrixDoc[] = []
|
||
let memberCursor = ''
|
||
let nextDocument = ''
|
||
let assignable = false
|
||
try {
|
||
do {
|
||
const page = await api.rdmProjectCommand<MatrixOut>(project, 'rdm.duty.matrix', dutyMatrixPayload(projectId, {
|
||
view: 'matrix', query, stage, typeKey, assignment, docCursor: cursor, memberCursor, docLimit: 10, memberLimit: 20,
|
||
}))
|
||
if (request !== seq.current) return
|
||
if (pageDocs.length === 0) pageDocs = page.docs ?? []
|
||
seenMembers.push(...(page.members ?? []))
|
||
seenCells.push(...(page.cells ?? []))
|
||
assignable = page.canAssign
|
||
nextDocument = page.nextDocCursor ?? ''
|
||
memberCursor = page.nextMemberCursor ?? ''
|
||
} while (memberCursor)
|
||
if (request !== seq.current) return
|
||
setMembers(seenMembers)
|
||
setCanAssign(assignable)
|
||
setNextDoc(nextDocument)
|
||
setDocs(current => absorbServerDocs(current, pageDocs, seenCells, replaceDirty))
|
||
} catch (error) {
|
||
if (request !== seq.current) return
|
||
setErr(String(error))
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
const media = window.matchMedia('(max-width: 720px)')
|
||
const apply = () => setNarrow(media.matches)
|
||
apply()
|
||
media.addEventListener('change', apply)
|
||
return () => media.removeEventListener('change', apply)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void load(docCursor)
|
||
return () => { seq.current += 1 }
|
||
}, [project, query, stage, typeKey, assignment, docCursor])
|
||
|
||
useEffect(() => { setAccountIds({}) }, [project])
|
||
|
||
const candOptions = useMemo(() => ({ docId, mode: candMode, searchFields: SEARCH_FIELDS[candFields].fields }),
|
||
[docId, candMode, candFields])
|
||
const fetchCandidates = useMemo(() => accountCandidatePager(
|
||
params => api.rdmProjectCommand<RdmAccountPage>(project, 'rdm.account.list', params), projectId, candOptions,
|
||
), [project, candOptions])
|
||
|
||
const onCell = (id: string, owner: string, duty: CellDuty) => {
|
||
setPreview([])
|
||
setConfirm(false)
|
||
setDocs(current => editCell(current, id, owner, duty))
|
||
}
|
||
|
||
const onCandidate = (id: string, candidate: PersonPickItem, duty: Exclude<CellDuty, 'none'>) => {
|
||
setAccountIds(current => ({ ...current, [candidate.owner]: candidate.id }))
|
||
onCell(id, candidate.owner, duty)
|
||
}
|
||
|
||
const visibleDocs = docs.filter(doc => {
|
||
const text = `${doc.title} ${doc.code} ${doc.docId}`.toLowerCase()
|
||
if (query && !text.includes(query.trim().toLowerCase())) return false
|
||
if (stage && doc.stage !== stage) return false
|
||
if (typeKey && doc.typeKey !== typeKey) return false
|
||
return true
|
||
})
|
||
|
||
const save = async () => {
|
||
const payload = savePayload(docsRef.current, confirm, accountIds)
|
||
if (payload.issues.length > 0) {
|
||
setErr(payload.issues.join(';'))
|
||
return
|
||
}
|
||
const request = ++seq.current
|
||
const ticket = ++op.current
|
||
setBusy(true); setErr(''); setNotice('')
|
||
let sent = false
|
||
try {
|
||
const looked = await api.rdmProjectCommand<PreviewOut>(project, 'rdm.duty.preview', dutyPreviewPayload(projectId, payload.docs))
|
||
if (request !== seq.current) return
|
||
setPreview(looked.docs ?? [])
|
||
if (!looked.ok) {
|
||
setErr('预览未通过,未写入')
|
||
return
|
||
}
|
||
if ((looked.docs ?? []).some(item => item.needsConfirm) && !confirm) {
|
||
setErr('在途审签将失效。确认后再保存。')
|
||
return
|
||
}
|
||
sent = true
|
||
await api.rdmProjectCommand(project, 'rdm.review.assign.doc.batch', dutyPreviewPayload(projectId, savePayload(docsRef.current, confirm, accountIds).docs))
|
||
if (request !== seq.current) return
|
||
setNotice('已保存文档职责')
|
||
setPreview([])
|
||
setConfirm(false)
|
||
setDocs(current => current.map(doc => ({ ...doc, dirty: false })))
|
||
await load(docCursor, request)
|
||
} catch (error) {
|
||
if (request !== seq.current) return
|
||
if (!sent) {
|
||
setErr(String(error))
|
||
return
|
||
}
|
||
setErr(`保存结果未确认:${error}`)
|
||
setConfirm(false)
|
||
await load(docCursor, request, true)
|
||
} finally {
|
||
if (ticket === op.current) setBusy(false)
|
||
}
|
||
}
|
||
|
||
const scanLegacy = async (request = ++seq.current) => {
|
||
try {
|
||
const out = await api.rdmProjectCommand<LegacyOut>(project, 'rdm.duty.legacy.scan', dutyLegacyPayload(projectId))
|
||
if (request !== seq.current) return
|
||
setLegacy(out.findings ?? [])
|
||
} catch (error) {
|
||
if (request !== seq.current) return
|
||
setErr(String(error))
|
||
}
|
||
}
|
||
|
||
const applyIdentical = async () => {
|
||
const request = ++seq.current
|
||
const ticket = ++op.current
|
||
setBusy(true)
|
||
try {
|
||
await api.rdmProjectCommand(project, 'rdm.duty.legacy.apply', dutyLegacyPayload(projectId))
|
||
if (request !== seq.current) return
|
||
setNotice('已归并无歧义的章节职责')
|
||
await scanLegacy(request)
|
||
await load(docCursor, request)
|
||
} catch (error) {
|
||
if (request !== seq.current) return
|
||
setErr(String(error))
|
||
} finally {
|
||
if (ticket === op.current) setBusy(false)
|
||
}
|
||
}
|
||
|
||
const disabled = busy || !!writeError || !canAssign
|
||
const ambiguous = legacy.filter(item => item.kind !== 'identical')
|
||
|
||
return (
|
||
<div>
|
||
<p className="ad-hint">本页只分配已有文档的职责,不创建或生成文档。</p>
|
||
{writeError && <p className="ad-hint">{writeError}</p>}
|
||
<div className="ad-row" role="tablist" aria-label="职责视图">
|
||
<button className="ad-mini" role="tab" aria-selected={view === 'member'} onClick={() => setView('member')}>按人员</button>
|
||
<button className="ad-mini" role="tab" aria-selected={view === 'document'} onClick={() => setView('document')}>按文档</button>
|
||
<button className="ad-mini" role="tab" aria-selected={view === 'matrix'} onClick={() => setView('matrix')}>矩阵</button>
|
||
</div>
|
||
<div className="ad-row">
|
||
<input aria-label="筛选文档名称或编号" value={query} placeholder="名称或编号" disabled={busy} onChange={event => { setDocCursor(''); setQuery(event.target.value) }} />
|
||
<input aria-label="筛选类型" value={typeKey} placeholder="类型" disabled={busy} onChange={event => { setDocCursor(''); setTypeKey(event.target.value) }} />
|
||
<input aria-label="筛选阶段" value={stage} placeholder="阶段" disabled={busy} onChange={event => { setDocCursor(''); setStage(event.target.value) }} />
|
||
<select aria-label="筛选当前职责" value={assignment} disabled={busy} onChange={event => { setDocCursor(''); setAssignment(event.target.value) }}>
|
||
<option value="">全部</option>
|
||
<option value="assigned">已分配</option>
|
||
<option value="unassigned">未分配</option>
|
||
</select>
|
||
</div>
|
||
{view === 'member' && <DutyByMemberView member={member} members={members} docs={visibleDocs} disabled={disabled} onMember={setMember} onCell={onCell} />}
|
||
{view === 'document' && <DutyByDocumentView docId={docId} docs={visibleDocs} members={members} disabled={disabled} onDoc={setDocId} onCell={onCell} />}
|
||
{view === 'document' && (() => {
|
||
const doc = docs.find(item => item.docId === docId)
|
||
return doc ? (
|
||
<DutyCandidateSearch doc={doc} mode={candMode} fields={candFields} fetchPage={fetchCandidates}
|
||
reloadKey={accountSearchKey(projectId, candOptions)} disabled={disabled}
|
||
onMode={setCandMode} onFields={setCandFields} onAssign={(candidate, duty) => onCandidate(doc.docId, candidate, duty)} />
|
||
) : null
|
||
})()}
|
||
{view === 'matrix' && <DutyMatrixView docs={visibleDocs} members={members} disabled={disabled} narrow={narrow} onCell={onCell} />}
|
||
<div className="ad-row">
|
||
{docCursor && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor('')}>文档首页</button>}
|
||
{nextDoc && <button className="ad-mini" disabled={busy} onClick={() => setDocCursor(nextDoc)}>下一页文档</button>}
|
||
<Btn variant="primary" icon="check" disabled={disabled || dirtyDocs(docs).length === 0} onClick={() => { void save() }}>保存职责</Btn>
|
||
</div>
|
||
<DutyChangePreview impacts={preview} confirm={confirm} onConfirm={setConfirm} />
|
||
<Collapsible id="duty-legacy" open={legacyOpen} onToggle={setLegacyOpen} trigger="旧章节职责">
|
||
<button className="ad-mini" disabled={busy} onClick={() => { void scanLegacy() }}>扫描</button>
|
||
<button className="ad-mini" disabled={disabled} onClick={() => { void applyIdentical() }}>归并无歧义覆盖</button>
|
||
{legacy.length === 0 ? <p className="ad-hint">尚未扫描,或没有章节级覆盖。</p> : (
|
||
<ul>
|
||
{legacy.map(item => <li key={`${item.docId}-${item.sectionTitle}`}>{item.title} / {item.sectionTitle}:{item.kind === 'identical' ? '与文档职责一致' : '有歧义,需逐文档确认'}</li>)}
|
||
</ul>
|
||
)}
|
||
{ambiguous.length > 0 && <p className="ad-hint">有歧义的文档不会自动覆盖。请在上面的视图里逐份确认后再保存。</p>}
|
||
</Collapsible>
|
||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||
{docs.length === 0 && !err && <EmptyState icon="inbox">暂无文档</EmptyState>}
|
||
</div>
|
||
)
|
||
}
|