ITN Dev 07-24
feat: 권리확인 탭 화면(목록/검색/업로드/판정 상세) 추가
기관관리 상세의 권리확인 탭을 자리표시에서 실제 화면으로 교체한다. 목록은
검색·페이지네이션·엑셀 업로드/다운로드와 처리결과 배지를 보여주고, 상세는
권리확인 대분류/세부에 따라 처리결과를 추천하되(수동 선택 시 유지) 변호사
판정을 저장할 수 있게 한다.
@12b5a3e2fdf9832b7e7bc4aec1b5fd94c8e09afc
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -304,6 +304,125 @@
   }
 }
 
+/** 권리확인 탭 게시물 한 건. W열은 원본 시트의 빈 열이라 대응 필드가 없다. */
+export interface ReviewItem {
+  id: number
+  orgId: number
+  seq: number
+  siteName: string | null
+  category: string | null
+  boardPath: string | null
+  boardName: string | null
+  postTitle: string | null
+  url: string | null
+  postRegistered: string | null
+  producedDate: string | null
+  publishedDate: string | null
+  hasAttachment: string | null
+  koglAttached: string | null
+  koglType: string | null
+  aiType: string | null
+  surveyorNote: string | null
+  openable: string | null
+  reviewMajor: string | null
+  reviewMinor: string | null
+  reviewResult: string | null
+  judgedKoglType: string | null
+  judgedAiType: string | null
+  opinion: string | null
+  lawyerNote: string | null
+  needsProcessing: string | null
+  webEditedAt: number | null
+  createdAt: number
+  updatedAt: number
+}
+
+/** total은 현재 keyword 필터 기준 건수(페이지 계산용), done은 검색어와 무관한 기관 전체 처리 건수다. */
+export interface ReviewPage {
+  items: ReviewItem[]
+  total: number
+  done: number
+  page: number
+  size: number
+}
+
+export interface ReviewImportReport {
+  created: number
+  updated: number
+  total: number
+}
+
+/** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이다. */
+export interface JudgmentRequest {
+  openable: string | null
+  reviewMajor: string | null
+  reviewMinor: string | null
+  reviewResult: string | null
+  judgedKoglType: string | null
+  judgedAiType: string | null
+  opinion: string | null
+  lawyerNote: string | null
+  needsProcessing: string | null
+}
+
+export function importReview(orgId: number, file: File): Promise<ReviewImportReport> {
+  const form = new FormData()
+  form.append('file', file)
+  return request<ReviewImportReport>(`/api/orgs/${orgId}/review/import`, { method: 'POST', body: form })
+}
+
+export function getReviewPage(
+  orgId: number,
+  params: { keyword?: string; page?: number; size?: number } = {},
+): Promise<ReviewPage> {
+  const query = new URLSearchParams()
+  if (params.keyword) {
+    query.set('keyword', params.keyword)
+  }
+  if (params.page !== undefined) {
+    query.set('page', String(params.page))
+  }
+  if (params.size !== undefined) {
+    query.set('size', String(params.size))
+  }
+  const qs = query.toString()
+  return request<ReviewPage>(`/api/orgs/${orgId}/review${qs ? `?${qs}` : ''}`)
+}
+
+export function getReviewItem(orgId: number, itemId: number): Promise<ReviewItem> {
+  return request<ReviewItem>(`/api/orgs/${orgId}/review/${itemId}`)
+}
+
+export function updateReviewJudgment(
+  orgId: number,
+  itemId: number,
+  body: JudgmentRequest,
+): Promise<ReviewItem> {
+  return request<ReviewItem>(`/api/orgs/${orgId}/review/${itemId}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */
+export async function deleteReviewItem(orgId: number, itemId: number): Promise<void> {
+  const response = await fetch(`/api/orgs/${orgId}/review/${itemId}`, {
+    method: 'DELETE',
+    credentials: 'same-origin',
+    headers: { 'X-XSRF-TOKEN': csrfToken() },
+  })
+  if (!response.ok) {
+    const body = await response.text()
+    throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`)
+  }
+}
+
+/** 다운로드는 <a href>가 그대로 열게 두므로 fetch()를 거치지 않는다. */
+export function reviewDownloadUrl(orgId: number): string {
+  return `/api/orgs/${orgId}/review/download`
+}
+
 export function uploadSeed(file: File): Promise<SeedReport> {
   const form = new FormData()
   form.append('file', file)
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -12,6 +12,7 @@
   getTimeline: vi.fn(),
   updateStage: vi.fn(),
   getStageHistory: vi.fn(),
+  getReviewPage: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -24,6 +25,7 @@
   getTimeline: mocks.getTimeline,
   updateStage: mocks.updateStage,
   getStageHistory: mocks.getStageHistory,
+  getReviewPage: mocks.getReviewPage,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -76,6 +78,7 @@
   mocks.getStageHistory.mockReset().mockResolvedValue([])
   mocks.getTimeline.mockReset().mockResolvedValue([])
   mocks.updateStage.mockReset().mockResolvedValue(undefined)
+  mocks.getReviewPage.mockReset().mockResolvedValue({ items: [], total: 0, done: 0, page: 0, size: 30 })
 })
 
 describe('OrgOverview', () => {
@@ -261,14 +264,26 @@
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj'))
   })
 
