import { useEffect, useRef, useState, type ReactNode } from 'react' import { bulkDeleteReview, bulkUpdateReview, getReviewItem, getReviewPage, importReview, reviewDownloadUrl, updateReviewJudgment, type JudgmentRequest, type Org, type ReviewImportReport, type ReviewItem, } from '../api/client' const PAGE_SIZE = 30 const REVIEW_MAJOR_OPTIONS = ['만료 저작물', '업무상 저작물', '제3자 권리', '개인정보 포함'] const REVIEW_MINOR_OPTIONS = [ '1. 원시적 권리 전부 보유', '2. 후천적 권리 전부 보유(계약서 확인 필요)', '3. 권리 일부(공동) 보유', '4. 권리 미보유', '초상권 포함', ] /** * 처리결과 선택지. 앞 4개의 순서는 판정 매트릭스의 행 배치와 맞물려 있으므로 바꾸지 말고, * 새 항목은 뒤에 붙인다. '권리처리 추진 미희망'은 제3자 권리 정책변경으로 추가된 항목이다. */ const REVIEW_RESULT_OPTIONS = [ '신유형 개방', '계약서 등 재확인', '권리처리 추진', '개방불가', '권리처리 추진 미희망', ] const RESULT_PROCESSING = '권리처리 추진' const RESULT_NO_PROCESSING = '권리처리 추진 미희망' const RESULT_BLOCKED = '개방불가' /** 권리처리를 추진하는 동안에는 공공누리 유형을 확정하지 않고 '보류'로 둔다(정책변경으로 신설). */ const KOGL_ON_HOLD = '보류' /** 권리처리를 추진하지 않아 기존 공공누리 유형을 그대로 쓸 때 비고에 남기는 문구. */ const NOTE_NO_PROCESSING = '권리처리 추진 미희망에 따른 기존 공공누리 부착' const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가', KOGL_ON_HOLD] /** 공공누리 유형 라디오 첫 줄에 놓는 항목(나머지는 둘째 줄). */ const KOGL_FIRST_ROW = ['0유형', RESULT_BLOCKED, KOGL_ON_HOLD] /** * 제3자 권리 세부항목별로 고를 수 있는 처리결과. 정책변경(2026-07-20 검토안)에 따라 * 3·4번은 추진/미희망 택일, 초상권은 개방불가까지 3택이다. 여기 없는 세부항목은 제한이 없다. */ const ALLOWED_RESULTS_BY_MINOR: Record = { '3. 권리 일부(공동) 보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING], '4. 권리 미보유': [RESULT_PROCESSING, RESULT_NO_PROCESSING], '초상권 포함': [RESULT_PROCESSING, RESULT_NO_PROCESSING, RESULT_BLOCKED], } const REVIEW_RESULT_BADGE_STYLES: Record = { '신유형 개방': 'bg-emerald-50 text-emerald-700', '계약서 등 재확인': 'bg-amber-50 text-amber-700', '권리처리 추진': 'bg-blue-50 text-blue-700', '권리처리 추진 미희망': 'bg-slate-100 text-slate-700', 개방불가: 'bg-red-50 text-red-700', } /** 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지). */ function suggestReviewResult(major: string, minor: string): string | null { if (major === '만료 저작물' || major === '업무상 저작물') { return '신유형 개방' } if (major === '개인정보 포함') { return '개방불가' } if (major === '제3자 권리') { if (minor === '1. 원시적 권리 전부 보유') { return '신유형 개방' } if (minor === '2. 후천적 권리 전부 보유(계약서 확인 필요)') { return '계약서 등 재확인' } if (ALLOWED_RESULTS_BY_MINOR[minor]) { // 정책변경: 추진/미희망(초상권은 개방불가까지) 중 택일이며 기본값은 '권리처리 추진'이다. return RESULT_PROCESSING } } return null } /** 세부(review-minor) 라디오의 실제 저장값은 REVIEW_MINOR_OPTIONS 그대로 유지하고, * 참조 양식과 같은 화면 표기(줄바꿈된 부연설명)만 별도로 매핑한다. */ const REVIEW_MINOR_DISPLAY: { value: string; label: string; note?: string }[] = [ { value: REVIEW_MINOR_OPTIONS[0], label: '1. 원시적 권리 전부 보유' }, { value: REVIEW_MINOR_OPTIONS[1], label: '2. 후천적 권리 전부 보유', note: '(계약에 의한 전부 양수)' }, { value: REVIEW_MINOR_OPTIONS[2], label: '3. 권리 일부(공동) 보유', note: '(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)', }, { value: REVIEW_MINOR_OPTIONS[3], label: '4. 권리 미보유', note: '(공모전 수상작 등)' }, { value: REVIEW_MINOR_OPTIONS[4], label: '초상권 포함' }, ] /** 소제목: 작은 파란 사각 불릿 + 굵은 제목 + 회색 부연설명. */ function SectionHeader({ title, note }: { title: string; note?: string }) { return (

{title} {note && {note}}

) } /** 정보 테이블의 회색 라벨 셀. */ function LabelCell({ children }: { children: ReactNode }) { return (
{children}
) } /** 정보 테이블의 값 셀. */ function ValueCell({ children }: { children: ReactNode }) { return
{children}
} /** 읽기전용 값 표시용 회색 박스. */ function ReadonlyBox({ value }: { value: string | null }) { return (
{value ? value : -}
) } /** 단일 라벨/값 행(150px + 1fr). */ function InfoRow({ label, children }: { label: string; 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}
) } /** 권리확인 매트릭스 표의 라디오 한 칸. aria-label로 접근성 이름을 실제 저장값 텍스트로 고정해 * 화면 표기(줄바꿈된 부연설명)가 늘어나도 접근성 이름은 항상 원래 값과 동일하게 유지한다. */ function MatrixRadio({ name, value, label, note, checked, onChange, bold, disabled, }: { name: string value: string label?: string note?: string checked: boolean onChange: () => void bold?: boolean /** 그 세부항목에서 고를 수 없는 처리결과를 잠근다(정책상 택일 범위 밖). */ disabled?: boolean }) { return ( ) } function ReviewResultBadge({ value }: { value: string | null }) { if (!value) { return ( 미판정 ) } return ( {value} ) } /** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */ export default function ReviewBoard({ 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 [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: string; board: string; title: string }>({ site: '', board: '', title: '', }) /** 일괄 등록/삭제 대상. 체크한 행의 id. */ const [selected, setSelected] = useState([]) const [bulkResult, setBulkResult] = useState('') const [bulkKoglType, setBulkKoglType] = useState('') const [bulkOpinion, setBulkOpinion] = 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) getReviewPage(org.id, { keyword: appliedKeyword || undefined, site: applied.site || undefined, board: applied.board || undefined, title: applied.title || undefined, 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, page, reloadKey]) function handleSearch() { setPage(0) setAppliedKeyword(keywordInput.trim()) setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() }) } function handleResetSearch() { setKeywordInput('') setSiteInput('') setBoardInput('') setTitleInput('') setPage(0) setAppliedKeyword('') setApplied({ site: '', board: '', title: '' }) } 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 (!bulkResult && !bulkKoglType && !bulkOpinion.trim()) { setBulkMessage('반영할 값을 하나 이상 채워주세요.') return } setBulkBusy(true) setBulkMessage(null) try { const res = await bulkUpdateReview(org.id, { ids: selected, reviewResult: bulkResult || undefined, judgedKoglType: bulkKoglType || undefined, opinion: bulkOpinion.trim() || undefined, }) setBulkMessage(`${res.updated}건에 반영했습니다.`) setSelected([]) setBulkResult('') setBulkKoglType('') setBulkOpinion('') 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 bulkDeleteReview(org.id, selected) setBulkMessage(`${res.deleted}건을 삭제했습니다.`) setSelected([]) setReloadKey((k) => k + 1) } catch (e) { setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.') } finally { setBulkBusy(false) } } async function handleImportFile(file: File) { setImportBusy(true) setImportError(null) try { const report = await importReview(org.id, file) setImportReport(report) setReloadKey((k) => k + 1) } catch (e) { setImportError(e instanceof Error ? e.message : '엑셀 업로드에 실패했습니다.') } finally { setImportBusy(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 (
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}

} {advancedOpen && (
{( [ ['사이트명', siteInput, setSiteInput], ['게시판명', boardInput, setBoardInput], ['게시물 제목', titleInput, setTitleInput], ] as const ).map(([label, value, setter]) => ( ))} 세 조건은 모두 만족하는 건만 찾습니다(AND)
)}

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

