이호영 이호영 07-28
feat: 프론트 codes 모듈 신설, 선택지를 서버에서 받아 쓰도록 연결
로그인 직후 /api/meta/codes를 한 번 불러 앱 전체가 나눠 쓴다. 화면마다 따로 부르면
선택지를 기다리느라 폼이 깜빡인다.

선택지를 못 받으면 화면을 아예 안 그리고 오류를 보여준다. 빈 드롭다운을 그려두면
거기서 저장할 때 값이 지워지기 때문이다.

색은 DB에 tone(의미 토큰)만 두고 Tailwind 클래스는 프론트가 붙인다. 조립식 클래스명은
빌드 시점에 지워지므로 맵을 풀어 썼다.

useStages()가 단계 수·라벨·원문자·구간·진행상태·KPI 임계값을 코드표에서 가져온다.
원문자는 저장하지 않고 번호에서 계산한다.

테스트는 CodeProvider에 codes를 직접 넘겨 서버 없이 돈다. 픽스처는 시드와 같은 값이라
화면 테스트가 실제 선택지로 검증된다.

아직 각 화면은 기존 하드코딩 배열을 쓴다. 화면 전환은 다음 커밋부터.

Co-Authored-By: Claude Opus 5 (1M context) 
@4a1d2c498b324b62b92c326c923abdb21ca4c5af
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -10,6 +10,7 @@
 import SeedUpload from './components/SeedUpload'
 import Sidebar from './components/Sidebar'
 import SystemPage from './components/SystemPage'
+import { CodeProvider } from './codes/CodeProvider'
 import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
 
 // 화면 4개: Dashboard(전체 기관 현황 - 좌측 기관 목록 없이 전폭으로 쓴다),
@@ -100,7 +101,10 @@
     }
   }
 
+  // 화면 선택지(진행단계·처리결과·공공누리유형 등)는 서버에서 한 번 받아 앱 전체가 나눠 쓴다.
+  // 로그인 화면에는 필요 없으므로 인증을 통과한 다음부터 감싼다.
   return (
+    <CodeProvider>
     <div className="flex h-screen">
       <Sidebar
         activeKey={page}
@@ -189,5 +193,6 @@
         </div>
       </div>
     </div>
+    </CodeProvider>
   )
 }
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -2,6 +2,23 @@
 
 export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' | 'OPERATOR' | 'ITN'
 
+/**
+ * 공통코드 한 건. 화면 선택지는 소스가 아니라 DB에서 온다.
+ *
+ * code는 실제로 저장되는 값이고 label은 화면 표시명이다. 둘이 다를 수 있다 -
+ * 권리확인 세부항목은 저장값이 길고 화면에는 짧은 형태로 나온다.
+ * attrs에는 코드별 부가 속성이 들어간다(색 토큰, 쓰이는 자리, 추천값 등).
+ */
+export interface Code {
+  code: string
+  label: string
+  sortOrder: number
+  attrs: Record<string, unknown>
+}
+
+/** 그룹ID -> 코드 목록(정렬순, 사용 중인 것만). */
+export type CodeMap = Record<string, Code[]>
+
 /** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */
 export interface Contact {
   id: number
@@ -149,6 +166,11 @@
   return request<Org[]>('/api/orgs')
 }
 
