이호영 이호영 07-28
refactor: 권리처리 화면을 코드표에 연결 (Phase 2 화면 전환 완료)
  처리상태          미처리/처리완료를 코드표로. 어느 값이 완료인지는 done 속성이 정한다
  필터 칩           서버에 넘기는 키(PENDING/DONE)도 done 속성에서 나온다
  계약서류 6종      '기타'는 freeText 속성으로 구분해 자유 입력을 붙인다
  공공누리 유형     권리확인과 같은 코드표를 쓰되 process scope라 '보류'가 안 나온다

프론트에 흩어져 있던 '처리완료' 비교 6곳이 사라졌다. 남은 것은 SQL 5곳과
자바 1곳이고 Phase 3에서 정리한다.

이로써 화면 8개가 모두 코드표를 본다. 각 화면 상단의 옵션 배열은 전부 없어졌다.

백엔드 263건, 프론트 208건 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@52b8e64dd10e73397200dfe9140f7a06e0efe5c1
frontend/src/components/ProcessBoard.test.tsx
--- frontend/src/components/ProcessBoard.test.tsx
+++ frontend/src/components/ProcessBoard.test.tsx
@@ -1,6 +1,7 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ProcessBoard from './ProcessBoard'
+import { withCodes } from '../codes/fixtures'
 import type { Org, ProcessItem, ProcessPage } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -104,7 +105,7 @@
   it('목록을 표로 보여주고 처리상태 배지가 나온다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => {
       expect(screen.getByText('2026년 사업 공고')).toBeTruthy()
@@ -117,7 +118,7 @@
   it('자료가 없으면 안내 문구를 보여준다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ items: [], total: 0 }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => {
       expect(
@@ -129,7 +130,7 @@
   it('상태 칩을 누르면 status 파라미터로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() =>
       expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
@@ -166,7 +167,7 @@
   it('검색어를 입력하고 검색을 누르면 keyword로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
 
@@ -187,7 +188,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.importProcess.mockResolvedValue({ created: 3, updated: 2, total: 5 })
 
-    const { container } = render(<ProcessBoard org={org()} />)
+    const { container } = render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalledTimes(1))
 
@@ -206,7 +207,7 @@
   it('처리완료 건은 수정 버튼이, 미처리 건은 처리등록 버튼만 보인다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ items: [item({ processStatus: '처리완료' })] }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     expect(screen.getByRole('button', { name: '수정' })).toBeTruthy()
@@ -220,7 +221,7 @@
     mocks.bulkDeleteProcess.mockResolvedValue({ deleted: 1 })
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
@@ -237,7 +238,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.bulkUpdateProcess.mockResolvedValue({ updated: 1 })
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
 
     fireEvent.click(screen.getByLabelText('1번 선택'))
@@ -259,7 +260,7 @@
   it('상세검색 3칸은 열별 조건으로 함께 넘어간다', async () => {
     mocks.getProcessPage.mockResolvedValue(page())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
     await waitFor(() => expect(mocks.getProcessPage).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '상세검색' }))
@@ -284,7 +285,7 @@
   it('다음 페이지 버튼을 누르면 page+1로 다시 조회한다', async () => {
     mocks.getProcessPage.mockResolvedValue(page({ total: 31 }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() =>
       expect(mocks.getProcessPage).toHaveBeenCalledWith(1, {
@@ -313,7 +314,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.getProcessItem.mockResolvedValue(item({ finalOpinion: '검토 완료' }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -328,7 +329,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.getProcessItem.mockResolvedValue(item())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -346,7 +347,7 @@
     mocks.getProcessItem.mockResolvedValue(item())
     mocks.updateProcessing.mockResolvedValue(item({ processStatus: '처리완료' }))
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -380,7 +381,7 @@
       item({ contractDocs: '양도계약서, 기타:구두 동의' }),
     )
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -395,7 +396,7 @@
     mocks.getProcessPage.mockResolvedValue(page())
     mocks.getProcessItem.mockResolvedValue(item())
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
@@ -413,7 +414,7 @@
     mocks.deleteProcessItem.mockResolvedValue(undefined)
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ProcessBoard org={org()} />)
+    render(withCodes(<ProcessBoard org={org()} />))
 
     await waitFor(() => expect(screen.getByText('2026년 사업 공고')).toBeTruthy())
     fireEvent.click(screen.getByRole('button', { name: '처리등록' }))
frontend/src/components/ProcessBoard.tsx
--- frontend/src/components/ProcessBoard.tsx
+++ frontend/src/components/ProcessBoard.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { useEffect, useRef, useState, type ReactNode } from 'react'
 import {
   bulkDeleteProcess,
   bulkUpdateProcess,
@@ -11,41 +11,47 @@
   type Org,
   type ProcessImportReport,
   type ProcessItem,
+  type Code,
   type ProcessStatusFilter,
   type ProcessingRequest,
 } from '../api/client'
+import { useCodes, useSplitByFirstRow } from '../codes/useCodes'
 
 const PAGE_SIZE = 30
 
-const STATUS_CHIPS: { key: ProcessStatusFilter | undefined; label: string }[] = [
-  { key: undefined, label: '전체' },
-  { key: 'PENDING', label: '미처리' },
-  { key: 'DONE', label: '처리완료' },
-]
+/** 공공누리 유형이 쓰이는 자리. 권리처리 판정에는 '보류'가 없다. */
+const SCOPE_PROCESS = 'process'
 
-const CONTRACT_DOC_OPTIONS = ['양도계약서', '제안요청서', '초상이용동의서', '공공누리동의서', '공문']
-const CONTRACT_ETC = '기타'
+/** 자유 입력이 붙는 계약서류('기타'). 저장값이 "기타:본문" 형태가 된다. */
+function etcDocOf(docs: Code[]): Code | undefined {
+  return docs.find((doc) => doc.attrs.freeText === true)
+}
 
-const JUDGED_KOGL_TYPE_OPTIONS = ['0유형', '개방불가', '1유형', '2유형', '3유형', '4유형']
-
-const PROCESS_STATUS_OPTIONS = ['미처리', '처리완료']
+/** '처리완료'에 해당하는 코드. 처리일시를 찍고 배지를 초록으로 바꾸는 기준이다. */
+function doneStatusOf(statuses: Code[]): Code | undefined {
+  return statuses.find((status) => status.attrs.done === true)
+}
 
 /** "양도계약서, 공문, 기타:직접 촬영본 사용동의" 형태의 저장값 → 체크박스 상태로 역파싱한다. */
-function parseContractDocs(value: string | null): { selected: Set<string>; etcText: string } {
+function parseContractDocs(
+  value: string | null,
+  docs: Code[],
+): { selected: Set<string>; etcText: string } {
   const selected = new Set<string>()
   let etcText = ''
   if (!value) {
     return { selected, etcText }
   }
+  const etc = etcDocOf(docs)
   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)) {
+    if (etc && part.startsWith(`${etc.code}:`)) {
+      selected.add(etc.code)
+      etcText = part.slice(etc.code.length + 1)
+    } else if (docs.some((doc) => doc.code === part)) {
       selected.add(part)
     }
   }
@@ -53,10 +59,17 @@
 }
 
 /** 체크박스 상태 → 저장값. 아무것도 선택하지 않았으면 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}`)
+function serializeContractDocs(
+  selected: Set<string>,
+  etcText: string,
+  docs: Code[],
+): string | null {
+  const etc = etcDocOf(docs)
+  const parts: string[] = docs
+    .filter((doc) => doc.attrs.freeText !== true && selected.has(doc.code))
+    .map((doc) => doc.code)
+  if (etc && selected.has(etc.code)) {
+    parts.push(`${etc.code}:${etcText}`)
   }
   return parts.length > 0 ? parts.join(', ') : null
 }
@@ -154,20 +167,37 @@
 }
 
 function ProcessStatusBadge({ value }: { value: string | null }) {
-  const done = value === '처리완료'
+  // 어느 값이 '완료'인지는 코드표의 done 속성이 정한다.
+  const statuses = useCodes('PROCESS_STATUS')
+  const doneCode = doneStatusOf(statuses)
+  const done = !!doneCode && value === doneCode.code
+  const pendingLabel = statuses.find((status) => status.attrs.done !== true)?.label ?? ''
   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 ? '처리완료' : '미처리'}
+      {done ? doneCode.label : pendingLabel}
     </span>
   )
 }
 
 /** 기관관리 상세의 권리처리 탭. 목록↔상세 두 화면을 이 컴포넌트 하나에서 전환한다(ReviewBoard와 동일 구조). */
 export default function ProcessBoard({ org }: { org: Org }) {
+  const statuses = useCodes('PROCESS_STATUS')
+  const doneStatusCode = doneStatusOf(statuses)?.code
+  const koglProcess = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS)
+  // 필터 칩의 '전체'는 코드가 아니라 화면에서만 쓰는 항목이라 앞에 붙인다.
+  // 나머지는 코드표에서 오되, 서버에 넘기는 키(PENDING/DONE)는 done 속성으로 정한다.
+  const statusChips: { key: ProcessStatusFilter | undefined; label: string }[] = [
+    { key: undefined, label: '전체' },
+    ...statuses.map((status) => ({
+      key: (status.attrs.done === true ? 'DONE' : 'PENDING') as ProcessStatusFilter,
+      label: status.label,
+    })),
+  ]
+
   const [mode, setMode] = useState<'list' | number>('list')
 
   const [items, setItems] = useState<ProcessItem[]>([])
@@ -363,7 +393,7 @@
       <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) => (
+            {statusChips.map((chip) => (
               <button
                 key={chip.label}
                 type="button"
@@ -510,8 +540,8 @@
           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>
+          {[...koglProcess.first, ...koglProcess.rest].map((option) => (
+            <option key={option.code} value={option.code}>{option.label}</option>
           ))}
         </select>
         <input
@@ -644,11 +674,11 @@
                     <ProcessStatusBadge value={item.processStatus} />
                   </td>
                   <td className="px-3 py-2 text-gray-600">
-                    {item.processStatus === '처리완료' ? formatDate(item.processedAt) : '-'}
+                    {item.processStatus === doneStatusCode ? formatDate(item.processedAt) : '-'}
                   </td>
                   <td className="px-3 py-2">
                     <div className="flex gap-2">
-                      {item.processStatus === '처리완료' ? (
+                      {item.processStatus === doneStatusCode ? (
                         <>
                           <button
                             type="button"
@@ -713,6 +743,12 @@
   onBack: () => void
   onSaved: () => void
 }) {
+  const contractDocs = useCodes('CONTRACT_DOC')
+  const etcDoc = etcDocOf(contractDocs)
+  const statuses = useCodes('PROCESS_STATUS')
+  // 권리처리 판정에는 '보류'가 없다 - 변호사 판정과 목록이 다르다.
+  const kogl = useSplitByFirstRow('KOGL_TYPE', SCOPE_PROCESS)
+
   const [item, setItem] = useState<ProcessItem | null>(null)
   const [loading, setLoading] = useState(true)
   const [loadError, setLoadError] = useState<string | null>(null)
@@ -739,7 +775,7 @@
           return
         }
         setItem(data)
-        const { selected, etcText } = parseContractDocs(data.contractDocs)
+        const { selected, etcText } = parseContractDocs(data.contractDocs, contractDocs)
         setContractSelected(selected)
         setContractEtc(etcText)
         setJudgedKoglType(data.judgedKoglType ?? '')
@@ -784,7 +820,7 @@
     setSaveError(null)
     try {
       const body: ProcessingRequest = {
-        contractDocs: serializeContractDocs(contractSelected, contractEtc),
+        contractDocs: serializeContractDocs(contractSelected, contractEtc, contractDocs),
         judgedKoglType: judgedKoglType || null,
         judgedAiType: judgedAiType ? 'Y' : null,
         finalOpinion: finalOpinion || null,
@@ -897,26 +933,31 @@
           <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">
+                {/* '기타'는 자유 입력이 딸려 있어 뒤에 따로 그린다. */}
+                {contractDocs
+                  .filter((option) => option.attrs.freeText !== true)
+                  .map((option) => (
+                    <label key={option.code} className="flex items-center gap-1.5 text-sm text-gray-800">
+                      <input
+                        type="checkbox"
+                        checked={contractSelected.has(option.code)}
+                        onChange={() => toggleContractOption(option.code)}
+                      />
+                      {option.label}
+                    </label>
+                  ))}
+                {etcDoc && (
+                  <label className="flex items-center gap-1.5 text-sm text-gray-800">
                     <input
                       type="checkbox"
-                      checked={contractSelected.has(option)}
-                      onChange={() => toggleContractOption(option)}
+                      checked={contractSelected.has(etcDoc.code)}
+                      onChange={() => toggleContractOption(etcDoc.code)}
                     />
-                    {option}
+                    {etcDoc.label}
                   </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) && (
+              {etcDoc && contractSelected.has(etcDoc.code) && (
                 <input
                   type="text"
                   aria-label="기타 계약서 내용"
@@ -932,28 +973,28 @@
           <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">
+                {kogl.first.map((option) => (
+                  <label key={option.code} 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)}
+                      checked={judgedKoglType === option.code}
+                      onChange={() => setJudgedKoglType(option.code)}
                     />
-                    {option}
+                    {option.label}
                   </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">
+                {kogl.rest.map((option) => (
+                  <label key={option.code} 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)}
+                      checked={judgedKoglType === option.code}
+                      onChange={() => setJudgedKoglType(option.code)}
                     />
-                    {option}
+                    {option.label}
                   </label>
                 ))}
                 <label className="ml-2 flex items-center gap-1.5 text-sm text-gray-800">
@@ -1017,9 +1058,9 @@
               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}
+              {statuses.map((option) => (
+                <option key={option.code} value={option.code}>
+                  {option.label}
                 </option>
               ))}
             </select>
Add a comment
List