이호영 이호영 07-28
feat: 진행단계를 코드표 기반으로 바꾸고 13단계를 반영 (요구사항 2·7·11)
대시보드는 단계를 안 바꾸고는 코드표에 연결할 수 없는 구조였다. 칸반 컬럼 수가 단계
배열 길이에서 나오고 완료 판정이 11로 박혀 있었는데 코드표는 13을 말하기 때문이다.
프론트만 바꾸면 화면과 서버 숫자가 어긋나므로 백엔드까지 한 번에 옮긴다.

  [11] 칸반 컬럼이 단계 미지정 + 13개가 된다. 단계 상한도 코드표 개수를 따른다.
  [2]  완료 = ⑬보고서 제출 완료. 보고서가 실제로 올라온 기관만 센다.
  [7]  진행상태에 '신청'이 생긴다. 신청은 ①만, 완료는 ⑬만, 나머지가 진행중이다.
       채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 그대로 둔다.

원문자 배열(①~⑫)과 구간 색 맵을 지웠다. 원문자는 번호에서 계산하고 색은 코드표의
tone에서 온다 - 단계가 늘 때마다 같이 늘려야 했던 자리다.

stages.ts를 지웠다. 남아 있던 소비자(기관상세·타임라인·시스템관리)도 모두 옮겼다.

동작이 바뀐 테스트 3건은 새 기준으로 다시 썼다(11단계 완료 집계, 13단계 거부,
마지막 단계 12). 도움말 문구도 13단계 기준으로 고쳤다.

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

Co-Authored-By: Claude Opus 5 (1M context) 
@0f40c99a42cf1af5c0ae9e7e47f33f39401a4527
frontend/src/components/Dashboard.test.tsx
--- frontend/src/components/Dashboard.test.tsx
+++ frontend/src/components/Dashboard.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 Dashboard from './Dashboard'
+import { withCodes } from '../codes/fixtures'
 import type { DashboardData, DashboardOrgRow } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -56,7 +57,7 @@
 }
 
 function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) {
-  render(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />)
+  render(withCodes(<Dashboard onOpenOrg={onOpenOrg} onOpenOrgList={onOpenOrgList} />))
   return { onOpenOrg, onOpenOrgList }
 }
 
frontend/src/components/Dashboard.tsx
--- frontend/src/components/Dashboard.tsx
+++ frontend/src/components/Dashboard.tsx
@@ -5,7 +5,8 @@
   type DashboardData,
   type DashboardOrgRow,
 } from '../api/client'
-import { STAGE_GROUPS, STAGE_LABELS, stageGroupKey, stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
+import { barClass } from '../codes/tone'
 import HelpButton from '../help/HelpButton'
 
 /**
@@ -28,16 +29,8 @@
   onOpenOrgList: () => void
 }
 
-const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫']
-
-/** 칸반 컬럼 상단 색 띠. 구간별로 같은 색을 쓴다. */
-const GROUP_COLOR: Record<string, string> = {
-  intake: 'bg-slate-400',
-  assign: 'bg-indigo-400',
-  check: 'bg-emerald-400',
-  process: 'bg-amber-400',
-  closing: 'bg-violet-400',
-}
+// 원문자(①②)와 구간 색은 더 이상 여기 박아두지 않는다. 원문자는 단계 번호에서 계산하고
+// 색은 코드표의 tone에서 온다 - 단계가 하나 늘 때마다 배열을 같이 늘려야 했던 자리다.
 
 /** 표/칸반이 한 번에 보여주는 기관 수. [더보기]를 누를 때마다 이만큼 늘어난다. */
 const PAGE_SIZE = 8
@@ -123,8 +116,9 @@
 
 /** 단계 구간에 따라 카드 본문이 달라진다(시안의 컬럼별 카드 모양). */
 function CardBody({ row }: { row: DashboardOrgRow }) {
+  const stages = useStages()
   const stage = columnOf(row)
-  const group = stageGroupKey(stage)
+  const group = stages.groupKey(stage)
   const lawyer = row.lawyerName ?? '변호사 미배정'
 
   if (stage === null) {
@@ -170,13 +164,14 @@
     <div className="space-y-1 text-xs text-gray-500">
       <p>{lawyer}</p>
       <span className="inline-block rounded-full bg-violet-50 px-2 py-0.5 text-[11px] text-violet-700">
-        {CIRCLED[stage - 1]} {stageLabel(stage)}
+        {stages.symbol(stage)} {stages.label(stage)}
       </span>
     </div>
   )
 }
 
 export default function Dashboard({ onOpenOrg, onOpenOrgList }: Props) {
+  const stages = useStages()
   const [data, setData] = useState<DashboardData | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [busy, setBusy] = useState(false)
@@ -226,7 +221,7 @@
     const keyword = boardKeyword.trim()
     return rows.filter((row) => {
       const stage = columnOf(row)
-      if (groupFilter && stageGroupKey(stage) !== groupFilter) {
+      if (groupFilter && stages.groupKey(stage) !== groupFilter) {
         return false
       }
       if (lawyerFilter && row.lawyerName !== lawyerFilter) {
@@ -235,13 +230,16 @@
       if (stageFilter && String(stage ?? '') !== stageFilter) {
         return false
       }
+      // 진행상태는 코드표(STAGE의 progress)에서 온다 - 신청은 ①만, 완료는 ⑬만,
+      // 나머지가 진행중이다. 채널이 없어 단계가 아예 없는 기관은 '단계 미지정'으로 따로 본다.
       if (statusFilter === 'unassigned' && row.hasChannel) {
         return false
       }
-      if (statusFilter === 'active' && (!row.hasChannel || (stage ?? 0) >= 11)) {
-        return false
-      }
-      if (statusFilter === 'done' && (stage ?? 0) < 11) {
+      if (
+        statusFilter &&
+        statusFilter !== 'unassigned' &&
+        (!row.hasChannel || stages.progressOf(stage) !== statusFilter)
+      ) {
         return false
       }
       if (keyword && !row.orgName.includes(keyword) && !row.orgNo.includes(keyword)) {
@@ -249,7 +247,7 @@
       }
       return true
     })
-  }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword])
+  }, [rows, groupFilter, lawyerFilter, stageFilter, statusFilter, boardKeyword, stages])
 
   /** 시안의 "정렬 기준 최근 진행순" - 칸반 카드도 최근 변동이 위로 온다. */
   const byRecent = useCallback(
@@ -344,7 +342,8 @@
   }
 
   const s = data.summary
