import { useEffect, useRef, useState, type ReactNode } from 'react' import { bulkDeleteReview, bulkUpdateReview, getReviewItem, getReviewPage, importReview, reviewDownloadUrl, updateReviewJudgment, type JudgmentRequest, type Org, type ReviewImportReport, type Code, type ReviewItem, } from '../api/client' import { NOTE_NO_PROCESSING } from '../codes/messages' import StatusChip from '../ui/StatusChip' import { attr, useCodes, useScopedCodes, useSplitByFirstRow } from '../codes/useCodes' const PAGE_SIZE = 30 /** 공공누리 유형이 쓰이는 자리. 변호사 판정과 기존 부착 유형은 고를 수 있는 목록이 다르다. */ const SCOPE_REVIEW = 'review' const SCOPE_PRIOR = 'prior' // 대분류·세부·처리결과·공공누리 유형은 모두 코드표에서 온다. // 판정 매트릭스는 종이 양식과 같은 자리에 칸을 놓아야 해서 목록을 순서대로 훑지 않고 // majors[0], minors[1] 처럼 자리로 집는다. 순서는 코드표의 정렬순서가 정한다. /** * 대분류/세부 선택에 따른 처리결과 추천값. 매칭되는 조합이 없으면 null(현재 값 유지). * * 예전에는 대분류·세부 이름을 직접 비교하는 if 사슬이었다. 지금은 코드의 suggest 속성만 * 읽는다 - 추천값이 바뀌어도 코드표만 고치면 된다. */ function suggestReviewResult( major: string, minor: string, majors: Code[], minors: Code[], ): string | null { const majorCode = majors.find((candidate) => candidate.code === major) if (!majorCode) { return null } // 세부항목을 쓰는 대분류(제3자 권리)는 세부에서 추천값이 나온다. if (majorCode.attrs.hasMinor === true) { return attr(minors.find((candidate) => candidate.code === minor), 'suggest') } return attr(majorCode, 'suggest') } /** 세부항목의 회색 부연설명. 없으면 undefined. */ function noteOf(code: Code | undefined): string | undefined { return attr(code, 'note') ?? undefined } /** 소제목: 작은 파란 사각 불릿 + 굵은 제목 + 회색 부연설명. */ 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 }) { // 색과 모양이 모두 코드의 tone에서 파생된다. 코드표에 없는 값이면 회색 빈 사각으로 떨어진다. const results = useCodes('REVIEW_RESULT') if (!value) { return } const tone = attr(results.find((candidate) => candidate.code === value), 'tone') return } /** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */ export default function ReviewBoard({ org }: { org: Org }) { // 일괄 등록 드롭다운이 쓰는 선택지. 변호사가 고르는 자리라 review scope다. const results = useCodes('REVIEW_RESULT') const koglReview = useScopedCodes('KOGL_TYPE', SCOPE_REVIEW) 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('') /** 요구사항 [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) 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() && !bulkNote.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, lawyerNote: bulkNote.trim() || undefined, }) setBulkMessage(`${res.updated}건에 반영했습니다.`) setSelected([]) setBulkResult('') 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 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" /> setBulkNote(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 }) { // 판정 매트릭스가 쓰는 선택지. 자리(majors[0] 등)로 집으므로 코드표의 정렬순서가 곧 배치다. const majors = useCodes('REVIEW_MAJOR') const minors = useCodes('REVIEW_MINOR') const results = useCodes('REVIEW_RESULT') // 변호사 판정값과 기존 부착 유형은 고를 수 있는 목록이 다르다. const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_REVIEW) const koglPrior = useScopedCodes('KOGL_TYPE', SCOPE_PRIOR) const attachmentYn = useCodes('ATTACHMENT_YN') const koglAttachedOptions = useCodes('KOGL_ATTACHED') 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, majors, minors) if (suggestion) { setReviewResult(suggestion) applyResultDerivations(suggestion) } } /** * 처리결과가 정해지면 공공누리 유형·비고·권리처리필요를 규칙대로 함께 맞춘다 * (2026-07-20 제3자 권리처리 정책변경). * * - 권리처리 추진 → 공공누리 유형 '보류' * - 권리처리 추진 미희망 → 기존 공공누리 유형을 그대로 쓰고 비고에 사유를 남긴다. * 기존 유형이 없으면 미선택 상태로 둔다(별도 제한 없음) * - 개방불가 → 공공누리 유형 '개방불가' * * 사용자가 이 값들을 다시 손으로 바꿀 수 있게, 잠그지는 않고 값만 채워 준다. * * 어떤 값을 채울지는 코드표(needsProcessing·koglSet·koglCopiesPrior)에서 오고, * "기존 유형을 복사하고 비고에 문구를 덧붙인다"는 동작만 여기 남는다. 동작까지 데이터로 * 옮기면 코드표 안에 규칙 언어를 새로 만드는 꼴이 된다. */ function applyResultDerivations(result: string) { const resultCode = results.find((candidate) => candidate.code === result) if (!resultCode) { return } const needs = resultCode.attrs.needsProcessing if (typeof needs === 'boolean') { setNeedsProcessing(needs) } if (resultCode.attrs.koglCopiesPrior === true) { // 기존에 부착돼 있던 유형을 그대로 쓰고, 그 사유를 비고에 남긴다. setJudgedKoglType(koglType || '') if (koglType) { setLawyerNote((prev) => prev.includes(NOTE_NO_PROCESSING) ? prev : [prev.trim(), NOTE_NO_PROCESSING].filter(Boolean).join('\n'), ) } return } const koglSet = attr(resultCode, 'koglSet') if (koglSet) { setJudgedKoglType(koglSet) } } /** 지금 고른 세부항목에서 그 처리결과를 고를 수 있는지. 제한이 없는 세부항목은 전부 허용이다. */ function resultAllowed(option: string): boolean { const minorCode = minors.find((candidate) => candidate.code === reviewMinor) const allowed = minorCode?.attrs.allowed return !Array.isArray(allowed) || (allowed as string[]).includes(option) } function pickMajor(major: string) { setReviewMajor(major) // 세부항목을 쓰지 않는 대분류로 옮기면 세부 선택을 비운다. const majorCode = majors.find((candidate) => candidate.code === major) const minor = majorCode?.attrs.hasMinor === true ? 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={} />
{attachmentYn.map((option) => ( ))}
{koglAttachedOptions.map((option) => ( ))}
좌(대분류) 중(세부) 우(처리결과)
pickMajor(majors[0].code)} /> pickResult(results[0].code)} disabled={!resultAllowed(results[0].code)} bold />
pickMajor(majors[1].code)} /> pickMinor(minors[0].code)} />
pickMajor(majors[2].code)} /> pickMinor(minors[1].code)} /> pickResult(results[1].code)} disabled={!resultAllowed(results[1].code)} bold />
pickMinor(minors[2].code)} /> {/* 정책변경: 3·4·초상권은 추진/미희망 택일이다. 초상권만 개방불가까지 고를 수 있다. */}
pickResult(results[2].code)} disabled={!resultAllowed(results[2].code)} bold /> pickResult(results[4].code)} disabled={!resultAllowed(results[4].code)} /> {reviewMinor === minors[4].code && ( 개방불가도 선택 가능 )}
pickMinor(minors[3].code)} />
pickMinor(minors[4].code)} />
pickMajor(majors[3].code)} /> pickResult(results[3].code)} disabled={!resultAllowed(results[3].code)} bold />
{kogl.first.map((option) => ( ))}
{kogl.rest.map((option) => ( ))}