{/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다(행마다 두지 않는다). */}
선택 {selected.length}건 setBulkOpinion(e.target.value)} placeholder="의견(비우면 유지)" className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm" /> {bulkMessage && {bulkMessage}}
{error &&

{error}

}
{loading ? ( ) : items.length === 0 ? ( ) : ( items.map((item) => ( )) )}
No 사이트/게시판 게시물 경로/제목 제작/공표일 첨부파일 기존 공공누리 권리확인 처리결과 공공누리유형(판정) 관리
불러오는 중…
업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.
toggleOne(item.id)} /> {item.seq}
{item.siteName ?? '-'}
{item.boardName ?? '-'}
{item.category ?? '-'}
{item.postTitle ?? '-'} {item.url && ( 🔗 )}
{item.producedDate ?? '-'}
{item.publishedDate ?? '-'}
{item.hasAttachment ?? '-'}
{item.koglAttached ?? '-'} / {item.koglType ?? '-'}
{item.aiType ? 'AI' : ''}
{item.reviewMajor ?? '-'}
{item.reviewMinor ?? ''}
{item.judgedKoglType ?? '-'}
{item.judgedAiType === 'Y' ? 'AI' : ''}
{items.length > 0 && (
{`${page + 1} / ${totalPages} Page`}
)}
) } /** 권리확인 상세(변호사 판정 입력) 화면. */ function ReviewDetail({ 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 [hasAttachment, setHasAttachment] = useState('') const [koglAttached, setKoglAttached] = useState('') const [koglType, setKoglType] = useState('') const [aiType, setAiType] = useState(false) const [reviewMajor, setReviewMajor] = useState('') const [reviewMinor, setReviewMinor] = useState('') const [reviewResult, setReviewResult] = useState('') const [resultManuallySet, setResultManuallySet] = useState(false) const [judgedKoglType, setJudgedKoglType] = useState('') const [judgedAiType, setJudgedAiType] = useState(false) const [needsProcessing, setNeedsProcessing] = useState(false) const [opinion, setOpinion] = useState('') const [lawyerNote, setLawyerNote] = useState('') const [busy, setBusy] = useState(false) const [saveError, setSaveError] = useState(null) useEffect(() => { let cancelled = false setLoading(true) setLoadError(null) getReviewItem(org.id, itemId) .then((data) => { if (cancelled) { return } setItem(data) setHasAttachment(data.hasAttachment ?? '') setKoglAttached(data.koglAttached ?? '') setKoglType(data.koglType ?? '') setAiType(data.aiType === 'Y') setReviewMajor(data.reviewMajor ?? '') setReviewMinor(data.reviewMinor ?? '') setReviewResult(data.reviewResult ?? '') setResultManuallySet(false) setJudgedKoglType(data.judgedKoglType ?? '') setJudgedAiType(data.judgedAiType === 'Y') setNeedsProcessing(data.needsProcessing === 'Y') setOpinion(data.opinion ?? '') setLawyerNote(data.lawyerNote ?? '') }) .catch((e: Error) => { if (!cancelled) { setLoadError(e.message) } }) .finally(() => { if (!cancelled) { setLoading(false) } }) return () => { cancelled = true } }, [org.id, itemId]) function applySuggestion(major: string, minor: string) { if (resultManuallySet) { return } const suggestion = suggestReviewResult(major, minor) if (suggestion) { setReviewResult(suggestion) applyResultDerivations(suggestion) } } /** * 처리결과가 정해지면 공공누리 유형·비고·권리처리필요를 규칙대로 함께 맞춘다 * (2026-07-20 제3자 권리처리 정책변경). * * - 권리처리 추진 → 공공누리 유형 '보류' * - 권리처리 추진 미희망 → 기존 공공누리 유형을 그대로 쓰고 비고에 사유를 남긴다. * 기존 유형이 없으면 미선택 상태로 둔다(별도 제한 없음) * - 개방불가 → 공공누리 유형 '개방불가' * * 사용자가 이 값들을 다시 손으로 바꿀 수 있게, 잠그지는 않고 값만 채워 준다. */ function applyResultDerivations(result: string) { if (result === RESULT_PROCESSING) { setJudgedKoglType(KOGL_ON_HOLD) setNeedsProcessing(true) return } if (result === RESULT_NO_PROCESSING) { setNeedsProcessing(false) setJudgedKoglType(koglType || '') if (koglType) { setLawyerNote((prev) => prev.includes(NOTE_NO_PROCESSING) ? prev : [prev.trim(), NOTE_NO_PROCESSING].filter(Boolean).join('\n'), ) } return } if (result === RESULT_BLOCKED) { setJudgedKoglType(RESULT_BLOCKED) setNeedsProcessing(false) } } /** 지금 고른 세부항목에서 그 처리결과를 고를 수 있는지. 제한이 없는 세부항목은 전부 허용이다. */ function resultAllowed(option: string): boolean { const allowed = ALLOWED_RESULTS_BY_MINOR[reviewMinor] return !allowed || allowed.includes(option) } function pickMajor(major: string) { setReviewMajor(major) const minor = major === '제3자 권리' ? reviewMinor : '' setReviewMinor(minor) applySuggestion(major, minor) } function pickMinor(minor: string) { setReviewMinor(minor) applySuggestion(reviewMajor, minor) } function pickResult(result: string) { setReviewResult(result) setResultManuallySet(true) applyResultDerivations(result) } async function handleSave() { setBusy(true) setSaveError(null) try { const body: JudgmentRequest = { hasAttachment: hasAttachment || null, koglAttached: koglAttached || null, koglType: koglType || null, aiType: aiType ? 'Y' : null, // 개방가능여부는 별도 입력칸 없이 처리결과에서 파생한다("신유형 개방"일 때만 개방 가능). openable: reviewResult === '신유형 개방' ? 'Y' : reviewResult ? 'N' : null, reviewMajor: reviewMajor || null, reviewMinor: reviewMinor || null, reviewResult: reviewResult || null, judgedKoglType: judgedKoglType || null, judgedAiType: judgedAiType ? 'Y' : null, opinion: opinion || null, lawyerNote: lawyerNote || null, needsProcessing: needsProcessing ? 'Y' : null, } await updateReviewJudgment(org.id, itemId, body) onSaved() } catch (e) { setSaveError(e instanceof Error ? e.message : '저장에 실패했습니다.') } finally { setBusy(false) } } if (loading) { return

불러오는 중…

} if (loadError || !item) { return

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

} return (
} rightLabel="범주(카테고리)" right={} /> {item.url ? ( {item.url} ) : ( )} } rightLabel="공표일" right={} />
{['있음', '없음'].map((option) => ( ))}
{['부착', '미부착'].map((option) => ( ))}
좌(대분류) 중(세부) 우(처리결과)
pickMajor(REVIEW_MAJOR_OPTIONS[0])} /> pickResult(REVIEW_RESULT_OPTIONS[0])} disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[0])} bold />
pickMajor(REVIEW_MAJOR_OPTIONS[1])} /> pickMinor(REVIEW_MINOR_DISPLAY[0].value)} />
pickMajor(REVIEW_MAJOR_OPTIONS[2])} /> pickMinor(REVIEW_MINOR_DISPLAY[1].value)} /> pickResult(REVIEW_RESULT_OPTIONS[1])} disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[1])} bold />
pickMinor(REVIEW_MINOR_DISPLAY[2].value)} /> {/* 정책변경: 3·4·초상권은 추진/미희망 택일이다. 초상권만 개방불가까지 고를 수 있다. */}
pickResult(RESULT_PROCESSING)} disabled={!resultAllowed(RESULT_PROCESSING)} bold /> pickResult(RESULT_NO_PROCESSING)} disabled={!resultAllowed(RESULT_NO_PROCESSING)} /> {reviewMinor === '초상권 포함' && ( 개방불가도 선택 가능 )}
pickMinor(REVIEW_MINOR_DISPLAY[3].value)} />
pickMinor(REVIEW_MINOR_DISPLAY[4].value)} />
pickMajor(REVIEW_MAJOR_OPTIONS[3])} /> pickResult(REVIEW_RESULT_OPTIONS[3])} disabled={!resultAllowed(REVIEW_RESULT_OPTIONS[3])} bold />
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => KOGL_FIRST_ROW.includes(o)).map((option) => ( ))}
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => !KOGL_FIRST_ROW.includes(o)).map((option) => ( ))}