ITN Dev 07-24
feat: 권리처리 탭 화면(목록/필터/업로드/처리등록 상세) 추가
권리확인 탭과 같은 목록↔상세 구조로 권리처리 보드를 만든다. 상태 칩(전체/미처리/
처리완료)과 검색어로 목록을 필터링하고, 상세에서 계약서 유무를 체크박스 조합(+기타
직접입력)으로 관리하며 권리처리상태를 고르기 전에는 저장을 막는다. 기관 상세 헤더의
권리확인/권리처리 통계 카드도 각 탭의 완료·전체 건수를 실데이터로 보여주도록 연결한다.
@ee9edce761d6f91bc2f1c09f735f18e24f119454
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -423,6 +423,129 @@
   return `/api/orgs/${orgId}/review/download`
 }
 
+/** 권리처리 탭 게시물 한 건. U(수정 링크)/Y(빈 열)열은 원본 시트의 건너뛰는 열이라 대응 필드가 없다. */
+export interface ProcessItem {
+  id: number
+  orgId: number
+  seq: number
+  siteName: string | null
+  category: string | null
+  boardName: string | null
+  postTitle: string | null
+  url: string | null
+  description: string | null
+  hasAttachment: string | null
+  priorKoglType: string | null
+  contractDocs: string | null
+  producedDate: string | null
+  publishedDate: string | null
+  reviewMajor: string | null
+  reviewMinor: string | null
+  reviewResult: string | null
+  reviewKoglType: string | null
+  reviewAiType: string | null
+  reviewOpinion: string | null
+  reviewNote: string | null
+  priorEvidence: string | null
+  judgedKoglType: string | null
+  judgedAiType: string | null
+  finalOpinion: string | null
+  judgmentBasis: string | null
+  processStatus: string | null
+  webEditedAt: number | null
+  processedAt: number | null
+  createdAt: number
+  updatedAt: number
+}
+
+export type ProcessStatusFilter = 'DONE' | 'PENDING'
+
+/** total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더 "총 n건 · 처리완료 m건"용). */
+export interface ProcessPage {
+  items: ProcessItem[]
+  total: number
+  done: number
+  page: number
+  size: number
+}
+
+export interface ProcessImportReport {
+  created: number
+  updated: number
+  total: number
+}
+
+/** 권리처리 상세의 [처리등록]/[수정] 저장 요청. 전부 선택값이다. */
+export interface ProcessingRequest {
+  contractDocs: string | null
+  judgedKoglType: string | null
+  judgedAiType: string | null
+  finalOpinion: string | null
+  judgmentBasis: string | null
+  processStatus: string | null
+}
+
+export function importProcess(orgId: number, file: File): Promise<ProcessImportReport> {
+  const form = new FormData()
+  form.append('file', file)
+  return request<ProcessImportReport>(`/api/orgs/${orgId}/process/import`, { method: 'POST', body: form })
+}
+
+export function getProcessPage(
+  orgId: number,
+  params: { keyword?: string; status?: ProcessStatusFilter; page?: number; size?: number } = {},
+): Promise<ProcessPage> {
+  const query = new URLSearchParams()
+  if (params.keyword) {
+    query.set('keyword', params.keyword)
+  }
+  if (params.status) {
+    query.set('status', params.status)
+  }
+  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<ProcessPage>(`/api/orgs/${orgId}/process${qs ? `?${qs}` : ''}`)
+}
+
+export function getProcessItem(orgId: number, itemId: number): Promise<ProcessItem> {
+  return request<ProcessItem>(`/api/orgs/${orgId}/process/${itemId}`)
+}
+
+export function updateProcessing(
+  orgId: number,
+  itemId: number,
+  body: ProcessingRequest,
+): Promise<ProcessItem> {
+  return request<ProcessItem>(`/api/orgs/${orgId}/process/${itemId}`, {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body),
+  })
+}
+
+/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */
+export async function deleteProcessItem(orgId: number, itemId: number): Promise<void> {
+  const response = await fetch(`/api/orgs/${orgId}/process/${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 processDownloadUrl(orgId: number): string {
+  return `/api/orgs/${orgId}/process/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
@@ -13,6 +13,7 @@
   updateStage: vi.fn(),
   getStageHistory: vi.fn(),
   getReviewPage: vi.fn(),
+  getProcessPage: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -26,6 +27,7 @@
   updateStage: mocks.updateStage,
   getStageHistory: mocks.getStageHistory,
   getReviewPage: mocks.getReviewPage,
+  getProcessPage: mocks.getProcessPage,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -79,6 +81,7 @@
   mocks.getTimeline.mockReset().mockResolvedValue([])
   mocks.updateStage.mockReset().mockResolvedValue(undefined)
   mocks.getReviewPage.mockReset().mockResolvedValue({ items: [], total: 0, done: 0, page: 0, size: 30 })
+  mocks.getProcessPage.mockReset().mockResolvedValue({ items: [], total: 0, done: 0, page: 0, size: 30 })
 })
 
 describe('OrgOverview', () => {
@@ -281,12 +284,36 @@
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
+    fireEvent.click(screen.getByRole('button', { name: 'RE' }))
+
+    expect(screen.getByText('RE 상세 화면은 추후 확정')).toBeTruthy()
+    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()
+    await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledWith(1, expect.anything()))
+    expect(screen.queryByText('권리처리 상세 화면은 추후 확정')).toBeNull()
     expect(screen.queryByRole('button', { name: '문정원' })).toBeNull()
   })
 