-  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
+  it('권리확인 탭을 누르면 권리확인 보드가 마운트되어 조회를 부른다', async () => {
     render(<OrgOverview org={org()} />)
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '권리확인' }))
 
-    expect(screen.getByText('권리확인 상세 화면은 추후 확정')).toBeTruthy()
+    await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledWith(1, expect.anything()))
+    expect(screen.queryByText('권리확인 상세 화면은 추후 확정')).toBeNull()
+    expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
+  })
+
+  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
+    render(<OrgOverview org={org()} />)
+
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('button', { name: '권리처리' }))
+
+    expect(screen.getByText('권리처리 상세 화면은 추후 확정')).toBeTruthy()
     expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -11,6 +11,7 @@
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
 import ContactPickerModal from './ContactPickerModal'
+import ReviewBoard from './ReviewBoard'
 import StatusBadge from './StatusBadge'
 import Timeline from './Timeline'
 import WorkMemos from './WorkMemos'
@@ -457,6 +458,10 @@
           <div className="mt-4 rounded-lg border border-gray-200">
             <WorkMemos key={org.id} org={org} />
           </div>
+        ) : activeTab === '권리확인' ? (
+          <div className="mt-4 rounded-lg border border-gray-200">
+            <ReviewBoard key={org.id} org={org} />
+          </div>
         ) : activeTab === '타임라인' ? (
           <div className="mt-4 rounded-lg border border-gray-200">
             <Timeline key={org.id} org={org} />
 
frontend/src/components/ReviewBoard.test.tsx (added)
+++ frontend/src/components/ReviewBoard.test.tsx
@@ -0,0 +1,280 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ReviewBoard from './ReviewBoard'
+import type { Org, ReviewItem, ReviewPage } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getReviewPage: vi.fn(),
+  getReviewItem: vi.fn(),
+  importReview: vi.fn(),
+  updateReviewJudgment: vi.fn(),
+  deleteReviewItem: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getReviewPage: mocks.getReviewPage,
+  getReviewItem: mocks.getReviewItem,
+  importReview: mocks.importReview,
+  updateReviewJudgment: mocks.updateReviewJudgment,
+  deleteReviewItem: mocks.deleteReviewItem,
+}))
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '한국출판문화산업진흥원',
+    status: 'ACTIVE',
+    applicant: null,
+    mj: null,
+    itn: null,
+    lawyer: null,
+    lawyerAssignedDate: null,
+    channelIdMj: 'chan-mj',
+    channelIdLaw: 'chan-law',
+    stage: null,
+    ...overrides,
+  }
+}
+
+function item(overrides: Partial<ReviewItem> = {}): ReviewItem {
+  return {
+    id: 1,
+    orgId: 1,
+    seq: 1,
+    siteName: '한국출판문화산업진흥원',
+    category: '공지',
+    boardPath: '게시판>공지',
+    boardName: '공지사항',
+    postTitle: '2026년 사업 공고',
+    url: 'https://example.com/1',
+    postRegistered: '2026-01-01',
+    producedDate: '20260101',
+    publishedDate: '20260102',
+    hasAttachment: 'N',
+    koglAttached: 'Y',
+    koglType: '1유형',
+    aiType: null,
+    surveyorNote: null,
+    openable: null,
+    reviewMajor: null,
+    reviewMinor: null,
+    reviewResult: '신유형 개방',
+    judgedKoglType: null,
+    judgedAiType: null,
+    opinion: null,
+    lawyerNote: null,
+    needsProcessing: null,
+    webEditedAt: null,
+    createdAt: 0,
+    updatedAt: 0,
+    ...overrides,
+  }
+}
+
+function page(overrides: Partial<ReviewPage> = {}): ReviewPage {
+  return {
+    items: [item()],
+    total: 1,
+    done: 0,
+    page: 0,
+    size: 30,
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getReviewPage.mockReset()
+  mocks.getReviewItem.mockReset()
+  mocks.importReview.mockReset()
+  mocks.updateReviewJudgment.mockReset()
+  mocks.deleteReviewItem.mockReset()
+})
+
+describe('ReviewBoard 목록', () => {
+  it('목록을 표로 보여주고 처리결과 배지가 나온다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
+      expect(screen.getByText('신유형 개방')).toBeTruthy()
+    })
+    expect(screen.getByText('총 1건 · 처리 0건')).toBeTruthy()
+  })
+
+  it('자료가 없으면 안내 문구를 보여준다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page({ items: [], total: 0 }))
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => {
+      expect(
+        screen.getByText('업로드된 권리확인 자료가 없습니다. 엑셀 업로드로 시작하세요.'),
+      ).toBeTruthy()
+    })
+  })
+
+  it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() =>
+      expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
+    )
+
+    fireEvent.change(screen.getByLabelText('검색어'), { target: { value: '출판' } })
+    fireEvent.click(screen.getByRole('button', { name: '검색' }))
+
+    await waitFor(() =>
+      expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: '출판', page: 0, size: 30 }),
+    )
+  })
+
+  it('엑셀 업로드를 하면 importReview를 호출하고 결과를 보여준다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.importReview.mockResolvedValue({ created: 3, updated: 2, total: 5 })
+
+    const { container } = render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => expect(mocks.getReviewPage).toHaveBeenCalledTimes(1))
+
+    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+    const file = new File(['dummy'], 'review.xlsx', {
+      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    })
+    fireEvent.change(fileInput, { target: { files: [file] } })
+
+    await waitFor(() => expect(mocks.importReview).toHaveBeenCalledWith(1, file))
+    await waitFor(() => {
+      expect(screen.getByText('신규 3 · 갱신 2 · 전체 5')).toBeTruthy()
+    })
+  })
+
+  it('삭제를 누르면 확인 후 deleteReviewItem을 호출한다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.deleteReviewItem.mockResolvedValue(undefined)
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalled()
+    await waitFor(() => expect(mocks.deleteReviewItem).toHaveBeenCalledWith(1, 1))
+
+    confirmSpy.mockRestore()
+  })
+
+  it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page({ total: 31 }))
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() =>
+      expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 0, size: 30 }),
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: '›' }))
+
+    await waitFor(() =>
+      expect(mocks.getReviewPage).toHaveBeenCalledWith(1, { keyword: undefined, page: 1, size: 30 }),
+    )
+  })
+})
+
+describe('ReviewBoard 상세', () => {
+  it('수정을 누르면 상세 화면이 열리고 조회한 값이 채워진다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.getReviewItem.mockResolvedValue(
+      item({ reviewMajor: '만료 저작물', opinion: '검토 완료' }),
+    )
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+
+    await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalledWith(1, 1))
+    await waitFor(() => {
+      expect((screen.getByLabelText('의견') as HTMLTextAreaElement).value).toBe('검토 완료')
+    })
+    expect(screen.getByRole('radio', { name: '만료 저작물' })).toBeChecked()
+  })
+
+  it('대분류를 바꾸면 처리결과가 자동으로 채워지지만, 수동으로 고르면 이후 대분류 변경에도 유지된다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+    await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('radio', { name: '만료 저작물' }))
+    expect(screen.getByRole('radio', { name: '신유형 개방' })).toBeChecked()
+
+    // 수동으로 다른 처리결과를 고른다
+    fireEvent.click(screen.getByRole('radio', { name: '권리처리 추진' }))
+    expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
+
+    // 대분류를 다시 바꿔도(원래대로면 "신유형 개방"을 추천) 수동 선택이 유지된다
+    fireEvent.click(screen.getByRole('radio', { name: '업무상 저작물' }))
+    expect(screen.getByRole('radio', { name: '권리처리 추진' })).toBeChecked()
+  })
+
+  it('수정을 저장하면 폼 값으로 updateReviewJudgment를 호출하고 목록으로 돌아간다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.getReviewItem.mockResolvedValue(item({ reviewMajor: null, reviewResult: null }))
+    mocks.updateReviewJudgment.mockResolvedValue(item({ reviewResult: '신유형 개방' }))
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+    await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('radio', { name: '만료 저작물' }))
+    fireEvent.change(screen.getByLabelText('의견'), { target: { value: '이상 없음' } })
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+
+    await waitFor(() =>
+      expect(mocks.updateReviewJudgment).toHaveBeenCalledWith(1, 1, {
+        openable: 'Y',
+        reviewMajor: '만료 저작물',
+        reviewMinor: null,
+        reviewResult: '신유형 개방',
+        judgedKoglType: null,
+        judgedAiType: null,
+        opinion: '이상 없음',
+        lawyerNote: null,
+        needsProcessing: null,
+      }),
+    )
+
+    // 저장 후 목록으로 복귀 - 업로드 버튼 등 목록 화면 요소가 다시 보인다
+    await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy())
+  })
+
+  it('목록 버튼을 누르면 저장 없이 목록으로 돌아간다', async () => {
+    mocks.getReviewPage.mockResolvedValue(page())
+    mocks.getReviewItem.mockResolvedValue(item())
+
+    render(<ReviewBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+    await waitFor(() => expect(mocks.getReviewItem).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('button', { name: '목록' }))
+
+    expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy()
+    expect(mocks.updateReviewJudgment).not.toHaveBeenCalled()
+  })
+})
 