+/** 모든 화면 선택지를 한 번에 받는다. 로그인 직후 1회만 부른다. */
+export function getCodes(): Promise<CodeMap> {
+  return request<CodeMap>('/api/meta/codes')
+}
+
 export function updateAssignments(orgId: number, body: AssignmentInput): Promise<Org> {
   return request<Org>(`/api/orgs/${orgId}/assignments`, {
     method: 'PUT',
 
frontend/src/codes/CodeProvider.tsx (added)
+++ frontend/src/codes/CodeProvider.tsx
@@ -0,0 +1,71 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { getCodes, type CodeMap } from '../api/client'
+
+/**
+ * 화면 선택지를 서버에서 한 번 받아 앱 전체에 나눠준다.
+ *
+ * 화면마다 따로 부르지 않는 이유는 코드가 자주 안 바뀌고, 선택지를 기다리느라 폼이
+ * 깜빡이는 것을 피하기 위해서다. 로그인 화면에서는 필요 없으므로 인증 이후에 감싼다.
+ */
+const CodeContext = createContext<CodeMap | null>(null)
+
+type LoadState = 'loading' | 'ready' | 'error'
+
+/**
+ * @param codes 넘기면 서버를 부르지 않고 그 값을 그대로 쓴다. 테스트에서 화면 하나만
+ *   떼어 그릴 때 쓰라고 열어둔 자리다.
+ */
+export function CodeProvider({ children, codes: given }: { children: ReactNode; codes?: CodeMap }) {
+  const [codes, setCodes] = useState<CodeMap | null>(given ?? null)
+  const [state, setState] = useState<LoadState>(given ? 'ready' : 'loading')
+  const [message, setMessage] = useState('')
+
+  async function load() {
+    setState('loading')
+    try {
+      setCodes(await getCodes())
+      setState('ready')
+    } catch (e) {
+      setMessage((e as Error).message)
+      setState('error')
+    }
+  }
+
+  useEffect(() => {
+    if (!given) {
+      void load()
+    }
+  }, [given])
+
+  if (state === 'loading') {
+    return <div className="p-8 text-sm text-gray-500">불러오는 중…</div>
+  }
+
+  // 선택지를 못 받은 채로 화면을 그리면 빈 드롭다운이 뜨고, 거기서 저장하면 값이
+  // 지워진다. 그리지 않고 오류를 보여주는 편이 안전하다.
+  if (state === 'error' || !codes) {
+    return (
+      <div className="flex min-h-screen flex-col items-center justify-center gap-3 p-8 text-sm">
+        <p className="text-red-600">선택지 정보를 불러오지 못했습니다. {message}</p>
+        <button
+          type="button"
+          onClick={() => void load()}
+          className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+        >
+          다시 시도
+        </button>
+      </div>
+    )
+  }
+
+  return <CodeContext.Provider value={codes}>{children}</CodeContext.Provider>
+}
+
+/** Provider 밖에서 부르면 개발 중에 바로 알아채도록 예외를 던진다. */
+export function useCodeMap(): CodeMap {
+  const codes = useContext(CodeContext)
+  if (!codes) {
+    throw new Error('CodeProvider 안에서만 쓸 수 있습니다.')
+  }
+  return codes
+}
 
frontend/src/codes/codes.test.tsx (added)
+++ frontend/src/codes/codes.test.tsx
@@ -0,0 +1,203 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CodeProvider } from './CodeProvider'
+import { TEST_CODES, withCodes } from './fixtures'
+import { useStages } from './stage'
+import { badgeClass, barClass } from './tone'
+import { useCodeLabel, useCodes, useScopedCodes, useSplitByFirstRow } from './useCodes'
+
+const mocks = vi.hoisted(() => ({ getCodes: vi.fn() }))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  getCodes: mocks.getCodes,
+}))
+
+beforeEach(() => {
+  mocks.getCodes.mockReset()
+})
+
+describe('useCodes', () => {
+  it('그룹의 선택지를 정렬순서대로 준다', () => {
+    function Probe() {
+      return <span>{useCodes('REVIEW_RESULT').map((c) => c.label).join('|')}</span>
+    }
+    render(withCodes(<Probe />))
+
+    expect(
+      screen.getByText('신유형 개방|계약서 등 재확인|권리처리 추진|개방불가|권리처리 추진 미희망'),
+    ).toBeTruthy()
+  })
+
+  it('없는 그룹이면 빈 배열이라 화면이 죽지 않는다', () => {
+    function Probe() {
+      return <span data-testid="n">{useCodes('NO_SUCH_GROUP').length}</span>
+    }
+    render(withCodes(<Probe />))
+
+    expect(screen.getByTestId('n').textContent).toBe('0')
+  })
+
+  it('저장된 값의 표시명을 준다', () => {
+    function Probe() {
+      return <span>{useCodeLabel('CONTACT_CATEGORY', 'MJ')}</span>
+    }
+    render(withCodes(<Probe />))
+
+    // 엑셀에는 '주관기관'으로 오지만 화면에는 '문정원'으로 나와야 한다.
+    expect(screen.getByText('문정원')).toBeTruthy()
+  })
+
+  it('코드표에 없는 값이면 값 자체를 보여준다', () => {
+    function Probe() {
+      return <span>{useCodeLabel('CONTACT_METHOD', '문자')}</span>
+    }
+    render(withCodes(<Probe />))
+
+    // 과거 데이터가 빈칸으로 사라지는 것보다 원문이라도 보이는 편이 낫다.
+    expect(screen.getByText('문자')).toBeTruthy()
+  })
+})
+
+describe('공공누리 유형의 사용처 구분', () => {
+  function ScopeProbe({ scope }: { scope: string }) {
+    return <span>{useScopedCodes('KOGL_TYPE', scope).map((c) => c.code).join(',')}</span>
+  }
+
+  it('변호사 판정에는 보류가 있다', () => {
+    render(withCodes(<ScopeProbe scope="review" />))
+    expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,개방불가,보류')).toBeTruthy()
+  })
+
+  it('권리처리 판정에는 보류가 없다', () => {
+    render(withCodes(<ScopeProbe scope="process" />))
+    expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,개방불가')).toBeTruthy()
+  })
+
+  it('기존 부착 유형에는 조사표에서 온 값이 들어간다', () => {
+    render(withCodes(<ScopeProbe scope="prior" />))
+    expect(screen.getByText('0유형,1유형,2유형,3유형,4유형,유형 확인 필요,제4유형')).toBeTruthy()
+  })
+
+  it('라디오 첫 줄과 둘째 줄을 나눈다', () => {
+    function Probe() {
+      const { first, rest } = useSplitByFirstRow('KOGL_TYPE', 'review')
+      return (
+        <>
+          <span data-testid="first">{first.map((c) => c.code).join(',')}</span>
+          <span data-testid="rest">{rest.map((c) => c.code).join(',')}</span>
+        </>
+      )
+    }
+    render(withCodes(<Probe />))
+
+    expect(screen.getByTestId('first').textContent).toBe('0유형,개방불가,보류')
+    expect(screen.getByTestId('rest').textContent).toBe('1유형,2유형,3유형,4유형')
+  })
+})
+
+describe('useStages', () => {
+  function StageProbe() {
+    const stages = useStages()
+    return (
+      <>
+        <span data-testid="count">{stages.count}</span>
+        <span data-testid="label13">{stages.label(13)}</span>
+        <span data-testid="symbol13">{stages.symbol(13)}</span>
+        <span data-testid="symbol1">{stages.symbol(1)}</span>
+        <span data-testid="group5">{stages.groupKey(5)}</span>
+        <span data-testid="tone-assign">{stages.groupTone('assign')}</span>
+        <span data-testid="progress1">{stages.progressOf(1)}</span>
+        <span data-testid="progress7">{stages.progressOf(7)}</span>
+        <span data-testid="progress13">{stages.progressOf(13)}</span>
+        <span data-testid="completed-from">{stages.kpiThreshold('completed')}</span>
+        <span data-testid="counts12">{String(stages.countsToward('completed', 12))}</span>
+        <span data-testid="counts13">{String(stages.countsToward('completed', 13))}</span>
+      </>
+    )
+  }
+
+  it('단계 수와 라벨이 코드표에서 온다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('count').textContent).toBe('13')
+    expect(screen.getByTestId('label13').textContent).toBe('보고서 제출 완료')
+  })
+
+  it('원문자는 번호에서 계산한다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('symbol1').textContent).toBe('①')
+    expect(screen.getByTestId('symbol13').textContent).toBe('⑬')
+  })
+
+  it('진행상태는 1단계만 신청이고 13단계만 완료다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('progress1').textContent).toBe('applied')
+    expect(screen.getByTestId('progress7').textContent).toBe('active')
+    expect(screen.getByTestId('progress13').textContent).toBe('done')
+  })
+
+  it('완료 KPI는 보고서가 올라온 13단계부터 센다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('completed-from').textContent).toBe('13')
+    expect(screen.getByTestId('counts12').textContent).toBe('false')
+    expect(screen.getByTestId('counts13').textContent).toBe('true')
+  })
+
+  it('업무구간과 색이 코드표에서 온다', () => {
+    render(withCodes(<StageProbe />))
+
+    expect(screen.getByTestId('group5').textContent).toBe('check')
+    expect(screen.getByTestId('tone-assign').textContent).toBe('indigo')
+  })
+})
+
+describe('tone', () => {
+  it('의미 토큰을 실제 클래스로 바꾼다', () => {
+    expect(badgeClass('emerald')).toBe('bg-emerald-50 text-emerald-700')
+    expect(barClass('indigo')).toBe('bg-indigo-400')
+  })
+
+  it('모르는 토큰은 회색으로 떨어뜨린다', () => {
+    // 코드 관리 화면에서 오타가 들어가도 색만 밋밋해질 뿐 항목이 사라지면 안 된다.
+    expect(badgeClass('없는색')).toBe('bg-gray-100 text-gray-700')
+    expect(barClass(null)).toBe('bg-gray-300')
+  })
+})
+
+describe('CodeProvider', () => {
+  it('서버에서 선택지를 받아 아래 화면에 넘긴다', async () => {
+    mocks.getCodes.mockResolvedValue(TEST_CODES)
+
+    function Probe() {
+      return <span>{useCodes('PROCESS_STATUS').map((c) => c.label).join(',')}</span>
+    }
+    render(
+      <CodeProvider>
+        <Probe />
+      </CodeProvider>,
+    )
+
+    await waitFor(() => expect(screen.getByText('미처리,처리완료')).toBeTruthy())
+  })
+
+  it('선택지를 못 받으면 화면을 그리지 않고 오류를 보여준다', async () => {
+    mocks.getCodes.mockRejectedValue(new Error('서버 오류'))
+
+    function Probe() {
+      return <span>그려지면 안 됨</span>
+    }
+    render(
+      <CodeProvider>
+        <Probe />
+      </CodeProvider>,
+    )
+
+    // 빈 드롭다운으로 저장하면 값이 지워지므로 아예 안 그리는 편이 안전하다.
+    await waitFor(() => expect(screen.getByText(/선택지 정보를 불러오지 못했습니다/)).toBeTruthy())
+    expect(screen.queryByText('그려지면 안 됨')).toBeNull()
+  })
+})
 