+  it('권리확인/권리처리 통계 카드가 각 조회 결과의 완료·전체 건수를 보여준다', async () => {
+    mocks.getReviewPage.mockResolvedValue({ items: [], total: 12, done: 5, page: 0, size: 1 })
+    mocks.getProcessPage.mockResolvedValue({ items: [], total: 8, done: 3, page: 0, size: 1 })
+
+    render(<OrgOverview org={org()} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('완료 5 / 전체 12')).toBeTruthy()
+      expect(screen.getByText('완료 3 / 전체 8')).toBeTruthy()
+    })
+  })
+
   it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
     render(<OrgOverview org={org()} />)
 
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -1,5 +1,7 @@
 import { useEffect, useState } from 'react'
 import {
+  getProcessPage,
+  getReviewPage,
   getStageHistory,
   updateAssignments,
   updateStage,
@@ -11,6 +13,7 @@
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
 import ContactPickerModal from './ContactPickerModal'
+import ProcessBoard from './ProcessBoard'
 import ReviewBoard from './ReviewBoard'
 import StatusBadge from './StatusBadge'
 import Timeline from './Timeline'
@@ -129,6 +132,46 @@
   const [stagePickerOpen, setStagePickerOpen] = useState(false)
   // 단계별로 '그 단계에 들어간 날짜'(가장 최근 이력)를 찍기 위한 조회 결과
   const [stageDates, setStageDates] = useState<Record<number, number>>({})
+
+  // 통계 카드용 권리확인/권리처리 완료·전체 건수. 탭을 열지 않아도 헤더에 바로 보여야 하므로
+  // 각 탭이 마운트되기 전에 가볍게(size:1) 별도로 조회한다 - 실패해도 카드는 '-'로 남을 뿐
+  // 화면 전체를 깨뜨리지 않는다.
+  const [reviewStats, setReviewStats] = useState<{ done: number; total: number } | null>(null)
+  const [processStats, setProcessStats] = useState<{ done: number; total: number } | null>(null)
+
+  useEffect(() => {
+    let cancelled = false
+    setReviewStats(null)
+    getReviewPage(org.id, { page: 0, size: 1 })
+      .then((data) => {
+        if (!cancelled) {
+          setReviewStats({ done: data.done, total: data.total })
+        }
+      })
+      .catch(() => {
+        /* 통계는 장식이다 - 조회 실패로 화면을 깨지 않는다 */
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [org.id])
+
+  useEffect(() => {
+    let cancelled = false
+    setProcessStats(null)
+    getProcessPage(org.id, { page: 0, size: 1 })
+      .then((data) => {
+        if (!cancelled) {
+          setProcessStats({ done: data.done, total: data.total })
+        }
+      })
+      .catch(() => {
+        /* 통계는 장식이다 - 조회 실패로 화면을 깨지 않는다 */
+      })
+    return () => {
+      cancelled = true
+    }
+  }, [org.id])
 
   useEffect(() => {
     let cancelled = false
@@ -408,21 +451,41 @@
           {stageError && <p className="mt-1 text-xs text-red-600">{stageError}</p>}
         </section>
 
-        {/* 통계 4칸 */}
+        {/* 통계 4칸. 권리확인/권리처리는 실제 완료·전체 건수를 보여주고, 나머지는 아직 백엔드가
+            없어 자리만 잡아 둔다. */}
         <div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
-          {[
-            ['사전검토', '검토 대상'],
-            ['권리확인', '완료 / 전체'],
-            ['권리처리', '완료 / 전체'],
-            ['RE', '확인·처리 합계'],
-          ].map(([title, caption]) => (
-            <div key={title} className="rounded-lg border border-gray-200 p-4">
-              <p className="text-xs text-gray-400">{title}</p>
-              <p className="mt-1 text-sm font-semibold">
-                {caption} <span className="ml-1 text-lg text-gray-300">-</span>
-              </p>
-            </div>
-          ))}
+          <div className="rounded-lg border border-gray-200 p-4">
+            <p className="text-xs text-gray-400">사전검토</p>
+            <p className="mt-1 text-sm font-semibold">
+              검토 대상 <span className="ml-1 text-lg text-gray-300">-</span>
+            </p>
+          </div>
+          <div className="rounded-lg border border-gray-200 p-4">
+            <p className="text-xs text-gray-400">권리확인</p>
+            <p className="mt-1 text-sm font-semibold">
+              {reviewStats ? `완료 ${reviewStats.done} / 전체 ${reviewStats.total}` : (
+                <>
+                  완료 / 전체 <span className="ml-1 text-lg text-gray-300">-</span>
+                </>
+              )}
+            </p>
+          </div>
+          <div className="rounded-lg border border-gray-200 p-4">
+            <p className="text-xs text-gray-400">권리처리</p>
+            <p className="mt-1 text-sm font-semibold">
+              {processStats ? `완료 ${processStats.done} / 전체 ${processStats.total}` : (
+                <>
+                  완료 / 전체 <span className="ml-1 text-lg text-gray-300">-</span>
+                </>
+              )}
+            </p>
+          </div>
+          <div className="rounded-lg border border-gray-200 p-4">
+            <p className="text-xs text-gray-400">RE</p>
+            <p className="mt-1 text-sm font-semibold">
+              확인·처리 합계 <span className="ml-1 text-lg text-gray-300">-</span>
+            </p>
+          </div>
         </div>
 
         {/* 탭 */}
@@ -462,6 +525,10 @@
           <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">
+            <ProcessBoard 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/ProcessBoard.test.tsx (added)
+++ frontend/src/components/ProcessBoard.test.tsx
@@ -0,0 +1,353 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ProcessBoard from './ProcessBoard'
+import type { Org, ProcessItem, ProcessPage } from '../api/client'
+
+const mocks = vi.hoisted(() => ({
+  getProcessPage: vi.fn(),
+  getProcessItem: vi.fn(),
+  importProcess: vi.fn(),
+  updateProcessing: vi.fn(),
+  deleteProcessItem: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getProcessPage: mocks.getProcessPage,
+  getProcessItem: mocks.getProcessItem,
+  importProcess: mocks.importProcess,
+  updateProcessing: mocks.updateProcessing,
+  deleteProcessItem: mocks.deleteProcessItem,
+}))
+
+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<ProcessItem> = {}): ProcessItem {
+  return {
+    id: 1,
+    orgId: 1,
+    seq: 1,
+    siteName: '한국출판문화산업진흥원',
+    category: '공지',
+    boardName: '공지사항',
+    postTitle: '2026년 사업 공고',
+    url: 'https://example.com/1',
+    description: null,
+    hasAttachment: 'N',
+    priorKoglType: '1유형',
+    contractDocs: null,
+    producedDate: '2026-01-01',
+    publishedDate: '2026-01-02',
+    reviewMajor: '만료 저작물',
+    reviewMinor: null,
+    reviewResult: '신유형 개방',
+    reviewKoglType: '1유형',
+    reviewAiType: null,
+    reviewOpinion: null,
+    reviewNote: null,
+    priorEvidence: null,
+    judgedKoglType: null,
+    judgedAiType: null,
+    finalOpinion: null,
+    judgmentBasis: null,
+    processStatus: null,
+    webEditedAt: null,
+    processedAt: null,
+    createdAt: 0,
+    updatedAt: 0,
+    ...overrides,
+  }
+}
+
+function page(overrides: Partial<ProcessPage> = {}): ProcessPage {
+  return {
+    items: [item()],
+    total: 1,
+    done: 0,
+    page: 0,
+    size: 30,
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.getProcessPage.mockReset()
+  mocks.getProcessItem.mockReset()
+  mocks.importProcess.mockReset()
+  mocks.updateProcessing.mockReset()
+  mocks.deleteProcessItem.mockReset()
+})
+
+describe('ProcessBoard 목록', () => {
+  it('목록을 표로 보여주고 처리상태 배지가 나온다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => {
+      expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
+      // "미처리"는 상태 필터 칩과 배지 둘 다에 나오므로 배지를 role로 구분해 확인한다.
+      expect(screen.getAllByText('미처리').length).toBeGreaterThanOrEqual(2)
+    })
+    expect(screen.getByText('총 1건 · 처리완료 0건')).toBeTruthy()
+  })
+
+  it('자료가 없으면 안내 문구를 보여준다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page({ items: [], total: 0 }))
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => {
+      expect(
+        screen.getByText('업로드된 권리처리 자료가 없습니다. 엑셀 업로드로 시작하세요.'),
+      ).toBeTruthy()
+    })
+  })
+
+  it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
+        keyword: undefined,
+        status: undefined,
+        page: 0,
+        size: 30,
+      }),
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: '처리완료' }))
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
+        keyword: undefined,
+        status: 'DONE',
+        page: 0,
+        size: 30,
+      }),
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: '미처리' }))
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
+        keyword: undefined,
+        status: 'PENDING',
+        page: 0,
+        size: 30,
+      }),
+    )
+  })
+
+  it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
+
+    fireEvent.change(screen.getByLabelText('검색어'), { target: { value: '출판' } })
+    fireEvent.click(screen.getByRole('button', { name: '검색' }))
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
+        keyword: '출판',
+        status: undefined,
+        page: 0,
+        size: 30,
+      }),
+    )
+  })
+
+  it('엑셀 업로드를 하면 importProcess를 호출하고 결과를 보여준다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.importProcess.mockResolvedValue({ created: 3, updated: 2, total: 5 })
+
+    const { container } = render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
+
+    const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
+    const file = new File(['dummy'], 'process.xlsx', {
+      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    })
+    fireEvent.change(fileInput, { target: { files: [file] } })
+
+    await waitFor(() => expect(mocks.importProcess).toHaveBeenCalledWith(1, file))
+    await waitFor(() => {
+      expect(screen.getByText('신규 3 · 갱신 2 · 전체 5')).toBeTruthy()
+    })
+  })
+
+  it('처리완료 건은 수정/삭제 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
+    expect(screen.getByRole('button', { name: '삭제' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '처리등록' })).toBeNull()
+  })
+
+  it('삭제를 누르면 확인 후 deleteProcessItem을 호출한다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
+    mocks.deleteProcessItem.mockResolvedValue(undefined)
+    const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '삭제' }))
+
+    expect(confirmSpy).toHaveBeenCalled()
+    await waitFor(() => expect(mocks.deleteProcessItem).toHaveBeenCalledWith(1, 1))
+
+    confirmSpy.mockRestore()
+  })
+
+  it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
+        keyword: undefined,
+        status: undefined,
+        page: 0,
+        size: 30,
+      }),
+    )
+
+    fireEvent.click(screen.getByRole('button', { name: '›' }))
+
+    await waitFor(() =>
+      expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
+        keyword: undefined,
+        status: undefined,
+        page: 1,
+        size: 30,
+      }),
+    )
+  })
+})
+
+describe('ProcessBoard 상세', () => {
+  it('처리등록을 누르면 상세 화면이 열리고 조회한 값이 채워진다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.getProcessItem.mockResolvedValue(item({ finalOpinion: '검토 완료' }))
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
+
+    await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalledWith(1, 1))
+    await waitFor(() => {
+      expect((screen.getByLabelText('최종의견') as HTMLTextAreaElement).value).toBe('검토 완료')
+    })
+  })
+
+  it('권리처리상태를 선택하지 않으면 저장 버튼이 비활성이고, 고르면 활성화된다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.getProcessItem.mockResolvedValue(item())
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
+    await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
+
+    expect(screen.getByRole('button', { name: '수정' })).toBeDisabled()
+
+    fireEvent.change(screen.getByLabelText('*권리처리상태'), { target: { value: '처리완료' } })
+
+    expect(screen.getByRole('button', { name: '수정' })).not.toBeDisabled()
+  })
+
+  it('계약서 유무 체크박스 조합과 기타 입력이 쉼표로 연결되어 저장 페이로드에 실린다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.getProcessItem.mockResolvedValue(item())
+    mocks.updateProcessing.mockResolvedValue(item({ processStatus: '처리완료' }))
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
+    await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('checkbox', { name: '양도계약서' }))
+    fireEvent.click(screen.getByRole('checkbox', { name: '공문' }))
+    fireEvent.click(screen.getByRole('checkbox', { name: '기타' }))
+    fireEvent.change(screen.getByLabelText('기타 계약서 내용'), { target: { value: '직접 촬영본 사용동의' } })
+    fireEvent.change(screen.getByLabelText('*권리처리상태'), { target: { value: '처리완료' } })
+    fireEvent.click(screen.getByRole('button', { name: '수정' }))
+
+    await waitFor(() =>
+      expect(mocks.updateProcessing).toHaveBeenCalledWith(1, 1, {
+        contractDocs: '양도계약서, 공문, 기타:직접 촬영본 사용동의',
+        judgedKoglType: null,
+        judgedAiType: null,
+        finalOpinion: null,
+        judgmentBasis: null,
+        processStatus: '처리완료',
+      }),
+    )
+
+    // 저장 후 목록으로 복귀
+    await waitFor(() => expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy())
+  })
+
+  it('기존에 저장된 계약서 유무 값을 체크박스 상태로 역파싱해 보여준다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.getProcessItem.mockResolvedValue(
+      item({ contractDocs: '양도계약서, 기타:구두 동의' }),
+    )
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
+    await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
+
+    expect(screen.getByRole('checkbox', { name: '양도계약서' })).toBeChecked()
+    expect(screen.getByRole('checkbox', { name: '기타' })).toBeChecked()
+    expect((screen.getByLabelText('기타 계약서 내용') as HTMLInputElement).value).toBe('구두 동의')
+  })
+
+  it('목록 버튼을 누르면 저장 없이 목록으로 돌아간다', async () => {
+    mocks.getProcessPage.mockResolvedValue(page())
+    mocks.getProcessItem.mockResolvedValue(item())
+
+    render(<ProcessBoard org={org()} />)
+
+    await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
+    fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
+    await waitFor(() => expect(mocks.getProcessItem).toHaveBeenCalled())
+
+    fireEvent.click(screen.getByRole('button', { name: '목록' }))
+
+    expect(screen.getByRole('button', { name: '엑셀 업로드' })).toBeTruthy()
+    expect(mocks.updateProcessing).not.toHaveBeenCalled()
+  })
+})
 
