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 {
bulkDeleteProcess,
bulkUpdateProcess,
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<string>; etcText: string } {
const selected = new Set<string>()
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<string>, 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 (
<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}
</h3>
)
}
/** 정보 테이블의 회색 라벨 셀. required면 라벨 앞에 빨간 별표를 붙인다. */
function LabelCell({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<div className="flex items-center bg-gray-50 px-3 py-2 text-xs font-medium text-gray-600">
{required && <span className="text-red-500">*</span>}
{children}
</div>
)
}
/** 정보 테이블의 값 셀. */
function ValueCell({ children }: { children: ReactNode }) {
return <div className="flex items-center px-3 py-1.5">{children}</div>
}
/** 읽기전용 값 표시용 회색 박스. */
function ReadonlyBox({ value }: { value: ReactNode }) {
return (
<div className="w-full rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800">
{value || <span className="text-gray-400">-</span>}
</div>
)
}
/** 단일 라벨/값 행(150px + 1fr). */
function InfoRow({
label,
required,
children,
}: {
label: string
required?: boolean
children: ReactNode
}) {
return (
<div className="grid grid-cols-[150px_1fr]">
<LabelCell required={required}>{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>
)
}
function ProcessStatusBadge({ value }: { value: string | null }) {
const done = value === '처리완료'
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
done ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'
}`}
>
{done ? '처리완료' : '미처리'}
</span>
)
}
/** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */
export default function ProcessBoard({ org }: { org: Org }) {
const [mode, setMode] = useState<'list' | number>('list')
const [items, setItems] = useState<ProcessItem[]>([])
const [total, setTotal] = useState(0)
const [done, setDone] = useState(0)
const [page, setPage] = useState(0)
const [statusFilter, setStatusFilter] = useState<ProcessStatusFilter | undefined>(undefined)
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: '', board: '', title: '' })
/** 일괄 등록/삭제 대상. */
const [selected, setSelected] = useState<number[]>([])
const [bulkStatus, setBulkStatus] = useState('')
const [bulkKoglType, setBulkKoglType] = useState('')
const [bulkOpinion, setBulkOpinion] = useState('')
const [bulkBusy, setBulkBusy] = useState(false)
const [bulkMessage, setBulkMessage] = useState<string | null>(null)
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<ProcessImportReport | null>(null)
const [importError, setImportError] = useState<string | null>(null)
useEffect(() => {
if (mode !== 'list') {
return
}
let cancelled = false
setLoading(true)
setError(null)
getProcessPage(org.id, {
keyword: appliedKeyword || undefined,
site: applied.site || undefined,
board: applied.board || undefined,
title: applied.title || 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, applied, statusFilter, page, reloadKey])
function handleSearchFields() {
setApplied({ site: siteInput.trim(), board: boardInput.trim(), title: titleInput.trim() })
}
function handleSearch() {
handleSearchFields()
setPage(0)
setAppliedKeyword(keywordInput.trim())
}
function handleResetSearch() {
setKeywordInput('')
setSiteInput('')
setBoardInput('')
setTitleInput('')
setPage(0)
setAppliedKeyword('')
setApplied({ site: '', board: '', title: '' })
}
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)
}
}
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 (!bulkStatus && !bulkKoglType && !bulkOpinion.trim()) {
setBulkMessage('반영할 값을 하나 이상 채워주세요.')
return
}
setBulkBusy(true)
setBulkMessage(null)
try {
const res = await bulkUpdateProcess(org.id, {
ids: selected,
processStatus: bulkStatus || undefined,
judgedKoglType: bulkKoglType || undefined,
finalOpinion: bulkOpinion.trim() || undefined,
})
setBulkMessage(`${res.updated}건에 반영했습니다.`)
setSelected([])
setBulkStatus('')
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 bulkDeleteProcess(org.id, selected)
setBulkMessage(`${res.deleted}건을 삭제했습니다.`)
setSelected([])
setReloadKey((k) => k + 1)
} catch (e) {
setBulkMessage(e instanceof Error ? e.message : '삭제에 실패했습니다.')
} finally {
setBulkBusy(false)
}
}
if (mode !== 'list') {
return (
<ProcessDetail
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 flex-wrap items-center gap-2">
<div className="flex items-center gap-1" role="group" aria-label="처리상태 필터">
{STATUS_CHIPS.map((chip) => (
<button
key={chip.label}
type="button"
onClick={() => pickStatusChip(chip.key)}
aria-pressed={statusFilter === chip.key}
className={`rounded-full border px-3 py-1 text-xs font-medium ${
statusFilter === chip.key
? 'border-blue-400 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
{chip.label}
</button>
))}
</div>
<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"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((v) => !v)}
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={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={processDownloadUrl(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>}
{advancedOpen && (
<div className="mt-3 flex flex-wrap items-end gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
{(
[
['사이트명', siteInput, setSiteInput],
['게시판명', boardInput, setBoardInput],
['게시물 제목', titleInput, setTitleInput],
] as const
).map(([label, value, setter]) => (
<label key={label} className="flex flex-col gap-1 text-xs text-gray-500">
{label}
<input
type="text"
aria-label={label}
value={value}
onChange={(e) => setter(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSearch()
}
}}
className="w-48 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
</label>
))}
<span className="pb-2 text-xs text-gray-400">세 조건은 모두 만족하는 건만 찾습니다(AND)</span>
</div>
)}
{/* 일괄 등록: 체크한 건들에 같은 값을 한 번에 넣는다. 삭제도 여기서 한다. */}
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-3">
<span className="text-sm font-medium text-gray-700">선택 {selected.length}건</span>
<select
aria-label="일괄 처리상태"
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">처리상태 그대로</option>
<option value="처리완료">처리완료</option>
<option value="미처리">미처리</option>
</select>
<select
aria-label="일괄 공공누리 유형"
value={bulkKoglType}
onChange={(e) => setBulkKoglType(e.target.value)}
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">공공누리 유형 그대로</option>
{JUDGED_KOGL_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
<input
type="text"
aria-label="일괄 최종의견"
value={bulkOpinion}
onChange={(e) => setBulkOpinion(e.target.value)}
placeholder="최종의견(비우면 유지)"
className="w-56 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
<button
type="button"
disabled={selected.length === 0 || bulkBusy}
onClick={() => void handleBulkApply()}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
일괄 등록
</button>
<button
type="button"
disabled={selected.length === 0 || bulkBusy}
onClick={() => void handleBulkDelete()}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:text-gray-300"
>
선택 삭제
</button>
{bulkMessage && <span className="text-sm text-gray-600">{bulkMessage}</span>}
</div>
<div className="mt-3 overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full min-w-[1400px] text-left text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50 text-xs text-gray-500">
<th className="w-8 px-3 py-2 font-medium">
<input
type="checkbox"
aria-label="전체 선택"
checked={allChecked}
onChange={toggleAll}
/>
</th>
<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="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={14} className="py-8 text-center text-sm text-gray-400">
불러오는 중…
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={14} 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">
<input
type="checkbox"
aria-label={`${item.seq}번 선택`}
checked={selected.includes(item.id)}
onChange={() => toggleOne(item.id)}
/>
</td>
<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="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>
<div className="text-xs text-gray-400">{item.hasAttachment ?? '-'}</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">
<div>{item.priorKoglType ?? '-'}</div>
<div className="text-xs text-gray-400">{item.contractDocs ?? '-'}</div>
</td>
<td className="px-3 py-2 text-gray-600">
<div>
{item.reviewMajor ?? '-'}
{item.reviewMinor ? ` > ${item.reviewMinor}` : ''}
</div>
<div className="text-xs text-gray-400">{item.reviewResult ?? ''}</div>
</td>
<td className="px-3 py-2 text-gray-600">
<div>{item.reviewKoglType ?? '-'}</div>
<div className="text-xs text-gray-400">{item.reviewAiType === 'Y' ? 'AI' : ''}</div>
</td>
<td className="px-3 py-2 text-gray-600">{item.contractDocs ?? '-'}</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 text-gray-600">{truncate(item.finalOpinion, 60)}</td>
<td className="px-3 py-2">
<ProcessStatusBadge value={item.processStatus} />
</td>
<td className="px-3 py-2 text-gray-600">
{item.processStatus === '처리완료' ? formatDate(item.processedAt) : '-'}
</td>
<td className="px-3 py-2">
<div className="flex gap-2">
{item.processStatus === '처리완료' ? (
<>
<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={() => setMode(item.id)}
className="rounded-md border border-emerald-300 bg-emerald-50 px-2 py-0.5 text-xs text-emerald-700 hover:bg-emerald-100"
>
처리등록
</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 ProcessDetail({
org,
itemId,
onBack,
onSaved,
}: {
org: Org
itemId: number
onBack: () => void
onSaved: () => void
}) {
const [item, setItem] = useState<ProcessItem | null>(null)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [contractSelected, setContractSelected] = useState<Set<string>>(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<string | null>(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 <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>
}
return (
<div className="p-4">
<section className="rounded-lg border border-gray-200 p-4">
<SectionHeader title="기본정보" />
<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.boardName} />}
/>
<InfoRow label="게시물제목">
<div className="flex w-full items-center gap-1.5 rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800">
<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>
</InfoRow>
<InfoRow label="기존 공공누리">
<ReadonlyBox value={item.priorKoglType} />
</InfoRow>
</div>
</section>
<section className="mt-4 rounded-lg border border-gray-200 p-4">
<SectionHeader title="권리확인" />
<div className="mt-3 overflow-hidden rounded border border-gray-300 divide-y divide-gray-200">
<InfoRow label="권리확인">
<ReadonlyBox
value={item.reviewMajor ? `${item.reviewMajor}${item.reviewMinor ? ` > ${item.reviewMinor}` : ''}` : null}
/>
</InfoRow>
<InfoRow label="권리확인 공공누리유형">
<ReadonlyBox
value={item.reviewKoglType ? `${item.reviewKoglType}${item.reviewAiType === 'Y' ? ' + AI' : ''}` : null}
/>
</InfoRow>
<InfoRow label="처리 구분">
<ReadonlyBox value={item.reviewResult} />
</InfoRow>
<InfoRow label="의견">
<ReadonlyBox value={item.reviewOpinion} />
</InfoRow>
<InfoRow label="비고">
<ReadonlyBox value={item.reviewNote} />
</InfoRow>
</div>
</section>
<section className="mt-4 rounded-lg border border-gray-200 p-4">
<SectionHeader title="권리처리" />
<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">
{CONTRACT_DOC_OPTIONS.map((option) => (
<label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
<input
type="checkbox"
checked={contractSelected.has(option)}
onChange={() => toggleContractOption(option)}
/>
{option}
</label>
))}
<label className="flex items-center gap-1.5 text-sm text-gray-800">
<input
type="checkbox"
checked={contractSelected.has(CONTRACT_ETC)}
onChange={() => toggleContractOption(CONTRACT_ETC)}
/>
{CONTRACT_ETC}
</label>
</div>
{contractSelected.has(CONTRACT_ETC) && (
<input
type="text"
aria-label="기타 계약서 내용"
value={contractEtc}
onChange={(e) => setContractEtc(e.target.value)}
placeholder="기타 내용을 입력하세요"
className="w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
)}
</div>
</InfoRow>
<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>
<div className="grid grid-cols-[150px_1fr]">
<LabelCell>권리처리 결과</LabelCell>
<div className="flex flex-col divide-y divide-gray-200">
<div className="grid grid-cols-[110px_1fr]">
<div className="flex items-center bg-gray-50 px-3 py-2 text-xs font-medium text-gray-600">
최종의견
</div>
<div className="flex flex-col gap-1 px-3 py-1.5">
<span className="text-[11px] text-gray-400">
작성안내) 전부 양도 체결, 일부 양도 체결, 초상 이용 동의, 공공누리 동의 등 처리한 사유 기입
</span>
<textarea
id="process-final-opinion"
aria-label="최종의견"
rows={3}
value={finalOpinion}
onChange={(e) => setFinalOpinion(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-[110px_1fr]">
<div className="flex items-center bg-gray-50 px-3 py-2 text-xs font-medium text-gray-600">
판단근거
</div>
<div className="flex flex-col gap-1 px-3 py-1.5">
<span className="text-[11px] text-gray-400">작성안내) 계약서 명칭, 해당 문구 기입</span>
<textarea
id="process-judgment-basis"
aria-label="판단근거"
rows={3}
value={judgmentBasis}
onChange={(e) => setJudgmentBasis(e.target.value)}
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
</div>
</div>
</div>
<InfoRow label="권리처리상태" required>
<select
id="process-status"
aria-label="*권리처리상태"
value={processStatus}
onChange={(e) => setProcessStatus(e.target.value)}
className="w-40 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">선택</option>
{PROCESS_STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</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 handleDelete()}
className="rounded-md border border-red-300 px-3 py-1.5 text-sm font-semibold text-red-600 hover:bg-red-50 disabled:opacity-50"
>
삭제
</button>
<button
type="button"
disabled={busy || !processStatus}
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>
)
}