import { useEffect, useRef, useState, type ReactNode } from 'react' import { bulkDeleteProcess, bulkUpdateProcess, deleteProcessItem, getProcessItem, getProcessPage, importProcess, processDownloadUrl, updateProcessing, type Org, type ProcessImportReport, type ProcessItem, type Code, type ProcessStatusFilter, type ProcessingRequest, } from '../api/client' import StatusChip from '../ui/StatusChip' import { useCodes, useSplitByFirstRow } from '../codes/useCodes' const PAGE_SIZE = 30 /** 공공누리 유형이 쓰이는 자리. 권리처리 판정에는 '보류'가 없다. */ const SCOPE_PROCESS = 'process' /** 자유 입력이 붙는 계약서류('기타'). 저장값이 "기타:본문" 형태가 된다. */ function etcDocOf(docs: Code[]): Code | undefined { return docs.find((doc) => doc.attrs.freeText === true) } /** '처리완료'에 해당하는 코드. 처리일시를 찍고 배지를 초록으로 바꾸는 기준이다. */ function doneStatusOf(statuses: Code[]): Code | undefined { return statuses.find((status) => status.attrs.done === true) } /** "양도계약서, 공문, 기타:직접 촬영본 사용동의" 형태의 저장값 → 체크박스 상태로 역파싱한다. */ function parseContractDocs( value: string | null, docs: Code[], ): { selected: Set; etcText: string } { const selected = new Set() let etcText = '' if (!value) { return { selected, etcText } } const etc = etcDocOf(docs) for (const raw of value.split(',')) { const part = raw.trim() if (!part) { continue } if (etc && part.startsWith(`${etc.code}:`)) { selected.add(etc.code) etcText = part.slice(etc.code.length + 1) } else if (docs.some((doc) => doc.code === part)) { selected.add(part) } } return { selected, etcText } } /** 체크박스 상태 → 저장값. 아무것도 선택하지 않았으면 null. */ function serializeContractDocs( selected: Set, etcText: string, docs: Code[], ): string | null { const etc = etcDocOf(docs) const parts: string[] = docs .filter((doc) => doc.attrs.freeText !== true && selected.has(doc.code)) .map((doc) => doc.code) if (etc && selected.has(etc.code)) { parts.push(`${etc.code}:${etcText}`) } return parts.length > 0 ? parts.join(', ') : null } function formatDate(ms: number | null): string { if (!ms) { return '-' } const d = new Date(ms) const y = d.getFullYear() const m = String(d.getMonth() + 1).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0') return `${y}-${m}-${day}` } function truncate(text: string | null, max: number): string { if (!text) { return '-' } return text.length > max ? `${text.slice(0, max)}…` : text } /** 소제목: 작은 파란 사각 불릿 + 굵은 제목. */ function SectionHeader({ title }: { title: string }) { return (

{title}

) } /** 정보 테이블의 회색 라벨 셀. required면 라벨 앞에 빨간 별표를 붙인다. */ function LabelCell({ children, required }: { children: ReactNode; required?: boolean }) { return (
{required && *} {children}
) } /** 정보 테이블의 값 셀. */ function ValueCell({ children }: { children: ReactNode }) { return
{children}
} /** 읽기전용 값 표시용 회색 박스. */ function ReadonlyBox({ value }: { value: ReactNode }) { return (
{value || -}
) } /** 단일 라벨/값 행(150px + 1fr). */ function InfoRow({ label, required, children, }: { label: string required?: boolean children: ReactNode }) { return (
{label} {children}
) } /** 라벨/값 두 쌍을 한 행에 나란히 배치(150px + 1fr + 150px + 1fr). */ function InfoRowPair({ leftLabel, left, rightLabel, right, }: { leftLabel: string left: ReactNode rightLabel: string right: ReactNode }) { return (
{leftLabel} {left} {rightLabel} {right}
) } function ProcessStatusBadge({ value }: { value: string | null }) { // 어느 값이 '완료'인지는 코드표의 done 속성이 정한다. const statuses = useCodes('PROCESS_STATUS') const doneCode = doneStatusOf(statuses) const done = !!doneCode && value === doneCode.code const pendingLabel = statuses.find((status) => status.attrs.done !== true)?.label ?? '' return ( ) } /** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */ export default function ProcessBoard({ org }: { org: Org }) { const statuses = useCodes('PROCESS_STATUS') const doneStatusCode = doneStatusOf(statuses)?.code const koglProcess = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS) // 필터 칩의 '전체'는 코드가 아니라 화면에서만 쓰는 항목이라 앞에 붙인다. // 나머지는 코드표에서 오되, 서버에 넘기는 키(PENDING/DONE)는 done 속성으로 정한다. const statusChips: { key: ProcessStatusFilter | undefined; label: string }[] = [ { key: undefined, label: '전체' }, ...statuses.map((status) => ({ key: (status.attrs.done === true ? 'DONE' : 'PENDING') as ProcessStatusFilter, label: status.label, })), ] const [mode, setMode] = useState<'list' | number>('list') const [items, setItems] = useState([]) const [total, setTotal] = useState(0) const [done, setDone] = useState(0) const [page, setPage] = useState(0) const [statusFilter, setStatusFilter] = useState(undefined) const [keywordInput, setKeywordInput] = useState('') const [appliedKeyword, setAppliedKeyword] = useState('') /** 복합검색(열별 조건). 서로 AND로 겹친다. */ const [advancedOpen, setAdvancedOpen] = useState(false) const [siteInput, setSiteInput] = useState('') const [boardInput, setBoardInput] = useState('') const [titleInput, setTitleInput] = useState('') const [applied, setApplied] = useState({ site: '', board: '', title: '' }) /** 일괄 등록/삭제 대상. */ const [selected, setSelected] = useState([]) const [bulkStatus, setBulkStatus] = useState('') const [bulkKoglType, setBulkKoglType] = useState('') const [bulkOpinion, setBulkOpinion] = useState('') /** 요구사항 [15]로 추가된 비고 일괄 입력. */ const [bulkNote, setBulkNote] = useState('') const [bulkBusy, setBulkBusy] = useState(false) const [bulkMessage, setBulkMessage] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [reloadKey, setReloadKey] = useState(0) const importInputRef = useRef(null) const [importBusy, setImportBusy] = useState(false) const [importReport, setImportReport] = useState(null) const [importError, setImportError] = useState(null) useEffect(() => { if (mode !== 'list') { return } let cancelled = false setLoading(true) setError(null) getProcessPage(org.id, { keyword: appliedKeyword || undefined, site: applied.site || undefined, board: applied.board || undefined, title: applied.title || undefined, status: statusFilter, page, size: PAGE_SIZE, }) .then((data) => { if (cancelled) { return } setItems(data.items) setTotal(data.total) setDone(data.done) }) .catch((e: Error) => { if (!cancelled) { setError(e.message) } }) .finally(() => { if (!cancelled) { setLoading(false) } }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode, org.id, appliedKeyword, applied, statusFilter, page, reloadKey]) function handleSearchFields() { setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() }) } function handleSearch() { handleSearchFields() setPage(0) setAppliedKeyword(keywordInput.trim()) } function handleResetSearch() { setKeywordInput('') setSiteInput('') setBoardInput('') setTitleInput('') setPage(0) setAppliedKeyword('') setApplied({ site: '', board: '', title: '' }) } function pickStatusChip(key: ProcessStatusFilter | undefined) { setPage(0) setStatusFilter(key) } async function handleImportFile(file: File) { setImportBusy(true) setImportError(null) try { const report = await importProcess(org.id, file) setImportReport(report) setReloadKey((k) => k + 1) } catch (e) { setImportError(e instanceof Error ? e.message : '엑셀 업로드에 실패했습니다.') } finally { setImportBusy(false) } } const allChecked = items.length > 0 && items.every((item) => selected.includes(item.id)) function toggleAll() { setSelected(allChecked ? [] : items.map((item) => item.id)) } function toggleOne(id: number) { setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) } /** 체크한 건들에 채운 항목만 한 번에 반영한다. */ async function handleBulkApply() { if (selected.length === 0 || bulkBusy) { return } if (!bulkStatus && !bulkKoglType && !bulkOpinion.trim() && !bulkNote.trim()) { setBulkMessage('반영할 값을 하나 이상 채워주세요.') return } setBulkBusy(true) setBulkMessage(null) try { const res = await bulkUpdateProcess(org.id, { ids: selected, processStatus: bulkStatus || undefined, judgedKoglType: bulkKoglType || undefined, finalOpinion: bulkOpinion.trim() || undefined, reviewNote: bulkNote.trim() || undefined, }) setBulkMessage(`${res.updated}건에 반영했습니다.`) setSelected([]) setBulkStatus('') setBulkKoglType('') setBulkOpinion('') setBulkNote('') setReloadKey((k) => k + 1) } catch (e) { setBulkMessage(e instanceof Error ? e.message : '일괄 등록에 실패했습니다.') } finally { setBulkBusy(false) } } async function handleBulkDelete() { if (selected.length === 0 || bulkBusy) { return } if (!window.confirm(`선택한 ${selected.length}건을 삭제할까요?`)) { return } setBulkBusy(true) setBulkMessage(null) try { const res = await bulkDeleteProcess(org.id, selected) setBulkMessage(`${res.deleted}건을 삭제했습니다.`) setSelected([]) setReloadKey((k) => k + 1) } catch (e) { setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.') } finally { setBulkBusy(false) } } if (mode !== 'list') { return ( setMode('list')} onSaved={() => { setMode('list') setReloadKey((k) => k + 1) }} /> ) } const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) return (
{statusChips.map((chip) => ( ))}
setKeywordInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { handleSearch() } }} placeholder="사이트명·게시물제목 검색" className="w-64 rounded-md border border-gray-300 px-2 py-1.5 text-sm" />
{ const file = e.target.files?.[0] if (file) { void handleImportFile(file) } e.target.value = '' }} /> 엑셀 다운로드
{importReport && (

{`신규 ${importReport.created} · 갱신 ${importReport.updated} · 전체 ${importReport.total}`}

)} {importError &&

{importError}

}