frontend/src/components/ReviewBoard.tsx (added)
+++ frontend/src/components/ReviewBoard.tsx
@@ -0,0 +1,690 @@
+import { useEffect, useRef, useState } 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
+}
+
+function Field({ label, value }: { label: string; value: string | null }) {
+  return (
+    <div className="flex gap-3 text-sm">
+      <span className="w-24 shrink-0 text-gray-400">{label}</span>
+      <span className={value ? 'text-gray-900' : 'text-gray-300'}>{value ?? '-'}</span>
+    </div>
+  )
+}
+
+function RadioGroup({
+  legend,
+  name,
+  options,
+  value,
+  onChange,
+  disabled,
+}: {
+  legend: string
+  name: string
+  options: string[]
+  value: string
+  onChange: (value: string) => void
+  disabled?: boolean
+}) {
+  return (
+    <fieldset className="flex flex-col gap-1.5">
+      <legend className="text-xs text-gray-500">{legend}</legend>
+      <div className="flex flex-wrap gap-x-4 gap-y-1">
+        {options.map((option) => (
+          <label key={option} className="flex items-center gap-1.5 text-sm text-gray-800">
+            <input
+              type="radio"
+              name={name}
+              disabled={disabled}
+              checked={value === option}
+              onChange={() => onChange(option)}
+            />
+            {option}
+          </label>
+        ))}
+      </div>
+    </fieldset>
+  )
+}
+
+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>
+  }
+
+  return (
+    <div className="p-4">
+      <section className="rounded-lg border border-gray-200 p-4">
+        <h2 className="text-sm font-semibold">게시물 정보 (조사원)</h2>
+        <div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
+          <Field label="기관명" value={org.orgName} />
+          <Field label="사이트명" value={item.siteName} />
+          <Field label="범주" value={item.category} />
+          <Field label="게시판명" value={item.boardName} />
+          <Field label="게시물제목" value={item.postTitle} />
+          <div className="flex gap-3 text-sm">
+            <span className="w-24 shrink-0 text-gray-400">URL</span>
+            {item.url ? (
+              <a href={item.url} target="_blank" rel="noreferrer" className="break-all text-blue-600 hover:underline">
+                {item.url}
+              </a>
+            ) : (
+              <span className="text-gray-300">-</span>
+            )}
+          </div>
+          <Field label="제작일" value={item.producedDate} />
+          <Field label="공표일" value={item.publishedDate} />
+          <Field label="첨부파일 여부" value={item.hasAttachment} />
+          <Field label="기존 공공누리 부착" value={item.koglAttached} />
+          <Field label="기존 공공누리유형" value={item.koglType} />
+          <Field label="기존 AI유형" value={item.aiType} />
+        </div>
+      </section>
+
+      <section className="mt-4 rounded-lg border border-gray-200 p-4">
+        <h2 className="text-sm font-semibold">권리확인 (변호사)</h2>
+
+        <div className="mt-3 flex flex-col gap-4">
+          <RadioGroup
+            legend="권리확인(대분류)"
+            name="review-major"
+            options={REVIEW_MAJOR_OPTIONS}
+            value={reviewMajor}
+            onChange={pickMajor}
+          />
+
+          {reviewMajor === '제3자 권리' && (
+            <RadioGroup
+              legend="권리확인(세부)"
+              name="review-minor"
+              options={REVIEW_MINOR_OPTIONS}
+              value={reviewMinor}
+              onChange={pickMinor}
+            />
+          )}
+
+          <RadioGroup
+            legend="처리결과"
+            name="review-result"
+            options={REVIEW_RESULT_OPTIONS}
+            value={reviewResult}
+            onChange={pickResult}
+          />
+
+          <RadioGroup
+            legend="공공누리유형(판정)"
+            name="judged-kogl-type"
+            options={JUDGED_KOGL_TYPE_OPTIONS}
+            value={judgedKoglType}
+            onChange={setJudgedKoglType}
+          />
+
+          <label className="flex items-center gap-2 text-sm text-gray-800">
+            <input
+              type="checkbox"
+              checked={judgedAiType}
+              onChange={(e) => setJudgedAiType(e.target.checked)}
+            />
+            AI유형
+          </label>
+
+          <label className="flex items-center gap-2 text-sm text-gray-800">
+            <input
+              type="checkbox"
+              checked={needsProcessing}
+              onChange={(e) => setNeedsProcessing(e.target.checked)}
+            />
+            권리처리필요
+          </label>
+
+          <div className="flex flex-col gap-1 text-sm">
+            <label htmlFor="review-opinion" className="text-xs text-gray-500">
+              의견
+            </label>
+            <textarea
+              id="review-opinion"
+              rows={3}
+              value={opinion}
+              onChange={(e) => setOpinion(e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+
+          <div className="flex flex-col gap-1 text-sm">
+            <label htmlFor="review-lawyer-note" className="text-xs text-gray-500">
+              비고
+            </label>
+            <textarea
+              id="review-lawyer-note"
+              rows={3}
+              value={lawyerNote}
+              onChange={(e) => setLawyerNote(e.target.value)}
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+        </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 border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
+          >
+            목록
+          </button>
+          <button
+            type="button"
+            disabled={busy}
+            onClick={() => void handleSave()}
+            className="rounded-md bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-300"
+          >
+            수정
+          </button>
+        </div>
+      </section>
+    </div>
+  )
+}
Add a comment
List