import { useEffect, useRef, useState, type ReactNode } from 'react' import { deleteReviewItem, 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. 권리 미보유', '초상권 포함', ] const REVIEW_RESULT_OPTIONS = ['신유형 개방', '계약서 등 재확인', '권리처리 추진', '개방불가'] const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '1유형', '2유형', '3유형', '4유형', '개방불가'] 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-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 (minor === '3. 권리 일부(공동) 보유' || minor === '4. 권리 미보유' || minor === '초상권 포함') { return '권리처리 추진' } } 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, }: { name: string value: string label?: string note?: string checked: boolean onChange: () => void bold?: 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('') 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, 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, page, reloadKey]) function handleSearch() { setPage(0) setAppliedKeyword(keywordInput.trim()) } function handleResetSearch() { setKeywordInput('') setPage(0) setAppliedKeyword('') } 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) } } async function handleDelete(item: ReviewItem) { if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) { return } try { await deleteReviewItem(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 (
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.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) } } 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) } 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])} 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])} bold />
pickMinor(REVIEW_MINOR_DISPLAY[2].value)} /> pickResult(REVIEW_RESULT_OPTIONS[2])} bold />
pickMinor(REVIEW_MINOR_DISPLAY[3].value)} />
pickMinor(REVIEW_MINOR_DISPLAY[4].value)} />
pickMajor(REVIEW_MAJOR_OPTIONS[3])} /> pickResult(REVIEW_RESULT_OPTIONS[3])} bold />
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => ( ))}
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => ( ))}