import { useEffect, useRef, useState, type ReactNode } from 'react' import { deleteProcessItem, getProcessItem, getProcessPage, importProcess, processDownloadUrl, updateProcessing, type Org, type ProcessImportReport, type ProcessItem, type ProcessStatusFilter, type ProcessingRequest, } from '../api/client' const PAGE_SIZE = 30 const STATUS_CHIPS: { key: ProcessStatusFilter | undefined; label: string }[] = [ { key: undefined, label: '전체' }, { key: 'PENDING', label: '미처리' }, { key: 'DONE', label: '처리완료' }, ] const CONTRACT_DOC_OPTIONS = ['양도계약서', '제안요청서', '초상이용동의서', '공공누리동의서', '공문'] const CONTRACT_ETC = '기타' const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '개방불가', '1유형', '2유형', '3유형', '4유형'] const PROCESS_STATUS_OPTIONS = ['미처리', '처리완료'] /** "양도계약서, 공문, 기타:직접 촬영본 사용동의" 형태의 저장값 → 체크박스 상태로 역파싱한다. */ function parseContractDocs(value: string | null): { selected: Set; etcText: string } { const selected = new Set() let etcText = '' if (!value) { return { selected, etcText } } for (const raw of value.split(',')) { const part = raw.trim() if (!part) { continue } if (part.startsWith(`${CONTRACT_ETC}:`)) { selected.add(CONTRACT_ETC) etcText = part.slice(CONTRACT_ETC.length + 1) } else if (CONTRACT_DOC_OPTIONS.includes(part)) { selected.add(part) } } return { selected, etcText } } /** 체크박스 상태 → 저장값. 아무것도 선택하지 않았으면 null. */ function serializeContractDocs(selected: Set, etcText: string): string | null { const parts: string[] = CONTRACT_DOC_OPTIONS.filter((opt) => selected.has(opt)) if (selected.has(CONTRACT_ETC)) { parts.push(`${CONTRACT_ETC}:${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 }) { const done = value === '처리완료' return ( {done ? '처리완료' : '미처리'} ) } /** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */ export default function ProcessBoard({ org }: { org: Org }) { 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('') 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, 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, statusFilter, page, reloadKey]) function handleSearch() { setPage(0) setAppliedKeyword(keywordInput.trim()) } function handleResetSearch() { setKeywordInput('') setPage(0) setAppliedKeyword('') } 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) } } async function handleDelete(item: ProcessItem) { if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) { return } try { await deleteProcessItem(org.id, item.id) setReloadKey((k) => k + 1) } catch (e) { setError(e instanceof Error ? e.message : '삭제에 실패했습니다.') } } if (mode !== 'list') { return ( setMode('list')} onSaved={() => { setMode('list') setReloadKey((k) => k + 1) }} /> ) } const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) return (
{STATUS_CHIPS.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}

}
{loading ? ( ) : items.length === 0 ? ( ) : ( items.map((item) => ( )) )}
No 사이트/게시판 제목 제작/공표일 기존유형/계약서 권리확인구분 권리확인 공공누리유형 계약여부 처리 공공누리유형 최종의견 처리상태 처리일시 관리
불러오는 중…
업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.
{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 === '처리완료' ? formatDate(item.processedAt) : '-'}
{item.processStatus === '처리완료' ? ( <> ) : ( )}
{items.length > 0 && (
{`${page + 1} / ${totalPages} Page`}
)}
) } /** 권리처리 상세([처리등록]/[수정]) 화면. */ function ProcessDetail({ org, itemId, onBack, onSaved, }: { org: Org itemId: number onBack: () => void onSaved: () => void }) { 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) 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), 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} />
{CONTRACT_DOC_OPTIONS.map((option) => ( ))}
{contractSelected.has(CONTRACT_ETC) && ( setContractEtc(e.target.value)} placeholder="기타 내용을 입력하세요" className="w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm" /> )}
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => ( ))}
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => ( ))}
권리처리 결과
최종의견
작성안내) 전부 양도 체결, 일부 양도 체결, 초상 이용 동의, 공공누리 동의 등 처리한 사유 기입