{`총 ${total}건 · 처리완료 ${done}건`}

{error &&

{error}

} {advancedOpen && (
{( [ ['사이트명', siteInput, setSiteInput], ['게시판명', boardInput, setBoardInput], ['게시물 제목', titleInput, setTitleInput], ] as const ).map(([label, value, setter]) => ( ))} 세 조건은 모두 만족하는 건만 찾습니다(AND)
)} {/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다. */}
선택 {selected.length}건 setBulkOpinion(e.target.value)} placeholder="최종의견(비우면 유지)" className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" /> setBulkNote(e.target.value)} placeholder="비고(비우면 유지)" className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" /> {bulkMessage && {bulkMessage}}
{loading ? ( ) : items.length === 0 ? ( ) : ( items.map((item) => ( )) )}
No 사이트/게시판 게시물 경로/제목 제작/공표일 기존유형/계약서 권리확인구분 권리확인 공공누리유형 계약여부 처리 공공누리유형 최종의견 처리상태 처리일시 관리
불러오는 중…
업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.
toggleOne(item.id)} /> {item.seq}
{item.siteName ?? '-'}
{item.boardName ?? '-'}
{item.postTitle ?? '-'} {item.url && ( 🔗 )}
{item.hasAttachment ?? '-'}
{item.producedDate ?? '-'}
{item.publishedDate ?? '-'}
{item.priorKoglType ?? '-'}
{item.contractDocs ?? '-'}
{item.reviewMajor ?? '-'} {item.reviewMinor ? ` > ${item.reviewMinor}` : ''}
{item.reviewResult ?? ''}
{item.reviewKoglType ?? '-'}
{item.reviewAiType === 'Y' ? 'AI' : ''}
{item.contractDocs ?? '-'}
{item.judgedKoglType ?? '-'}
{item.judgedAiType === 'Y' ? 'AI' : ''}
{truncate(item.finalOpinion, 60)} {item.processStatus === doneStatusCode ? formatDate(item.processedAt) : '-'}
{item.processStatus === doneStatusCode ? ( <> ) : ( )}
{items.length > 0 && (
{`${page + 1} / ${totalPages} Page`}
)}
) } /** 권리처리 상세([처리등록]/[수정]) 화면. */ function ProcessDetail({ org, itemId, onBack, onSaved, }: { org: Org itemId: number onBack: () => void onSaved: () => void }) { const contractDocs = useCodes('CONTRACT_DOC') const etcDoc = etcDocOf(contractDocs) const statuses = useCodes('PROCESS_STATUS') // 권리처리 판정에는 '보류'가 없다 - 변호사 판정과 목록이 다르다. const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS) const [item, setItem] = useState(null) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [contractSelected, setContractSelected] = useState>(new Set()) const [contractEtc, setContractEtc] = useState('') const [judgedKoglType, setJudgedKoglType] = useState('') const [judgedAiType, setJudgedAiType] = useState(false) const [finalOpinion, setFinalOpinion] = useState('') const [judgmentBasis, setJudgmentBasis] = useState('') const [processStatus, setProcessStatus] = useState('') const [busy, setBusy] = useState(false) const [saveError, setSaveError] = useState(null) useEffect(() => { let cancelled = false setLoading(true) setLoadError(null) getProcessItem(org.id, itemId) .then((data) => { if (cancelled) { return } setItem(data) const { selected, etcText } = parseContractDocs(data.contractDocs, contractDocs) setContractSelected(selected) setContractEtc(etcText) setJudgedKoglType(data.judgedKoglType ?? '') setJudgedAiType(data.judgedAiType === 'Y') setFinalOpinion(data.finalOpinion ?? '') setJudgmentBasis(data.judgmentBasis ?? '') setProcessStatus(data.processStatus ?? '') }) .catch((e: Error) => { if (!cancelled) { setLoadError(e.message) } }) .finally(() => { if (!cancelled) { setLoading(false) } }) return () => { cancelled = true } }, [org.id, itemId]) function toggleContractOption(option: string) { setContractSelected((prev) => { const next = new Set(prev) if (next.has(option)) { next.delete(option) } else { next.add(option) } return next }) } async function handleSave() { if (!processStatus) { return } setBusy(true) setSaveError(null) try { const body: ProcessingRequest = { contractDocs: serializeContractDocs(contractSelected, contractEtc, contractDocs), judgedKoglType: judgedKoglType || null, judgedAiType: judgedAiType ? 'Y' : null, finalOpinion: finalOpinion || null, judgmentBasis: judgmentBasis || null, processStatus, } await updateProcessing(org.id, itemId, body) onSaved() } catch (e) { setSaveError(e instanceof Error ? e.message : '저장에 실패했습니다.') } finally { setBusy(false) } } async function handleDelete() { if (!item) { return } if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) { return } setBusy(true) setSaveError(null) try { await deleteProcessItem(org.id, itemId) onSaved() } catch (e) { setSaveError(e instanceof Error ? e.message : '삭제에 실패했습니다.') } finally { setBusy(false) } } if (loading) { return

불러오는 중…

} if (loadError || !item) { return

{loadError ?? '게시물을 찾을 수 없습니다.'}

} return (
} rightLabel="게시판명" right={} />
{item.postTitle ?? '-'} {item.url && ( 🔗 )}
${item.reviewMinor}` : ''}` : null} />
{/* '기타'는 자유 입력이 딸려 있어 뒤에 따로 그린다. */} {contractDocs .filter((option) => option.attrs.freeText !== true) .map((option) => ( ))} {etcDoc && ( )}
{etcDoc && contractSelected.has(etcDoc.code) && ( setContractEtc(e.target.value)} placeholder="기타 내용을 입력하세요" className="w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm" /> )}
{kogl.first.map((option) => ( ))}
{kogl.rest.map((option) => ( ))}
권리처리 결과
최종의견
작성안내) 전부 양도 체결, 일부 양도 체결, 초상 이용 동의, 공공누리 동의 등 처리한 사유 기입