File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
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<string, string> = {
'신유형 개방': '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 (
<h3 className="flex items-center gap-2 text-sm font-bold">
<span className="h-2.5 w-2.5 shrink-0 bg-blue-700" />
{title}
{note && <span className="text-xs font-normal text-gray-400">{note}</span>}
</h3>
)
}
/** 정보 테이블의 회색 라벨 셀. */
function LabelCell({ children }: { children: ReactNode }) {
return (
<div className="flex items-center bg-gray-50 px-3 py-2 text-xs font-medium text-gray-600">{children}</div>
)
}
/** 정보 테이블의 값 셀. */
function ValueCell({ children }: { children: ReactNode }) {
return <div className="flex items-center px-3 py-1.5">{children}</div>
}
/** 읽기전용 값 표시용 회색 박스. */
function ReadonlyBox({ value }: { value: string | null }) {
return (
<div className="w-full rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800">
{value ? value : <span className="text-gray-400">-</span>}
</div>
)
}
/** 단일 라벨/값 행(150px + 1fr). */
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[150px_1fr]">
<LabelCell>{label}</LabelCell>
<ValueCell>{children}</ValueCell>
</div>
)
}
/** 라벨/값 두 쌍을 한 행에 나란히 배치(150px + 1fr + 150px + 1fr). */
function InfoRowPair({
leftLabel,
left,
rightLabel,
right,
}: {
leftLabel: string
left: ReactNode
rightLabel: string
right: ReactNode
}) {
return (
<div className="grid grid-cols-[150px_1fr_150px_1fr]">
<LabelCell>{leftLabel}</LabelCell>
<ValueCell>{left}</ValueCell>
<LabelCell>{rightLabel}</LabelCell>
<ValueCell>{right}</ValueCell>
</div>
)
}
/** 권리확인 매트릭스 표의 라디오 한 칸. 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 (
<label className="flex cursor-pointer flex-col gap-0.5">
<span className="flex items-center gap-1.5">
<input type="radio" name={name} aria-label={value} checked={checked} onChange={onChange} />
<span className={bold ? 'font-semibold text-gray-900' : 'text-gray-800'}>{label ?? value}</span>
</span>
{note && <span className="pl-5 text-[11px] text-gray-400">{note}</span>}
</label>
)
}
function ReviewResultBadge({ value }: { value: string | null }) {
if (!value) {
return (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">미판정</span>
)
}
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${REVIEW_RESULT_BADGE_STYLES[value] ?? 'bg-gray-100 text-gray-600'}`}
>
{value}
</span>
)
}
/** 기관관리 상세의 권리확인 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다. */
export default function ReviewBoard({ org }: { org: Org }) {
const [mode, setMode] = useState<'list' | number>('list')
const [items, setItems] = useState<ReviewItem[]>([])
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<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const importInputRef = useRef<HTMLInputElement>(null)
const [importBusy, setImportBusy] = useState(false)
const [importReport, setImportReport] = useState<ReviewImportReport | null>(null)
const [importError, setImportError] = useState<string | null>(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 (
<ReviewDetail
org={org}
itemId={mode}
onBack={() => setMode('list')}
onSaved={() => {
setMode('list')
setReloadKey((k) => k + 1)
}}
/>
)
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return (
<div className="p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input
type="text"
aria-label="검색어"
value={keywordInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={handleSearch}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
검색
</button>
<button
type="button"
onClick={handleResetSearch}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
초기화
</button>
</div>
<div className="flex items-center gap-2">
<input
ref={importInputRef}
type="file"
accept=".xlsx,.xlsm"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
void handleImportFile(file)
}
e.target.value = ''
}}
/>
<button
type="button"
disabled={importBusy}
onClick={() => importInputRef.current?.click()}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{importBusy ? '업로드 중…' : '엑셀 업로드'}
</button>
<a
href={reviewDownloadUrl(org.id)}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
엑셀 다운로드
</a>
</div>
</div>
{importReport && (
<p className="mt-3 text-sm text-gray-600">
{`신규 ${importReport.created} · 갱신 ${importReport.updated} · 전체 ${importReport.total}`}
</p>
)}
{importError && <p className="mt-3 text-sm text-red-600">{importError}</p>}
<p className="mt-4 text-sm font-medium text-gray-700">
{`총 ${total}건 · 처리 ${done}건`}
</p>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full min-w-[1100px] text-left text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<th className="whitespace-nowrap px-3 py-2 font-medium">No</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">사이트/게시판</th>
<th className="px-3 py-2 font-medium">범주/제목</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">제작/공표일</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">첨부파일</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">기존 공공누리</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">권리확인</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">처리결과</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">공공누리유형(판정)</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">관리</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={10} className="py-8 text-center text-sm text-gray-400">
불러오는 중…
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={10} className="py-10 text-center text-sm text-gray-300">
업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.
</td>
</tr>
) : (
items.map((item) => (
<tr key={item.id} className="border-b border-gray-100 align-top last:border-b-0">
<td className="px-3 py-2 text-gray-500">{item.seq}</td>
<td className="px-3 py-2">
<div className="text-gray-900">{item.siteName ?? '-'}</div>
<div className="text-xs text-gray-400">{item.boardName ?? '-'}</div>
</td>
<td className="px-3 py-2">
<div className="text-gray-500">{item.category ?? '-'}</div>
<div className="flex items-center gap-1 text-gray-900">
<span>{item.postTitle ?? '-'}</span>
{item.url && (
<a
href={item.url}
target="_blank"
rel="noreferrer"
title="원문 보기"
aria-label="원문 보기"
className="text-blue-500 hover:underline"
>
🔗
</a>
)}
</div>
</td>
<td className="px-3 py-2 text-gray-600">
<div>{item.producedDate ?? '-'}</div>
<div className="text-xs text-gray-400">{item.publishedDate ?? '-'}</div>
</td>
<td className="px-3 py-2 text-gray-600">{item.hasAttachment ?? '-'}</td>
<td className="px-3 py-2 text-gray-600">
<div>
{item.koglAttached ?? '-'} / {item.koglType ?? '-'}
</div>
<div className="text-xs text-gray-400">{item.aiType ? 'AI' : ''}</div>
</td>
<td className="px-3 py-2 text-gray-600">
<div>{item.reviewMajor ?? '-'}</div>
<div className="text-xs text-gray-400">{item.reviewMinor ?? ''}</div>
</td>
<td className="px-3 py-2">
<ReviewResultBadge value={item.reviewResult} />
</td>
<td className="px-3 py-2 text-gray-600">
<div>{item.judgedKoglType ?? '-'}</div>
<div className="text-xs text-gray-400">{item.judgedAiType === 'Y' ? 'AI' : ''}</div>
</td>
<td className="px-3 py-2">
<div className="flex gap-2">
<button
type="button"
onClick={() => setMode(item.id)}
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
수정
</button>
<button
type="button"
onClick={() => void handleDelete(item)}
className="rounded-md border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
삭제
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{items.length > 0 && (
<div className="mt-3 flex items-center justify-center gap-3 text-sm text-gray-600">
<button
type="button"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
className="rounded-md border border-gray-300 px-2 py-1 disabled:opacity-40"
>
‹
</button>
<span>{`${page + 1} / ${totalPages} Page`}</span>
<button
type="button"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => p + 1)}
className="rounded-md border border-gray-300 px-2 py-1 disabled:opacity-40"
>
›
</button>
</div>
)}
</div>
)
}
/** 권리확인 상세(변호사 판정 입력) 화면. */
function ReviewDetail({
org,
itemId,
onBack,
onSaved,
}: {
org: Org
itemId: number
onBack: () => void
onSaved: () => void
}) {
const [item, setItem] = useState<ReviewItem | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
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<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setLoadError(null)
getReviewItem(org.id, itemId)
.then((data) => {
if (cancelled) {
return
}
setItem(data)
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 = {
// 개방가능여부는 별도 입력칸 없이 처리결과에서 파생한다("신유형 개방"일 때만 개방 가능).
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 <p className="p-4 py-10 text-center text-sm text-gray-400">불러오는 중…</p>
}
if (loadError || !item) {
return <p className="p-4 py-10 text-center text-sm text-red-600">{loadError ?? '게시물을 찾을 수 없습니다.'}</p>
}
const koglSummary = `${item.koglAttached ?? '-'} / ${item.koglType ?? '-'}${item.aiType ? ' · AI' : ''}`
return (
<div className="p-4">
<section className="rounded-lg border border-gray-200 p-4">
<SectionHeader title="게시물 정보" note="(조사원)" />
<div className="mt-3 overflow-hidden rounded border border-gray-300 divide-y divide-gray-200">
<InfoRow label="기관명">
<ReadonlyBox value={org.orgName} />
</InfoRow>
<InfoRowPair
leftLabel="사이트명"
left={<ReadonlyBox value={item.siteName} />}
rightLabel="범주(카테고리)"
right={<ReadonlyBox value={item.category} />}
/>
<InfoRow label="게시판명">
<ReadonlyBox value={item.boardName} />
</InfoRow>
<InfoRow label="게시물제목">
<ReadonlyBox value={item.postTitle} />
</InfoRow>
<InfoRow label="URL주소">
{item.url ? (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="w-full break-all rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-blue-600 hover:underline"
>
{item.url}
</a>
) : (
<ReadonlyBox value={null} />
)}
</InfoRow>
<InfoRowPair
leftLabel="제작일"
left={<ReadonlyBox value={item.producedDate} />}
rightLabel="공표일"
right={<ReadonlyBox value={item.publishedDate} />}
/>
<InfoRow label="첨부파일 여부">
<ReadonlyBox value={item.hasAttachment} />
</InfoRow>
<InfoRow label="기존 공공누리">
<ReadonlyBox value={koglSummary} />
</InfoRow>
</div>
</section>
<section className="mt-4 rounded-lg border border-gray-200 p-4">
<SectionHeader title="권리확인" note="(변호사)" />
<div className="mt-3 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-50 text-xs font-semibold text-gray-600">
<th className="border border-gray-300 px-3 py-2">좌(대분류)</th>
<th className="border border-gray-300 px-3 py-2">중(세부)</th>
<th className="border border-gray-300 px-3 py-2">우(처리결과)</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
<MatrixRadio
name="review-major"
value={REVIEW_MAJOR_OPTIONS[0]}
note="저작권성 없음, 배포용, 단순데이터"
checked={reviewMajor === REVIEW_MAJOR_OPTIONS[0]}
onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[0])}
/>
</td>
<td className="border border-gray-300 px-3 py-2" />
<td
className="border border-gray-300 bg-gray-50 px-3 py-2 text-center align-middle"
rowSpan={2}
>
<MatrixRadio
name="review-result"
value={REVIEW_RESULT_OPTIONS[0]}
checked={reviewResult === REVIEW_RESULT_OPTIONS[0]}
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[0])}
bold
/>
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
<MatrixRadio
name="review-major"
value={REVIEW_MAJOR_OPTIONS[1]}
checked={reviewMajor === REVIEW_MAJOR_OPTIONS[1]}
onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[1])}
/>
</td>
<td className="border border-gray-300 px-3 py-2 align-top">
<MatrixRadio
name="review-minor"
value={REVIEW_MINOR_DISPLAY[0].value}
label={REVIEW_MINOR_DISPLAY[0].label}
checked={reviewMinor === REVIEW_MINOR_DISPLAY[0].value}
onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[0].value)}
/>
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={4}>
<MatrixRadio
name="review-major"
value={REVIEW_MAJOR_OPTIONS[2]}
checked={reviewMajor === REVIEW_MAJOR_OPTIONS[2]}
onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[2])}
/>
</td>
<td className="border border-gray-300 px-3 py-2 align-top">
<MatrixRadio
name="review-minor"
value={REVIEW_MINOR_DISPLAY[1].value}
label={REVIEW_MINOR_DISPLAY[1].label}
note={REVIEW_MINOR_DISPLAY[1].note}
checked={reviewMinor === REVIEW_MINOR_DISPLAY[1].value}
onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[1].value)}
/>
</td>
<td
className="border border-gray-300 bg-gray-50 px-3 py-2 text-center align-middle"
rowSpan={1}
>
<MatrixRadio
name="review-result"
value={REVIEW_RESULT_OPTIONS[1]}
checked={reviewResult === REVIEW_RESULT_OPTIONS[1]}
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[1])}
bold
/>
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top">
<MatrixRadio
name="review-minor"
value={REVIEW_MINOR_DISPLAY[2].value}
label={REVIEW_MINOR_DISPLAY[2].label}
note={REVIEW_MINOR_DISPLAY[2].note}
checked={reviewMinor === REVIEW_MINOR_DISPLAY[2].value}
onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[2].value)}
/>
</td>
<td
className="border border-gray-300 bg-gray-50 px-3 py-2 text-center align-middle"
rowSpan={3}
>
<MatrixRadio
name="review-result"
value={REVIEW_RESULT_OPTIONS[2]}
checked={reviewResult === REVIEW_RESULT_OPTIONS[2]}
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[2])}
bold
/>
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top">
<MatrixRadio
name="review-minor"
value={REVIEW_MINOR_DISPLAY[3].value}
label={REVIEW_MINOR_DISPLAY[3].label}
note={REVIEW_MINOR_DISPLAY[3].note}
checked={reviewMinor === REVIEW_MINOR_DISPLAY[3].value}
onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[3].value)}
/>
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top">
<MatrixRadio
name="review-minor"
value={REVIEW_MINOR_DISPLAY[4].value}
label={REVIEW_MINOR_DISPLAY[4].label}
checked={reviewMinor === REVIEW_MINOR_DISPLAY[4].value}
onChange={() => pickMinor(REVIEW_MINOR_DISPLAY[4].value)}
/>
</td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 align-top" rowSpan={1}>
<MatrixRadio
name="review-major"
value={REVIEW_MAJOR_OPTIONS[3]}
note="공공누리 적용 제외 대상, 검토불가"
checked={reviewMajor === REVIEW_MAJOR_OPTIONS[3]}
onChange={() => pickMajor(REVIEW_MAJOR_OPTIONS[3])}
/>
</td>
<td className="border border-gray-300 px-3 py-2" />
<td
className="border border-gray-300 bg-gray-50 px-3 py-2 text-center align-middle"
rowSpan={1}
>
<MatrixRadio
name="review-result"
value={REVIEW_RESULT_OPTIONS[3]}
checked={reviewResult === REVIEW_RESULT_OPTIONS[3]}
onChange={() => pickResult(REVIEW_RESULT_OPTIONS[3])}
bold
/>
</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-3 overflow-hidden rounded border border-gray-300 divide-y divide-gray-200">
<InfoRow label="공공누리 유형">
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap gap-x-4 gap-y-1">
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o === '0유형' || o === '개방불가').map((option) => (
<label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
<input
type="radio"
name="judged-kogl-type"
checked={judgedKoglType === option}
onChange={() => setJudgedKoglType(option)}
/>
{option}
</label>
))}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{JUDGED_KOGL_TYPE_OPTIONS.filter((o) => o !== '0유형' && o !== '개방불가').map((option) => (
<label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
<input
type="radio"
name="judged-kogl-type"
checked={judgedKoglType === option}
onChange={() => setJudgedKoglType(option)}
/>
{option}
</label>
))}
<label className="ml-2 flex items-center gap-1.5 text-sm text-gray-800">
<input
type="checkbox"
checked={judgedAiType}
onChange={(e) => setJudgedAiType(e.target.checked)}
/>
AI유형
</label>
</div>
</div>
</InfoRow>
<InfoRow label="권리처리필요">
<label className="flex items-center gap-1.5 text-sm text-gray-800">
<input
type="checkbox"
checked={needsProcessing}
onChange={(e) => setNeedsProcessing(e.target.checked)}
/>
권리처리필요
</label>
</InfoRow>
<InfoRow label="의견">
<textarea
id="review-opinion"
aria-label="의견"
rows={3}
value={opinion}
onChange={(e) => setOpinion(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</InfoRow>
<InfoRow label="비고">
<textarea
id="review-lawyer-note"
aria-label="비고"
rows={3}
value={lawyerNote}
onChange={(e) => setLawyerNote(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</InfoRow>
</div>
{saveError && <p className="mt-3 text-sm text-red-600">{saveError}</p>}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
disabled={busy}
onClick={onBack}
className="rounded-md bg-gray-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-gray-700 disabled:opacity-50"
>
목록
</button>
<button
type="button"
disabled={busy}
onClick={() => void handleSave()}
className="rounded-md bg-blue-700 px-4 py-1.5 text-sm font-semibold text-white hover:bg-blue-800 disabled:cursor-not-allowed disabled:bg-gray-300"
>
수정
</button>
</div>
</section>
</div>
)
}