frontend/src/components/ProcessBoard.tsx (added)
+++ frontend/src/components/ProcessBoard.tsx
@@ -0,0 +1,742 @@
+import { useEffect, useRef, useState } from 'react'
+import {
+  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 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 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('')
+  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,
+      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, statusFilter, page, reloadKey])
+
+  function handleSearch() {
+    setPage(0)
+    setAppliedKeyword(keywordInput.trim())
+  }
+
+  function handleResetSearch() {
+    setKeywordInput('')
+    setPage(0)
+    setAppliedKeyword('')
+  }
+
+  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)
+    }
+  }
+
+  async function handleDelete(item: ProcessItem) {
+    if (!window.confirm(`"${item.postTitle ?? item.seq}" 게시물을 삭제할까요?`)) {
+      return
+    }
+    try {
+      await deleteProcessItem(org.id, item.id)
+      setReloadKey((k) => k + 1)
+    } catch (e) {
+      setError(e instanceof Error ? e.message : '삭제에 실패했습니다.')
+    }
+  }
+
+  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"
+            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>}
+
+      <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="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={13} className="py-8 text-center text-sm text-gray-400">
+                  불러오는 중…
+                </td>
+              </tr>
+            ) : items.length === 0 ? (
+              <tr>
+                <td colSpan={13} 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="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={() => 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>
+                        </>
+                      ) : (
+                        <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)
+    }
+  }
+
+  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.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.priorKoglType} />
+        </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 grid grid-cols-1 gap-2 sm:grid-cols-2">
+          <Field
+            label="권리확인"
+            value={item.reviewMajor ? `${item.reviewMajor}${item.reviewMinor ? ` > ${item.reviewMinor}` : ''}` : null}
+          />
+          <Field
+            label="공공누리유형"
+            value={item.reviewKoglType ? `${item.reviewKoglType}${item.reviewAiType === 'Y' ? ' + AI' : ''}` : null}
+          />
+          <Field label="처리 구분" value={item.reviewResult} />
+          <Field label="의견" value={item.reviewOpinion} />
+          <Field label="비고" value={item.reviewNote} />
+        </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">
+          <fieldset className="flex flex-col gap-1.5">
+            <legend className="text-xs text-gray-500">계약서 유무</legend>
+            <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="mt-1 w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
+              />
+            )}
+          </fieldset>
+
+          <fieldset className="flex flex-col gap-1.5">
+            <legend className="text-xs text-gray-500">공공누리 유형</legend>
+            <div className="flex flex-wrap gap-x-4 gap-y-1">
+              {JUDGED_KOGL_TYPE_OPTIONS.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>
+          </fieldset>
+
+          <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>
+
+          <div className="flex flex-col gap-1 text-sm">
+            <label htmlFor="process-final-opinion" className="text-xs text-gray-500">
+              최종의견
+            </label>
+            <textarea
+              id="process-final-opinion"
+              rows={3}
+              value={finalOpinion}
+              onChange={(e) => setFinalOpinion(e.target.value)}
+              placeholder="전부 양도 체결, 일부 양도 체결, 초상 이용 동의, 공공누리 동의 등 처리한 사유 기입"
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+
+          <div className="flex flex-col gap-1 text-sm">
+            <label htmlFor="process-judgment-basis" className="text-xs text-gray-500">
+              판단근거
+            </label>
+            <textarea
+              id="process-judgment-basis"
+              rows={3}
+              value={judgmentBasis}
+              onChange={(e) => setJudgmentBasis(e.target.value)}
+              placeholder="계약서 명칭, 해당 문구 기입"
+              className="rounded-md border border-gray-300 px-2 py-1.5"
+            />
+          </div>
+
+          <div className="flex flex-col gap-1 text-sm">
+            <label htmlFor="process-status" className="text-xs text-gray-500">
+              *권리처리상태
+            </label>
+            <select
+              id="process-status"
+              value={processStatus}
+              onChange={(e) => setProcessStatus(e.target.value)}
+              className="w-40 rounded-md border border-gray-300 px-2 py-1.5"
+            >
+              <option value="">선택</option>
+              {PROCESS_STATUS_OPTIONS.map((option) => (
+                <option key={option} value={option}>
+                  {option}
+                </option>
+              ))}
+            </select>
+          </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 || !processStatus}
+            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