frontend/src/codes/fixtures.tsx (added)
+++ frontend/src/codes/fixtures.tsx
@@ -0,0 +1,120 @@
+import type { ReactElement } from 'react'
+import type { Code, CodeMap } from '../api/client'
+import { CodeProvider } from './CodeProvider'
+
+/**
+ * 테스트용 코드표. V13~V15 시드와 같은 값이라 화면 테스트가 실제 선택지로 돌아간다.
+ *
+ * 서버 응답을 흉내 내는 것이므로 비활성 코드는 들어 있지 않다(서버가 활성만 내려준다).
+ * 시드가 바뀌면 여기도 같이 고쳐야 한다 - 어긋나면 화면 테스트가 실제와 다른 선택지로
+ * 통과해버린다.
+ */
+function code(c: string, label: string, sortOrder: number, attrs: Record<string, unknown> = {}): Code {
+  return { code: c, label, sortOrder, attrs }
+}
+
+export const TEST_CODES: CodeMap = {
+  STAGE: [
+    code('1', '신청', 1, { groupKey: 'intake', progress: 'applied' }),
+    code('2', '목록접수', 2, { groupKey: 'intake', progress: 'active', kpiFrom: 'docSubmitted' }),
+    code('3', '예비검토', 3, { groupKey: 'intake', progress: 'active' }),
+    code('4', '변호사 배당', 4, { groupKey: 'assign', progress: 'active' }),
+    code('5', '법률검토(권리확인)', 5, { groupKey: 'check', progress: 'active' }),
+    code('6', 'RE:확인', 6, { groupKey: 'check', progress: 'active' }),
+    code('7', '법률검토(권리확인) 완료', 7, { groupKey: 'check', progress: 'active' }),
+    code('8', '법률검토(권리처리)', 8, { groupKey: 'process', progress: 'active' }),
+    code('9', 'RE:처리', 9, { groupKey: 'process', progress: 'active' }),
+    code('10', '법률검토(권리처리) 완료', 10, { groupKey: 'process', progress: 'active' }),
+    code('11', '법률검토 최종완료', 11, { groupKey: 'closing', progress: 'active' }),
+    code('12', '보고서 작성', 12, { groupKey: 'closing', progress: 'active', kpiFrom: 'reportWriting' }),
+    code('13', '보고서 제출 완료', 13, { groupKey: 'closing', progress: 'done', kpiFrom: 'completed' }),
+  ],
+  STAGE_GROUP: [
+    code('intake', '접수', 1, { tone: 'slate' }),
+    code('assign', '배당', 2, { tone: 'indigo' }),
+    code('check', '권리확인', 3, { tone: 'emerald' }),
+    code('process', '권리처리', 4, { tone: 'amber' }),
+    code('closing', '마감', 5, { tone: 'violet' }),
+  ],
+  REVIEW_MAJOR: [
+    code('만료 저작물', '만료 저작물', 1, { suggest: '신유형 개방', hasMinor: false }),
+    code('업무상 저작물', '업무상 저작물', 2, { suggest: '신유형 개방', hasMinor: false }),
+    code('제3자 권리', '제3자 권리', 3, { hasMinor: true }),
+    code('개인정보 포함', '개인정보 포함', 4, { suggest: '개방불가', hasMinor: false }),
+  ],
+  REVIEW_MINOR: [
+    code('1. 원시적 권리 전부 보유', '1. 원시적 권리 전부 보유', 1, { suggest: '신유형 개방' }),
+    code('2. 후천적 권리 전부 보유(계약서 확인 필요)', '2. 후천적 권리 전부 보유', 2, {
+      note: '(계약에 의한 전부 양수)',
+      suggest: '계약서 등 재확인',
+    }),
+    code('3. 권리 일부(공동) 보유', '3. 권리 일부(공동) 보유', 3, {
+      note: '(보도자료·제3자저작물, 계약에 의한 전부/공동 보유 등)',
+      suggest: '권리처리 추진',
+      allowed: ['권리처리 추진', '권리처리 추진 미희망'],
+    }),
+    code('4. 권리 미보유', '4. 권리 미보유', 4, {
+      note: '(공모전 수상작 등)',
+      suggest: '권리처리 추진',
+      allowed: ['권리처리 추진', '권리처리 추진 미희망'],
+    }),
+    code('초상권 포함', '초상권 포함', 5, {
+      suggest: '권리처리 추진',
+      allowed: ['권리처리 추진', '권리처리 추진 미희망', '개방불가'],
+    }),
+  ],
+  REVIEW_RESULT: [
+    code('신유형 개방', '신유형 개방', 1, { tone: 'emerald', openable: 'Y' }),
+    code('계약서 등 재확인', '계약서 등 재확인', 2, { tone: 'amber' }),
+    code('권리처리 추진', '권리처리 추진', 3, { tone: 'blue', needsProcessing: true, koglSet: '보류' }),
+    code('개방불가', '개방불가', 4, { tone: 'red', needsProcessing: false, koglSet: '개방불가' }),
+    code('권리처리 추진 미희망', '권리처리 추진 미희망', 5, {
+      tone: 'slate',
+      needsProcessing: false,
+      koglCopiesPrior: true,
+    }),
+  ],
+  KOGL_TYPE: [
+    code('0유형', '0유형', 1, { scopes: ['review', 'process', 'prior'], firstRow: ['review', 'process'] }),
+    code('1유형', '1유형', 2, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('2유형', '2유형', 3, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('3유형', '3유형', 4, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('4유형', '4유형', 5, { scopes: ['review', 'process', 'prior'], firstRow: [] }),
+    code('개방불가', '개방불가', 6, { scopes: ['review', 'process'], firstRow: ['review', 'process'] }),
+    code('보류', '보류', 7, { scopes: ['review'], firstRow: ['review'] }),
+    code('유형 확인 필요', '유형 확인 필요', 8, { scopes: ['prior'], firstRow: [], surveyOnly: true }),
+    code('제4유형', '제4유형', 9, { scopes: ['prior'], firstRow: [], surveyOnly: true, sameAs: '4유형' }),
+  ],
+  PROCESS_STATUS: [
+    code('미처리', '미처리', 1, { done: false }),
+    code('처리완료', '처리완료', 2, { done: true, stampsProcessedAt: true }),
+  ],
+  CONTRACT_DOC: [
+    code('양도계약서', '양도계약서', 1),
+    code('제안요청서', '제안요청서', 2),
+    code('초상이용동의서', '초상이용동의서', 3),
+    code('공공누리동의서', '공공누리동의서', 4),
+    code('공문', '공문', 5),
+    code('기타', '기타', 6, { freeText: true }),
+  ],
+  CONTACT_METHOD: [
+    code('전화', '전화', 1),
+    code('메일', '메일', 2),
+    code('방문', '방문', 3),
+    code('기타', '기타', 4),
+  ],
+  CONTACT_CATEGORY: [
+    code('APPLICANT', '신청기관', 1, { tone: 'blue', importAlias: '신청기관' }),
+    code('MJ', '문정원', 2, { tone: 'emerald', importAlias: '주관기관' }),
+    code('LAWYER', '변호사', 3, { tone: 'violet', importAlias: '변호사' }),
+    code('OPERATOR', '수행기관', 4, { tone: 'sky', importAlias: '수행기관' }),
+    code('ITN', '아이티앤 담당자', 5, { tone: 'orange', importAlias: '아이티앤 담당자' }),
+  ],
+  ATTACHMENT_YN: [code('있음', '있음', 1), code('없음', '없음', 2)],
+  KOGL_ATTACHED: [code('부착', '부착', 1), code('미부착', '미부착', 2)],
+}
+
+/** 화면 하나를 코드표와 함께 그린다. 서버를 부르지 않는다. */
+export function withCodes(ui: ReactElement, codes: CodeMap = TEST_CODES): ReactElement {
+  return <CodeProvider codes={codes}>{ui}</CodeProvider>
+}
 
frontend/src/codes/stage.ts (added)
+++ frontend/src/codes/stage.ts
@@ -0,0 +1,89 @@
+import { useMemo } from 'react'
+import type { Code } from '../api/client'
+import { useCodes } from './useCodes'
+
+/** 원문자 ①(U+2460)부터 ⑳까지. 그 밖은 번호를 그대로 쓴다. */
+const CIRCLED_BASE = 0x2460
+const CIRCLED_MAX = 20
+
+/**
+ * 진행단계를 다루는 헬퍼. 단계 개수·라벨·구간·진행상태·KPI 임계값이 전부 코드표에서 온다.
+ *
+ * 예전에는 stages.ts에 12개 라벨 배열이 박혀 있어서 단계를 하나 늘리려면 백엔드 4곳,
+ * 프론트 8곳을 같이 고쳐야 했다. 이제 코드표 한 줄이면 된다.
+ */
+export interface Stages {
+  /** 등록된 단계 수. 지금은 13. */
+  count: number
+  /** [1, 2, ... count] */
+  numbers: number[]
+  /** 화면 표시명. 모르는 번호면 번호를 문자열로 돌려준다. */
+  label(stage: number): string
+  /** ①②③… 20을 넘으면 번호 문자열. */
+  symbol(stage: number): string
+  /** 단계가 속한 업무구간 key. 미지정(null)이면 null. */
+  groupKey(stage: number | null): string | null
+  /** 업무구간 목록(정렬순). 칸반 색과 필터가 쓴다. */
+  groups: Code[]
+  /** 업무구간의 tone. 모르면 null. */
+  groupTone(key: string | null): string | null
+  /** 진행상태 - 'applied' | 'active' | 'done'. 단계 미지정이면 null. */
+  progressOf(stage: number | null): string | null
+  /** 이 KPI에 집계하기 시작하는 단계 번호. 지정이 없으면 null. */
+  kpiThreshold(kpi: string): number | null
+  /** 해당 단계가 이 KPI 집계 대상인지(임계값 이상인지). */
+  countsToward(kpi: string, stage: number | null): boolean
+}
+
+export function useStages(): Stages {
+  const stageCodes = useCodes('STAGE')
+  const groups = useCodes('STAGE_GROUP')
+
+  return useMemo(() => {
+    const byNumber = new Map<number, Code>()
+    for (const code of stageCodes) {
+      byNumber.set(Number(code.code), code)
+    }
+    const numbers = stageCodes.map((code) => Number(code.code)).sort((a, b) => a - b)
+
+    function attrOf(stage: number | null, key: string): string | null {
+      if (stage === null) {
+        return null
+      }
+      const value = byNumber.get(stage)?.attrs[key]
+      return value == null ? null : String(value)
+    }
+
+    function kpiThreshold(kpi: string): number | null {
+      for (const [number, code] of byNumber) {
+        if (code.attrs.kpiFrom === kpi) {
+          return number
+        }
+      }
+      return null
+    }
+
+    return {
+      count: numbers.length,
+      numbers,
+      label: (stage) => byNumber.get(stage)?.label ?? String(stage),
+      symbol: (stage) =>
+        stage >= 1 && stage <= CIRCLED_MAX
+          ? String.fromCharCode(CIRCLED_BASE + stage - 1)
+          : String(stage),
+      groupKey: (stage) => attrOf(stage, 'groupKey'),
+      groups,
+      groupTone: (key) => {
+        const found = groups.find((group) => group.code === key)
+        const tone = found?.attrs.tone
+        return tone == null ? null : String(tone)
+      },
+      progressOf: (stage) => attrOf(stage, 'progress'),
+      kpiThreshold,
+      countsToward: (kpi, stage) => {
+        const from = kpiThreshold(kpi)
+        return from !== null && stage !== null && stage >= from
+      },
+    }
+  }, [stageCodes, groups])
+}
 
frontend/src/codes/tone.ts (added)
+++ frontend/src/codes/tone.ts
@@ -0,0 +1,52 @@
+/**
+ * 코드의 tone(의미 토큰)을 실제 Tailwind 클래스로 바꾼다.
+ *
+ * DB에는 'emerald' 같은 이름표만 저장하고 클래스는 여기서 붙인다. DB에 Tailwind 클래스를
+ * 넣으면 화면 스타일을 손볼 때마다 DB를 고쳐야 하기 때문이다.
+ *
+ * 맵을 풀어 쓴 이유는 Tailwind가 `bg-${tone}-50` 같은 조립식 클래스명을 빌드 시점에
+ * 못 찾아 지워버리기 때문이다. 반드시 문자열 전체가 소스에 보여야 한다.
+ */
+
+/** 뱃지: 옅은 배경 + 진한 글씨. 처리결과·담당자 구분이 쓴다. */
+const BADGE: Record<string, string> = {
+  slate: 'bg-slate-100 text-slate-700',
+  gray: 'bg-gray-100 text-gray-700',
+  blue: 'bg-blue-50 text-blue-700',
+  sky: 'bg-sky-50 text-sky-700',
+  indigo: 'bg-indigo-50 text-indigo-700',
+  violet: 'bg-violet-50 text-violet-700',
+  emerald: 'bg-emerald-50 text-emerald-700',
+  amber: 'bg-amber-50 text-amber-700',
+  orange: 'bg-orange-50 text-orange-700',
+  red: 'bg-red-50 text-red-700',
+}
+
+/** 색 띠: 칸반 컬럼 상단에 그리는 한 줄. 업무구간이 쓴다. */
+const BAR: Record<string, string> = {
+  slate: 'bg-slate-400',
+  gray: 'bg-gray-400',
+  blue: 'bg-blue-400',
+  sky: 'bg-sky-400',
+  indigo: 'bg-indigo-400',
+  violet: 'bg-violet-400',
+  emerald: 'bg-emerald-400',
+  amber: 'bg-amber-400',
+  orange: 'bg-orange-400',
+  red: 'bg-red-400',
+}
+
+const BADGE_FALLBACK = 'bg-gray-100 text-gray-700'
+const BAR_FALLBACK = 'bg-gray-300'
+
+/**
+ * 모르는 tone이 와도 화면이 깨지지 않게 회색으로 떨어뜨린다 - 코드 관리 화면에서
+ * 오타가 들어가도 색만 밋밋해질 뿐 항목이 사라지지는 않아야 한다.
+ */
+export function badgeClass(tone: string | null | undefined): string {
+  return (tone && BADGE[tone]) || BADGE_FALLBACK
+}
+
+export function barClass(tone: string | null | undefined): string {
+  return (tone && BAR[tone]) || BAR_FALLBACK
+}
 
frontend/src/codes/useCodes.ts (added)
+++ frontend/src/codes/useCodes.ts
@@ -0,0 +1,69 @@
+import { useCodeMap } from './CodeProvider'
+import type { Code } from '../api/client'
+
+/**
+ * 그룹의 선택지. 서버가 정렬순서대로, 사용 중인 것만 내려준다.
+ * 없는 그룹이면 빈 배열이라 화면이 터지지는 않는다.
+ */
+export function useCodes(groupId: string): Code[] {
+  return useCodeMap()[groupId] ?? []
+}
+
+/**
+ * 쓰이는 자리(scope)로 거른 선택지. 공공누리 유형이 이걸 쓴다 -
+ * 같은 코드체계라도 변호사 판정(review)·권리처리 판정(process)·기존 부착(prior)에
+ * 나오는 목록이 서로 다르다.
+ */
+export function useScopedCodes(groupId: string, scope: string): Code[] {
+  return useCodes(groupId).filter((code) => scopes(code).includes(scope))
+}
+
+/** 라디오 첫 줄에 놓을 것과 둘째 줄에 놓을 것으로 나눈다. */
+export function useSplitByFirstRow(
+  groupId: string,
+  scope: string,
+): { first: Code[]; rest: Code[] } {
+  const codes = useScopedCodes(groupId, scope)
+  return {
+    first: codes.filter((code) => firstRow(code).includes(scope)),
+    rest: codes.filter((code) => !firstRow(code).includes(scope)),
+  }
+}
+
+/**
+ * 저장된 값의 화면 표시명. 코드표에 없는 값이면 값 자체를 그대로 돌려준다 -
+ * 과거 데이터가 화면에서 빈칸으로 사라지는 것보다 원문이라도 보이는 편이 낫다.
+ */
+export function useCodeLabel(groupId: string, code: string | null | undefined): string {
+  const found = useCodes(groupId).find((candidate) => candidate.code === code)
+  return found?.label ?? code ?? ''
+}
+
+/** 코드 한 건. 없으면 undefined. */
+export function useCode(groupId: string, code: string | null | undefined): Code | undefined {
+  return useCodes(groupId).find((candidate) => candidate.code === code)
+}
+
+function stringList(code: Code, key: string): string[] {
+  const raw = code.attrs[key]
+  return Array.isArray(raw) ? (raw as string[]) : []
+}
+
+export function scopes(code: Code): string[] {
+  return stringList(code, 'scopes')
+}
+
+export function firstRow(code: Code): string[] {
+  return stringList(code, 'firstRow')
+}
+
+/** attrs의 문자열 속성. 없으면 null. */
+export function attr(code: Code | undefined, key: string): string | null {
+  const value = code?.attrs[key]
+  return value == null ? null : String(value)
+}
+
+/** attrs의 불리언 속성. 없으면 false. */
+export function flag(code: Code | undefined, key: string): boolean {
+  return code?.attrs[key] === true
+}
Add a comment
List