이호영 이호영 07-28
refactor: 담당자관리·사업관리 화면을 코드표에 연결
담당자관리는 구분 5종이 한 파일 안에서만 네 번 따로 적혀 있었다(표시명·뱃지색·필터칩·
드롭다운). 전부 코드표에서 가져오게 바꿨다.

사업관리의 단계 필터는 Array.from({length: 12})로 12가 박혀 있어서, 13단계를 늘려도
이 필터만 12개로 남을 자리였다. 연락 방법 4종도 코드표로 옮겼다.

동작은 그대로다. 프론트 208건 전부 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@4171d274981e298003bc2dc280488957a364a8f0
frontend/src/components/ContactsPage.test.tsx
--- frontend/src/components/ContactsPage.test.tsx
+++ frontend/src/components/ContactsPage.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 ContactsPage from './ContactsPage'
+import { withCodes } from '../codes/fixtures'
 import type { Contact } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -46,7 +47,7 @@
   it('담당자 목록을 구분·성명 등과 함께 보여준다', async () => {
     mocks.getContacts.mockResolvedValue([contact()])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
     expect(screen.getByRole('cell', { name: '신청기관' })).toBeTruthy()
@@ -59,7 +60,7 @@
   it('담당자가 없으면 안내 문구를 보여준다', async () => {
     mocks.getContacts.mockResolvedValue([])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => {
       expect(screen.getByText('등록된 담당자가 없습니다.')).toBeTruthy()
@@ -72,7 +73,7 @@
       contact({ id: 2, name: '이변호', category: 'LAWYER', affiliation: null, deptName: null }),
     ])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
@@ -88,7 +89,7 @@
       contact({ id: 2, name: '홍수행', category: 'OPERATOR', affiliation: '수행사', deptName: '운영팀' }),
     ])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
 
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
@@ -101,7 +102,7 @@
   it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
     mocks.getContacts.mockResolvedValue([])
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
 
     fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
@@ -118,7 +119,7 @@
     mocks.getContacts.mockResolvedValueOnce([]).mockResolvedValueOnce([contact({ name: '홍길동' })])
     mocks.createContact.mockResolvedValue(contact({ name: '홍길동' }))
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalledTimes(1))
 
     fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
@@ -145,7 +146,7 @@
     mocks.getContacts.mockResolvedValue([contact()])
     mocks.updateContactInfo.mockResolvedValue(contact({ phone: '02-0000-0000' }))
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
     fireEvent.click(screen.getByRole('button', { name: '수정' }))
@@ -169,7 +170,7 @@
     mocks.deleteContact.mockResolvedValue(undefined)
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
     fireEvent.click(screen.getByRole('button', { name: '삭제' }))
@@ -186,7 +187,7 @@
     mocks.getContacts.mockResolvedValue([contact()])
     const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
 
     fireEvent.click(screen.getByRole('button', { name: '삭제' }))