-  const columns: (number | null)[] = [null, ...STAGE_LABELS.map((_, i) => i + 1)]
+  // 첫 칸은 '단계 미지정'(채널 미생성 기관 자리)이고, 나머지는 코드표의 단계 수만큼이다.
+  const columns: (number | null)[] = [null, ...stages.numbers]
 
   return (
     <div className="space-y-4 p-6">
@@ -397,8 +396,8 @@
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
             <option value="">업무 구간 전체</option>
-            {STAGE_GROUPS.map((g) => (
-              <option key={g.key} value={g.key}>{g.label}</option>
+            {stages.groups.map((g) => (
+              <option key={g.code} value={g.code}>{g.label}</option>
             ))}
           </select>
           <select
@@ -419,8 +418,8 @@
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
             <option value="">현재 단계 전체</option>
-            {STAGE_LABELS.map((label, i) => (
-              <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
             ))}
           </select>
           <select
@@ -431,6 +430,7 @@
           >
             <option value="">진행 상태 전체</option>
             <option value="unassigned">단계 미지정</option>
+            <option value="applied">신청</option>
             <option value="active">진행중</option>
             <option value="done">완료</option>
           </select>
@@ -454,7 +454,7 @@
             const cards = boardRows.filter((row) => columnOf(row) === stage).sort(byRecent)
             const expanded = expandedColumns[key]
             const shown = expanded ? cards : cards.slice(0, COLUMN_SIZE)
-            const color = stage === null ? 'bg-gray-300' : GROUP_COLOR[stageGroupKey(stage) ?? ''] ?? 'bg-gray-300'
+            const color = stage === null ? 'bg-gray-300' : barClass(stages.groupTone(stages.groupKey(stage)))
 
             return (
               <div
@@ -475,7 +475,7 @@
                 <div className={`h-1 rounded-t-lg ${color}`} />
                 <div className="flex items-center justify-between px-3 py-2">
                   <span className="text-sm font-medium">
-                    {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
+                    {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
                   </span>
                   <span className="text-sm text-gray-500 tabular-nums">{cards.length}</span>
                 </div>
@@ -542,8 +542,8 @@
             className="rounded-md border border-gray-300 px-2 py-1.5 text-sm"
           >
             <option value="">현재 단계 전체</option>
-            {STAGE_LABELS.map((label, i) => (
-              <option key={label} value={String(i + 1)}>{CIRCLED[i]} {label}</option>
+            {stages.numbers.map((n) => (
+              <option key={n} value={String(n)}>{stages.symbol(n)} {stages.label(n)}</option>
             ))}
           </select>
           <select
@@ -622,7 +622,7 @@
                     <td className="px-2 py-2 font-medium">{row.orgName}</td>
                     <td className="px-2 py-2">
                       <span className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
-                        {stage === null ? '단계 미지정' : `${CIRCLED[stage - 1]} ${stageLabel(stage)}`}
+                        {stage === null ? '단계 미지정' : `${stages.symbol(stage)} ${stages.label(stage)}`}
                       </span>
                     </td>
                     <td className="px-2 py-2 text-gray-600">{row.mjName ?? '-'}</td>
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.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 OrgOverview from './OrgOverview'
+import { withCodes } from '../codes/fixtures'
 import type { Contact, Org } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -86,7 +87,7 @@
 
 describe('OrgOverview', () => {
   it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByText('001_00_국제방송교류재단')).toBeTruthy()
     expect(screen.getByText('데이터정보화팀')).toBeTruthy()
@@ -97,7 +98,7 @@
   })
 
   it('문정원 담당자와 아이티앤 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
     const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement
@@ -111,6 +112,7 @@
 
   it('담당자 4종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
     render(
+      withCodes(
       <OrgOverview
         org={org({
           mj: contact({
@@ -139,6 +141,7 @@
           lawyerAssignedDate: '2026-07-21',
         })}
       />,
+      ),
     )
 
     const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement
@@ -168,7 +171,7 @@
   })
 
   it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
     expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
@@ -183,7 +186,7 @@
     ])
     const onChanged = vi.fn()
 
-    render(<OrgOverview org={org()} onChanged={onChanged} />)
+    render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
 
     fireEvent.click(screen.getByRole('button', { name: '문정원 담당자 생성' }))
 
@@ -208,7 +211,7 @@
     ])
     const onChanged = vi.fn()
 
-    render(<OrgOverview org={org()} onChanged={onChanged} />)
+    render(withCodes(<OrgOverview org={org()} onChanged={onChanged} />))
 
     fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 생성' }))
 
@@ -228,7 +231,7 @@
   })
 
   it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
     expect(screen.getByRole('button', { name: '진행단계 변경' })).not.toBeDisabled()
@@ -242,7 +245,7 @@
       { stage: 1, changedAt: new Date('2026-07-15T09:00:00').getTime() },
     ])
 
-    render(<OrgOverview org={org({ stage: 3 })} />)
+    render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
 
     await waitFor(() => {
       expect(screen.getByText('2026-07-24 10:00')).toBeTruthy()
@@ -252,7 +255,7 @@
   })
 
   it('업무단계 12개가 순서대로 보인다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByText('신청')).toBeTruthy()
     expect(screen.getByText('변호사 배당')).toBeTruthy()
@@ -261,14 +264,14 @@
   })
 
   it('기본 탭인 채팅은 자리표시 대신 채널 선택 화면을 보여준다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByRole('button', { name: '문정원' })).toBeTruthy()
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalledWith(1, 'mj'))
   })
 
   it('권리확인 탭을 누르면 권리확인 보드가 마운트되어 조회를 부른다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -280,7 +283,7 @@
   })
 
   it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -291,7 +294,7 @@
   })
 
   it('권리처리 탭을 누르면 권리처리 보드가 마운트되어 조회를 부른다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -306,7 +309,7 @@
     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()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => {
       expect(screen.getByText('완료 5 / 전체 12')).toBeTruthy()
@@ -315,7 +318,7 @@
   })
 
   it('타임라인 탭을 누르면 Timeline이 마운트되어 조회를 부른다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
 
@@ -326,7 +329,7 @@
   })
 
   it('자료 탭을 누르면 자료 목록 화면으로 바뀐다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     fireEvent.click(screen.getByRole('button', { name: '자료' }))
 
@@ -335,7 +338,7 @@
   })
 
   it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     const link = screen.getByRole('link', { name: 'Mattermost 열기' })
     expect(link).toHaveAttribute(
@@ -346,7 +349,7 @@
   })
 
   it('채널이 없으면 Mattermost 열기가 비활성이다', () => {
-    render(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
+    render(withCodes(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
 
     expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull()
     expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled()
@@ -354,9 +357,11 @@
 
   it('치안정책연구소도 합성코드 슬러그로 채널 링크를 만든다', async () => {
     render(
-      <OrgOverview
-        org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
-      />,
+      withCodes(
+        <OrgOverview
+          org={org({ orgNo: '008_01', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
+        />,
+      ),
     )
 
     expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute(
@@ -367,7 +372,7 @@
   })
 
   it('미구현 동작 버튼들은 비활성이다', async () => {
-    render(<OrgOverview org={org()} />)
+    render(withCodes(<OrgOverview org={org()} />))
 
     expect(screen.getByRole('button', { name: '자료 업로드' })).toBeDisabled()
     expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
@@ -378,14 +383,14 @@
 
   describe('업무단계 현황 스트립', () => {
     it('채널이 없어 단계가 시작되지 않았으면 전부 회색이고 안내 문구가 보인다', async () => {
-      render(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
+      render(withCodes(<OrgOverview org={org({ stage: null, channelIdMj: null, channelIdLaw: null, status: 'READY' })} />))
 
       expect(screen.getByText('채널 생성 시 신청 단계가 시작됩니다')).toBeTruthy()
       expect(screen.queryByText('진행중')).toBeNull()
     })
 
     it('현재 단계 이전은 완료(체크) 표시, 현재 단계는 진행중, 이후는 미래 상태로 보인다', async () => {
-      render(<OrgOverview org={org({ stage: 3 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 3 })} />))
 
       const doneButton = screen.getByText('목록접수').closest('button') as HTMLElement
       const currentButton = screen.getByText('예비검토').closest('button') as HTMLElement
@@ -405,7 +410,7 @@
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
       const onChanged = vi.fn()
 
-      render(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />)
+      render(withCodes(<OrgOverview org={org({ stage: 4 })} onChanged={onChanged} />))
 
       const currentButton = screen.getByText('변호사 배당').closest('button') as HTMLElement
       fireEvent.click(currentButton)
@@ -422,7 +427,7 @@
     it('확인을 취소하면 진행단계 변경 API를 부르지 않는다', async () => {
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
 
-      render(<OrgOverview org={org({ stage: 4 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
 
       fireEvent.click(screen.getByText('변호사 배당').closest('button') as HTMLElement)
 
@@ -435,7 +440,7 @@
     it('현재 단계가 아닌 다른 단계를 클릭해도 아무 일도 일어나지 않는다', async () => {
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-      render(<OrgOverview org={org({ stage: 4 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 4 })} />))
 
       fireEvent.click(screen.getByText('예비검토').closest('button') as HTMLElement)
       fireEvent.click(screen.getByText('RE:확인').closest('button') as HTMLElement)
@@ -446,12 +451,14 @@
       confirmSpy.mockRestore()
     })
 
-    it('마지막 단계(12)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
+    // 요구사항 [11]로 13단계(보고서 제출 완료)가 생겨 마지막 단계가 13으로 내려갔다.
+    // 12단계는 이제 마지막이 아니라서 다음 단계로 넘어갈 수 있다.
+    it('마지막 단계(13)가 현재 단계이면 클릭해도 아무 일도 일어나지 않는다', async () => {
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
 
-      render(<OrgOverview org={org({ stage: 12 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 13 })} />))
 
-      const lastButton = screen.getByText('보고서 작성').closest('button') as HTMLElement
+      const lastButton = screen.getByText('보고서 제출 완료').closest('button') as HTMLElement
       expect(lastButton).toBeDisabled()
       fireEvent.click(lastButton)
 
@@ -467,7 +474,7 @@
       const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
       const onChanged = vi.fn()
 
-      render(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />)
+      render(withCodes(<OrgOverview org={org({ stage: 2 })} onChanged={onChanged} />))
 
       fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
 
@@ -487,7 +494,7 @@
     })
 
     it('닫기를 누르면 모달이 사라진다', async () => {
-      render(<OrgOverview org={org({ stage: 2 })} />)
+      render(withCodes(<OrgOverview org={org({ stage: 2 })} />))
 
       fireEvent.click(screen.getByRole('button', { name: '진행단계 변경' }))
       expect(screen.getByRole('heading', { name: '진행단계 변경' })).toBeTruthy()
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -11,7 +11,7 @@
   type Org,
   type PostView,
 } from '../api/client'
-import { STAGE_LABELS, stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
 import HelpButton from '../help/HelpButton'
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
@@ -135,6 +135,7 @@
   org: Org
   onChanged?: () => void
 }) {
+  const stages = useStages()
   const [activeTab, setActiveTab] = useState(TABS[0])
   const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null)
   const [assignBusy, setAssignBusy] = useState(false)
@@ -273,18 +274,18 @@
 
   /** 업무단계 현황 스트립에서 "현재 단계"를 클릭했을 때만 호출된다 - 다음 단계로 1칸 전진. */
   async function advanceStage() {
-    if (org.stage === null || org.stage >= 12 || stageBusy) {
+    if (org.stage === null || org.stage >= stages.count || stageBusy) {
       return
     }
     const next = org.stage + 1
-    if (!window.confirm(`다음 단계 "${next}. ${stageLabel(next)}"(으)로 진행할까요?`)) {
+    if (!window.confirm(`다음 단계 "${next}. ${stages.label(next)}"(으)로 진행할까요?`)) {
       return
     }
     await changeStage(next)
   }
 
   async function pickStage(next: number) {
-    if (!window.confirm(`"${next}. ${stageLabel(next)}"(으)로 진행단계를 변경할까요?`)) {
+    if (!window.confirm(`"${next}. ${stages.label(next)}"(으)로 진행단계를 변경할까요?`)) {
       return
     }
     setStagePickerOpen(false)
@@ -439,18 +440,18 @@
             <HelpButton topic="org.stage" />
           </h2>
           <ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
-            {STAGE_LABELS.map((step, i) => {
-              const n = i + 1
+            {stages.numbers.map((n) => {
+              const step = stages.label(n)
               const isDone = org.stage !== null && n < org.stage
               const isCurrent = org.stage !== null && n === org.stage
-              const clickable = isCurrent && n < 12
+              const clickable = isCurrent && n < stages.count
 
               return (
-                <li key={step} className="shrink-0">
+                <li key={n} className="shrink-0">
                   <button
                     type="button"
                     disabled={!clickable || stageBusy}
-                    title={isCurrent && n >= 12 ? '마지막 단계입니다' : undefined}
+                    title={isCurrent && n >= stages.count ? '마지막 단계입니다' : undefined}
                     onClick={() => {
                       if (isCurrent) {
                         void advanceStage()
@@ -697,6 +698,7 @@
   onPick: (stage: number) => void
   onClose: () => void
 }) {
+  const stages = useStages()
   return (
     <div className="fixed inset-0 flex items-center justify-center bg-black/30">
       <div className="w-96 rounded-lg bg-white p-5">
@@ -713,11 +715,11 @@
         </div>
 
         <ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
-          {STAGE_LABELS.map((label, i) => {
-            const n = i + 1
+          {stages.numbers.map((n) => {
+            const label = stages.label(n)
             const isCurrent = currentStage === n
             return (
-              <li key={label}>
+              <li key={n}>
                 <button
                   type="button"
                   disabled={busy}
frontend/src/components/SystemPage.test.tsx
--- frontend/src/components/SystemPage.test.tsx
+++ frontend/src/components/SystemPage.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 SystemPage from './SystemPage'
+import { withCodes } from '../codes/fixtures'
 import type { ResetTarget } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -46,7 +47,7 @@
       target(),
       target({ orgId: 2, orgNo: '002', orgName: '세종학당재단', channels: [{ kind: '문정원', channelId: 'c2' }] }),
     ])
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
 
     expect(await screen.findByText('대상 2개 기관 · 채널 3개')).toBeTruthy()
     const rows = within(screen.getByTestId('reset-targets')).getAllByRole('row')
@@ -55,7 +56,7 @@
   })
 
   it('확인 문구를 입력하기 전에는 실행 버튼이 잠겨 있다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     expect(screen.getByText('채널 초기화 실행')).toHaveProperty('disabled', true)
@@ -68,7 +69,7 @@
   })
 
   it('실행 버튼을 눌러도 확인창에서 한 번 더 물어본다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
@@ -82,7 +83,7 @@
   })
 
   it('확인창에서 실행하면 문구와 함께 초기화를 요청하고 결과를 보여준다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     await typeConfirmAndRun()
@@ -99,7 +100,7 @@
       clearedOrgs: 0,
       failures: ['001 국제방송교류재단 (법률검토): 권한 없음'],
     })
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     await typeConfirmAndRun()
@@ -110,7 +111,7 @@
 
   it('대상이 없으면 실행할 수 없다', async () => {
     mocks.getResetPreview.mockResolvedValue([])
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
 
     expect(await screen.findByText('초기화할 채널이 없습니다.')).toBeTruthy()
     fireEvent.change(screen.getByLabelText(/실행하려면/), { target: { value: '채널 초기화' } })
@@ -118,7 +119,7 @@
   })
 
   it('무엇이 남고 무엇이 사라지는지 화면에 적어 둔다', async () => {
-    render(<SystemPage />)
+    render(withCodes(<SystemPage />))
     await screen.findByTestId('reset-targets')
 
     expect(screen.getByText(/보관\(아카이브\)/)).toBeTruthy()
frontend/src/components/SystemPage.tsx
--- frontend/src/components/SystemPage.tsx
+++ frontend/src/components/SystemPage.tsx
@@ -6,7 +6,7 @@
   type ResetReport,
   type ResetTarget,
 } from '../api/client'
-import { stageLabel } from '../stages'
+import { useStages } from '../codes/stage'
 import HelpButton from '../help/HelpButton'
 
 /**
@@ -28,6 +28,7 @@
 }
 
 export default function SystemPage({ onChanged }: Props) {
+  const stages = useStages()
   const [targets, setTargets] = useState<ResetTarget[] | null>(null)
   const [error, setError] = useState<string | null>(null)
   const [confirm, setConfirm] = useState('')
@@ -134,7 +135,7 @@
                       <td className="px-3 py-2 text-gray-500 tabular-nums">{t.orgNo}</td>
                       <td className="px-3 py-2 font-medium">{t.orgName}</td>
                       <td className="px-3 py-2 text-gray-600">
-                        {t.stage === null ? '-' : `${t.stage}. ${stageLabel(t.stage)}`}
+                        {t.stage === null ? '-' : `${t.stage}. ${stages.label(t.stage)}`}
                       </td>
                       <td className="px-3 py-2 text-gray-600">
                         {t.channels.map((c) => c.kind).join(' · ')}
frontend/src/components/Timeline.test.tsx
--- frontend/src/components/Timeline.test.tsx
+++ frontend/src/components/Timeline.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 Timeline from './Timeline'
+import { withCodes } from '../codes/fixtures'
 import type { Org, TimelineEvent } from '../api/client'
 
 const mocks = vi.hoisted(() => ({
@@ -48,7 +49,7 @@
   it('불러오는 동안 로딩 문구를 보여준다', () => {
     mocks.getTimeline.mockReturnValue(new Promise(() => {}))
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     expect(screen.getByText('불러오는 중…')).toBeTruthy()
   })
@@ -56,7 +57,7 @@
   it('기록이 없으면 빈 상태 문구를 보여준다', async () => {
     mocks.getTimeline.mockResolvedValue([])
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() =>
       expect(
@@ -76,7 +77,7 @@
       event({ type: 'STAGE', at: day1, title: '4', detail: null }),
     ])
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() => expect(screen.getByText('보고서.pdf')).toBeTruthy())
 
@@ -99,7 +100,7 @@
   it('새로고침 버튼을 누르면 다시 조회한다', async () => {
     mocks.getTimeline.mockResolvedValue([])
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() => expect(mocks.getTimeline).toHaveBeenCalledTimes(1))
 
@@ -111,7 +112,7 @@
   it('조회가 실패하면 오류 메시지를 보여준다', async () => {
     mocks.getTimeline.mockRejectedValue(new Error('타임라인을 불러오지 못했습니다.'))
 
-    render(<Timeline org={org()} />)
+    render(withCodes(<Timeline org={org()} />))
 
     await waitFor(() => expect(screen.getByText('타임라인을 불러오지 못했습니다.')).toBeTruthy())
   })
frontend/src/components/Timeline.tsx
--- frontend/src/components/Timeline.tsx
+++ frontend/src/components/Timeline.tsx
@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react'
 import { getTimeline, type Org, type TimelineEvent } from '../api/client'
-import { stageLabel } from '../stages'
+import { useStages, type Stages } from '../codes/stage'
 
 const dateFormatter = new Intl.DateTimeFormat('ko-KR', {
   year: 'numeric',
@@ -62,17 +62,18 @@
 }
 
 /** STAGE 이벤트의 title(단계 번호 문자열)을 "5. 법률검토(권리확인) 진행" 형태로 바꾼다. */
-function stageEventText(title: string): string {
+function stageEventText(title: string, stages: Stages): string {
   const n = Number(title)
   if (!Number.isInteger(n)) {
     return `${title} 단계로 진행`
   }
-  return `${n}. ${stageLabel(n)} 단계로 진행`
+  return `${n}. ${stages.label(n)} 단계로 진행`
 }
 
 function EventBody({ event }: { event: TimelineEvent }) {
+  const stages = useStages()
   if (event.type === 'STAGE') {
-    return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title)}</p>
+    return <p className="text-sm font-medium text-gray-900">{stageEventText(event.title, stages)}</p>
   }
   if (event.type === 'FILE') {
     return (
frontend/src/help/helpContent.ts
--- frontend/src/help/helpContent.ts
+++ frontend/src/help/helpContent.ts
@@ -41,7 +41,7 @@
       '서류제출 — ②목록접수 단계 이상에 도달한 기관 수',
       '권리확인 / 권리처리 — 전 기관 건수 합계(완료 / 전체)와 진척률',
       '보고서 작성 — ⑫보고서 작성 단계에 도달한 기관 수',
-      '완료 — ⑪법률검토 최종완료 이상에 도달한 기관 수',
+      '완료 — ⑬보고서 제출 완료에 도달한 기관 수(최종 보고서가 올라온 기관만 센다)',
     ],
     cautions: [
       '미해결 RE는 아직 데이터가 없어 항상 0으로 표시된다(자리만 잡아 둔 상태).',
@@ -56,7 +56,7 @@
       {
         src: '/help/dashboard-kanban.png',
         alt: '단계 미지정 컬럼과 ①신청~ 컬럼들이 있는 칸반',
-        caption: '컬럼은 단계 미지정 + ①~⑫ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다',
+        caption: '컬럼은 단계 미지정 + ①~⑬ 순서다. 오른쪽으로 스크롤하면 나머지 단계가 나온다',
       },
       {
         src: '/help/dashboard-drag-warning.png',
@@ -160,7 +160,7 @@
 
   'org.stage': {
     title: '진행단계 변경',
-    summary: '12단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.',
+    summary: '13단계 업무 진행 상태를 사람이 직접 바꾼다. 자동으로 넘어가는 일은 없다.',
     images: [
       {
         src: '/help/org-stage.png',
 
frontend/src/stages.ts (deleted)
--- frontend/src/stages.ts
@@ -1,42 +0,0 @@
-// 진행단계 12개 라벨. 백엔드는 숫자(1..12)만 저장하고 라벨은 여기서만 관리한다 -
-// OrgOverview(업무단계 현황 스트립·진행단계 변경 모달)와 Timeline(단계 변경 이벤트)이
-// 같은 배열을 가져다 쓴다. 인덱스 0이 1단계다.
-export const STAGE_LABELS: string[] = [
-  '신청',
-  '목록접수',
-  '예비검토',
-  '변호사 배당',
-  '법률검토(권리확인)',
-  'RE:확인',
-  '법률검토(권리확인) 완료',
-  '법률검토(권리처리)',
-  'RE:처리',
-  '법률검토(권리처리) 완료',
-  '법률검토 최종완료',
-  '보고서 작성',
-]
-
-/** 1..12 단계 번호를 라벨로 바꾼다. 범위 밖이면 번호를 그대로 문자열로 돌려준다. */
-export function stageLabel(stage: number): string {
-  return STAGE_LABELS[stage - 1] ?? String(stage)
-}
-
-/**
- * 12단계를 업무 성격으로 묶은 구간. 대시보드의 "업무 구간" 필터와 칸반 카드 모양
- * (권리확인 구간은 확인 진척 막대 1줄, 권리처리 구간은 확인+처리 2줄)이 이 묶음을 쓴다.
- */
-export const STAGE_GROUPS: { key: string; label: string; stages: number[] }[] = [
-  { key: 'intake', label: '접수', stages: [1, 2, 3] },
-  { key: 'assign', label: '배당', stages: [4] },
-  { key: 'check', label: '권리확인', stages: [5, 6, 7] },
-  { key: 'process', label: '권리처리', stages: [8, 9, 10] },
-  { key: 'closing', label: '마감', stages: [11, 12] },
-]
-
-/** 단계 번호가 속한 구간 key. 범위 밖이거나 null이면 null. */
-export function stageGroupKey(stage: number | null): string | null {
-  if (stage === null) {
-    return null
-  }
-  return STAGE_GROUPS.find((group) => group.stages.includes(stage))?.key ?? null
-}
src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
@@ -1,5 +1,6 @@
 package kr.itn.itnhub.dashboard;
 
+import kr.itn.itnhub.code.CodeService;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
@@ -8,21 +9,36 @@
  * 대시보드 화면 하나가 쓰는 서비스. 집계 쿼리는 {@link DashboardMapper#findOrgRows()} 한 번이고,
  * 상단 KPI는 그 결과를 Java에서 접어 만든다 - 같은 원본에서 계산해야 타일 숫자와 아래 표/칸반
  * 숫자가 어긋나지 않는다(쿼리를 따로 두면 두 결과 사이에 시차가 생긴다).
+ *
+ * <p>어느 단계부터 각 KPI에 세는지는 코드표(STAGE의 kpiFrom 속성)에서 온다. 예전에는 여기
+ * 상수 세 개로 박혀 있어서, 기준이 바뀔 때마다 배포가 필요했다.</p>
  */
 @Service
 public class DashboardService {
 
-    /** ②목록접수. 시안 KPI "서류제출"의 기준으로 쓴다. */
-    private static final int STAGE_DOC_SUBMITTED = 2;
-    /** ⑪법률검토 최종완료. 시안 KPI "완료"의 기준. */
-    private static final int STAGE_COMPLETED = 11;
-    /** ⑫보고서 작성. */
-    private static final int STAGE_REPORT = 12;
+    /** KPI 이름. 코드표의 kpiFrom 값과 맞물린다. */
+    static final String KPI_DOC_SUBMITTED = "docSubmitted";
+    static final String KPI_COMPLETED = "completed";
+    static final String KPI_REPORT = "reportWriting";
 
     private final DashboardMapper mapper;
+    private final CodeService codeService;
 
-    public DashboardService(DashboardMapper mapper) {
+    public DashboardService(DashboardMapper mapper, CodeService codeService) {
         this.mapper = mapper;
+        this.codeService = codeService;
+    }
+
+    /**
+     * 이 KPI에 집계하기 시작하는 단계 번호. 코드표에 지정이 없으면 아무 기관도 세지 않도록
+     * {@link Integer#MAX_VALUE}를 준다 - 0을 주면 단계가 있는 모든 기관이 잘못 집계된다.
+     */
+    private int thresholdOf(String kpi) {
+        return codeService.group(kr.itn.itnhub.code.Codes.STAGE).stream()
+                .filter(code -> kpi.equals(code.attr(kr.itn.itnhub.code.Codes.ATTR_KPI_FROM)))
+                .mapToInt(code -> Integer.parseInt(code.code()))
+                .min()
+                .orElse(Integer.MAX_VALUE);
     }
 
     public DashboardResponse dashboard() {
@@ -31,6 +47,10 @@
     }
 
     DashboardSummary summarize(List<DashboardOrgRow> rows) {
+        int docSubmittedFrom = thresholdOf(KPI_DOC_SUBMITTED);
+        int completedFrom = thresholdOf(KPI_COMPLETED);
+        int reportFrom = thresholdOf(KPI_REPORT);
+
         int docSubmitted = 0;
         int reviewTotal = 0;
         int reviewDone = 0;
@@ -51,13 +71,13 @@
             if (stage == null) {
                 continue;
             }
-            if (stage >= STAGE_DOC_SUBMITTED) {
+            if (stage >= docSubmittedFrom) {
                 docSubmitted++;
             }
-            if (stage >= STAGE_COMPLETED) {
+            if (stage >= completedFrom) {
                 completed++;
             }
-            if (stage >= STAGE_REPORT) {
+            if (stage >= reportFrom) {
                 reportWriting++;
             }
         }
src/main/java/kr/itn/itnhub/stage/StageService.java
--- src/main/java/kr/itn/itnhub/stage/StageService.java
+++ src/main/java/kr/itn/itnhub/stage/StageService.java
@@ -1,5 +1,7 @@
 package kr.itn.itnhub.stage;
 
+import kr.itn.itnhub.code.CodeService;
+import kr.itn.itnhub.code.Codes;
 import kr.itn.itnhub.org.OrgNotFoundException;
 import kr.itn.itnhub.org.OrgResponse;
 import kr.itn.itnhub.org.Organization;
@@ -17,10 +19,13 @@
 
     private final OrganizationMapper orgMapper;
     private final StageHistoryMapper historyMapper;
+    private final CodeService codeService;
 
-    public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper) {
+    public StageService(OrganizationMapper orgMapper, StageHistoryMapper historyMapper,
+                        CodeService codeService) {
         this.orgMapper = orgMapper;
         this.historyMapper = historyMapper;
+        this.codeService = codeService;
     }
 
     @Transactional
@@ -30,9 +35,13 @@
             throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
         }
 
+        // 단계 개수는 코드표에서 온다. 예전에는 12가 박혀 있어서 단계를 늘릴 때
+        // 여기와 메시지까지 같이 고쳐야 했다.
+        int lastStage = codeService.group(Codes.STAGE).size();
         Integer stage = request.stage();
-        if (stage == null || stage < 1 || stage > 12) {
-            throw new InvalidStageException("진행단계는 1에서 12 사이의 값이어야 합니다.");
+        if (stage == null || stage < 1 || stage > lastStage) {
+            throw new InvalidStageException(
+                    "진행단계는 1에서 " + lastStage + " 사이의 값이어야 합니다.");
         }
 
         orgMapper.updateStage(orgId, stage);
src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
--- src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
+++ src/test/java/kr/itn/itnhub/dashboard/DashboardControllerTest.java
@@ -85,6 +85,11 @@
                 orgId, seq, status);
     }
 
+    /**
+     * KPI 기준 단계는 코드표(STAGE의 kpiFrom)에서 온다. 요구사항 [2]로 완료 기준이
+     * ⑪법률검토 최종완료에서 ⑬보고서 제출 완료로 올라갔다 - 보고서가 실제로 올라온
+     * 기관만 완료로 센다. 그래서 11단계인 003은 더 이상 완료가 아니다.
+     */
     @Test
     void KPI는_전체_기관과_단계별_도달_기관수를_센다() throws Exception {
         mvc.perform(get("/api/dashboard"))
@@ -93,13 +98,23 @@
                 .andExpect(jsonPath("$.summary.applied").value(3))
                 // 서류제출 = 2단계 이상 (002, 003)
                 .andExpect(jsonPath("$.summary.docSubmitted").value(2))
-                // 완료 = 11단계 이상 (003)
-                .andExpect(jsonPath("$.summary.completed").value(1))
+                // 완료 = 13단계 이상 (없음)
+                .andExpect(jsonPath("$.summary.completed").value(0))
                 // 보고서 작성 = 12단계 이상 (없음)
                 .andExpect(jsonPath("$.summary.reportWriting").value(0));
     }
 
     @Test
+    void 완료는_보고서_제출까지_끝난_기관만_센다() throws Exception {
+        setStage(stage11Id, 13);
+
+        mvc.perform(get("/api/dashboard"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.summary.completed").value(1))
+                .andExpect(jsonPath("$.summary.reportWriting").value(1));
+    }
+
+    @Test
     void KPI의_권리확인_권리처리는_전_기관_건수_합계다() throws Exception {
         mvc.perform(get("/api/dashboard"))
                 .andExpect(status().isOk())
src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
--- src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
+++ src/test/java/kr/itn/itnhub/stage/StageControllerTest.java
@@ -82,19 +82,34 @@
         assertThat(mapper.findById(orgId).getStage()).isEqualTo(2);
     }
 
+    /**
+     * 상한은 코드표에 등록된 단계 수를 따른다. 요구사항 [11]로 13단계(보고서 제출 완료)가
+     * 생겼으므로 13은 이제 정상이고, 14부터 범위를 벗어난다.
+     */
     @Test
     void 범위를_벗어난_값은_400이다() throws Exception {
         mvc.perform(put("/api/orgs/{id}/stage", orgId)
                         .with(csrf())
                         .contentType(MediaType.APPLICATION_JSON)
-                        .content("{\"stage\": 13}"))
+                        .content("{\"stage\": 14}"))
                 .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.message").value(containsString("1에서 12")));
+                .andExpect(jsonPath("$.message").value(containsString("1에서 13")));
 
         assertThat(mapper.findById(orgId).getStage()).isNull();
     }
 
     @Test
+    void 마지막_단계인_13단계는_저장된다() throws Exception {
+        mvc.perform(put("/api/orgs/{id}/stage", orgId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"stage\": 13}"))
+                .andExpect(status().isOk());
+
+        assertThat(mapper.findById(orgId).getStage()).isEqualTo(13);
+    }
+
+    @Test
     void 하한_미만인_0단계는_400이다() throws Exception {
         mvc.perform(put("/api/orgs/{id}/stage", orgId)
                         .with(csrf())
Add a comment
List