@@ -201,7 +202,7 @@
     mocks.getContacts.mockResolvedValue([])
     mocks.importMembers.mockResolvedValue({ created: 2, updated: 1, skipped: 1, assigned: 1 })
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
 
     const file = new File(['dummy'], 'members.xlsx', {
@@ -220,7 +221,7 @@
     mocks.getContacts.mockResolvedValue([])
     mocks.importMembers.mockRejectedValue(new Error('파일 형식이 올바르지 않습니다.'))
 
-    render(<ContactsPage />)
+    render(withCodes(<ContactsPage />))
     await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
 
     const file = new File(['dummy'], 'members.xlsx', {
frontend/src/components/ContactsPage.tsx
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
@@ -10,32 +10,12 @@
   type ContactInput,
   type MemberImportReport,
 } from '../api/client'
+import { useCodes } from '../codes/useCodes'
+import { badgeClass } from '../codes/tone'
 import HelpButton from '../help/HelpButton'
 
-const CATEGORY_LABELS: Record<ContactCategory, string> = {
-  APPLICANT: '신청기관',
-  MJ: '문정원',
-  LAWYER: '변호사',
-  OPERATOR: '수행기관',
-  ITN: '아이티앤 담당자',
-}
-
-const CATEGORY_BADGE_STYLES: Record<ContactCategory, string> = {
-  APPLICANT: 'bg-blue-50 text-blue-700',
-  MJ: 'bg-emerald-50 text-emerald-700',
-  LAWYER: 'bg-violet-50 text-violet-700',
-  OPERATOR: 'bg-sky-50 text-sky-700',
-  ITN: 'bg-orange-50 text-orange-700',
-}
-
-const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
-  { key: 'ALL', label: '전체' },
-  { key: 'APPLICANT', label: '신청기관' },
-  { key: 'MJ', label: '문정원' },
-  { key: 'LAWYER', label: '변호사' },
-  { key: 'OPERATOR', label: '수행기관' },
-  { key: 'ITN', label: '아이티앤 담당자' },
-]
+// 구분 5종(표시명·뱃지색·필터·드롭다운)은 예전에 이 파일 안에서만 네 번 따로 적혀 있었다.
+// 지금은 전부 코드표(CONTACT_CATEGORY)에서 온다.
 
 function emptyForm(contact: Contact | null): ContactInput {
   return {
@@ -61,6 +41,7 @@
   const [form, setForm] = useState<ContactInput>(emptyForm(editing))
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState<string | null>(null)
+  const categories = useCodes('CONTACT_CATEGORY')
 
   useEffect(() => {
     function onKeyDown(e: KeyboardEvent) {
@@ -125,11 +106,11 @@
               onChange={(e) => setField('category', e.target.value)}
               className="rounded-md border border-gray-300 px-2 py-1.5"
             >
-              <option value="APPLICANT">신청기관</option>
-              <option value="MJ">문정원</option>
-              <option value="LAWYER">변호사</option>
-              <option value="OPERATOR">수행기관</option>
-              <option value="ITN">아이티앤 담당자</option>
+              {categories.map((category) => (
+                <option key={category.code} value={category.code}>
+                  {category.label}
+                </option>
+              ))}
             </select>
           </div>
           <div className="flex flex-col gap-1">
@@ -240,6 +221,20 @@
   const [importReport, setImportReport] = useState<MemberImportReport | null>(null)
   const [importError, setImportError] = useState<string | null>(null)
 
+  const categories = useCodes('CONTACT_CATEGORY')
+  /** 코드표에 없는 구분값이 저장돼 있어도 뱃지가 사라지지 않도록 undefined를 허용한다. */
+  function categoryOf(category: ContactCategory) {
+    return categories.find((candidate) => candidate.code === category)
+  }
+  // '전체'는 코드가 아니라 화면에서만 쓰는 필터 항목이라 코드표에 넣지 않고 여기서 앞에 붙인다.
+  const filters: { key: ContactCategory | 'ALL'; label: string }[] = [
+    { key: 'ALL', label: '전체' },
+    ...categories.map((category) => ({
+      key: category.code as ContactCategory,
+      label: category.label,
+    })),
+  ]
+
   async function load() {
     setLoading(true)
     try {
@@ -307,7 +302,7 @@
     <div className="p-8">
       <div className="flex flex-wrap items-center justify-between gap-3">
         <div className="flex flex-wrap gap-2">
-          {FILTERS.map((f) => (
+          {filters.map((f) => (
             <button
               key={f.key}
               type="button"
@@ -397,9 +392,11 @@
                 <tr key={contact.id} className="border-b border-gray-100 last:border-b-0">
                   <td className="px-3 py-2">
                     <span
-                      className={`rounded-full px-2 py-0.5 text-xs font-medium ${CATEGORY_BADGE_STYLES[contact.category]}`}
+                      className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
+                        categoryOf(contact.category)?.attrs.tone as string | undefined,
+                      )}`}
                     >
-                      {CATEGORY_LABELS[contact.category]}
+                      {categoryOf(contact.category)?.label ?? contact.category}
                     </span>
                   </td>
                   <td className="px-3 py-2 font-medium text-gray-900">{contact.name}</td>
frontend/src/components/ProjectsPage.test.tsx
--- frontend/src/components/ProjectsPage.test.tsx
+++ frontend/src/components/ProjectsPage.test.tsx
@@ -1,6 +1,7 @@
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import ProjectsPage from './ProjectsPage'
+import { withCodes } from '../codes/fixtures'
 import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -77,7 +78,7 @@
 }
 
 async function openPanel() {
-  render(<ProjectsPage onOpenOrg={vi.fn()} />)
+  render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
   await screen.findByTestId('project-table')
   fireEvent.click(screen.getByRole('button', { name: '연락·보고서' }))
   await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1))
@@ -95,7 +96,7 @@
     mocks.getProjects.mockResolvedValue([
       row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }),
     ])
-    render(<ProjectsPage onOpenOrg={vi.fn()} />)
+    render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
 
     const table = within(await screen.findByTestId('project-table'))
     expect(table.getByText('국제방송교류재단')).toBeTruthy()
@@ -106,7 +107,7 @@
 
   it('기관명을 클릭하면 기관관리로 넘긴다', async () => {
     const onOpenOrg = vi.fn()
-    render(<ProjectsPage onOpenOrg={onOpenOrg} />)
+    render(withCodes(<ProjectsPage onOpenOrg={onOpenOrg} />))
 
     fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' }))
     expect(onOpenOrg).toHaveBeenCalledWith(1)
@@ -117,7 +118,7 @@
       row(),
       row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }),
     ])
-    render(<ProjectsPage onOpenOrg={vi.fn()} />)
+    render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
     await screen.findByTestId('project-table')
 
     fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
frontend/src/components/ProjectsPage.tsx
--- frontend/src/components/ProjectsPage.tsx
+++ frontend/src/components/ProjectsPage.tsx
@@ -13,7 +13,8 @@
   type OrgReport,
   type ProjectRow,
 } from '../api/client'
-import { stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
+import { useCodes } from '../codes/useCodes'
 import HelpButton from '../help/HelpButton'
 
 /**
@@ -28,8 +29,6 @@
   /** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */
   onOpenOrg: (orgId: number) => void
 }
-
-const METHODS = ['전화', '메일', '방문', '기타']
 
 function formatDateTime(ms: number): string {
   const d = new Date(ms)
@@ -55,6 +54,11 @@
 }
 
 export default function ProjectsPage({ onOpenOrg }: Props) {
+  // 단계 목록과 연락 방법은 코드표에서 온다. 단계는 예전에 12로 박혀 있어서
+  // 13단계를 늘리면 이 필터만 12개로 남는 문제가 있었다.
+  const stages = useStages()
+  const methods = useCodes('CONTACT_METHOD')
+
   const [rows, setRows] = useState<ProjectRow[] | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [keyword, setKeyword] = useState('')
@@ -69,7 +73,7 @@
   const [panelError, setPanelError] = useState<string | null>(null)
 
   const [logDate, setLogDate] = useState(today())
-  const [logMethod, setLogMethod] = useState(METHODS[0])
+  const [logMethod, setLogMethod] = useState(methods[0]?.code ?? '')
   const [logSummary, setLogSummary] = useState('')
   const [editingLogId, setEditingLogId] = useState<number | null>(null)
 
@@ -237,8 +241,8 @@
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
             <option value="">현재 단계 전체</option>
-            {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
-              <option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{`${n}. ${stages.label(n)}`}</option>
             ))}
           </select>
           <select
@@ -296,7 +300,9 @@
                       </button>
                     </td>
                     <td className="px-2 py-2 text-gray-600">
-                      {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`}
+                      {row.org.stage === null
+                        ? '단계 미지정'
+                        : `${row.org.stage}. ${stages.label(row.org.stage)}`}
                     </td>
                     <td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
                     <td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
@@ -384,8 +390,10 @@
                       onChange={(e) => setLogMethod(e.target.value)}
                       className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
                     >
-                      {METHODS.map((m) => (
-                        <option key={m} value={m}>{m}</option>
+                      {methods.map((m) => (
+                        <option key={m.code} value={m.code}>
+                          {m.label}
+                        </option>
                       ))}
                     </select>
                   </label>
Add a comment
List