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
... | ... | @@ -1,6 +1,7 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ContactsPage from './ContactsPage' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { Contact } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -46,7 +47,7 @@ |
| 46 | 47 |
it('담당자 목록을 구분·성명 등과 함께 보여준다', async () => {
|
| 47 | 48 |
mocks.getContacts.mockResolvedValue([contact()]) |
| 48 | 49 |
|
| 49 |
- render(<ContactsPage />) |
|
| 50 |
+ render(withCodes(<ContactsPage />)) |
|
| 50 | 51 |
|
| 51 | 52 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 52 | 53 |
expect(screen.getByRole('cell', { name: '신청기관' })).toBeTruthy()
|
... | ... | @@ -59,7 +60,7 @@ |
| 59 | 60 |
it('담당자가 없으면 안내 문구를 보여준다', async () => {
|
| 60 | 61 |
mocks.getContacts.mockResolvedValue([]) |
| 61 | 62 |
|
| 62 |
- render(<ContactsPage />) |
|
| 63 |
+ render(withCodes(<ContactsPage />)) |
|
| 63 | 64 |
|
| 64 | 65 |
await waitFor(() => {
|
| 65 | 66 |
expect(screen.getByText('등록된 담당자가 없습니다.')).toBeTruthy()
|
... | ... | @@ -72,7 +73,7 @@ |
| 72 | 73 |
contact({ id: 2, name: '이변호', category: 'LAWYER', affiliation: null, deptName: null }),
|
| 73 | 74 |
]) |
| 74 | 75 |
|
| 75 |
- render(<ContactsPage />) |
|
| 76 |
+ render(withCodes(<ContactsPage />)) |
|
| 76 | 77 |
|
| 77 | 78 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 78 | 79 |
|
... | ... | @@ -88,7 +89,7 @@ |
| 88 | 89 |
contact({ id: 2, name: '홍수행', category: 'OPERATOR', affiliation: '수행사', deptName: '운영팀' }),
|
| 89 | 90 |
]) |
| 90 | 91 |
|
| 91 |
- render(<ContactsPage />) |
|
| 92 |
+ render(withCodes(<ContactsPage />)) |
|
| 92 | 93 |
|
| 93 | 94 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 94 | 95 |
|
... | ... | @@ -101,7 +102,7 @@ |
| 101 | 102 |
it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
|
| 102 | 103 |
mocks.getContacts.mockResolvedValue([]) |
| 103 | 104 |
|
| 104 |
- render(<ContactsPage />) |
|
| 105 |
+ render(withCodes(<ContactsPage />)) |
|
| 105 | 106 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
| 106 | 107 |
|
| 107 | 108 |
fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
|
... | ... | @@ -118,7 +119,7 @@ |
| 118 | 119 |
mocks.getContacts.mockResolvedValueOnce([]).mockResolvedValueOnce([contact({ name: '홍길동' })])
|
| 119 | 120 |
mocks.createContact.mockResolvedValue(contact({ name: '홍길동' }))
|
| 120 | 121 |
|
| 121 |
- render(<ContactsPage />) |
|
| 122 |
+ render(withCodes(<ContactsPage />)) |
|
| 122 | 123 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalledTimes(1)) |
| 123 | 124 |
|
| 124 | 125 |
fireEvent.click(screen.getByRole('button', { name: '담당자 추가' }))
|
... | ... | @@ -145,7 +146,7 @@ |
| 145 | 146 |
mocks.getContacts.mockResolvedValue([contact()]) |
| 146 | 147 |
mocks.updateContactInfo.mockResolvedValue(contact({ phone: '02-0000-0000' }))
|
| 147 | 148 |
|
| 148 |
- render(<ContactsPage />) |
|
| 149 |
+ render(withCodes(<ContactsPage />)) |
|
| 149 | 150 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 150 | 151 |
|
| 151 | 152 |
fireEvent.click(screen.getByRole('button', { name: '수정' }))
|
... | ... | @@ -169,7 +170,7 @@ |
| 169 | 170 |
mocks.deleteContact.mockResolvedValue(undefined) |
| 170 | 171 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) |
| 171 | 172 |
|
| 172 |
- render(<ContactsPage />) |
|
| 173 |
+ render(withCodes(<ContactsPage />)) |
|
| 173 | 174 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 174 | 175 |
|
| 175 | 176 |
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
|
... | ... | @@ -186,7 +187,7 @@ |
| 186 | 187 |
mocks.getContacts.mockResolvedValue([contact()]) |
| 187 | 188 |
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) |
| 188 | 189 |
|
| 189 |
- render(<ContactsPage />) |
|
| 190 |
+ render(withCodes(<ContactsPage />)) |
|
| 190 | 191 |
await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
| 191 | 192 |
|
| 192 | 193 |
fireEvent.click(screen.getByRole('button', { name: '삭제' }))
|
... | ... | @@ -201,7 +202,7 @@ |
| 201 | 202 |
mocks.getContacts.mockResolvedValue([]) |
| 202 | 203 |
mocks.importMembers.mockResolvedValue({ created: 2, updated: 1, skipped: 1, assigned: 1 })
|
| 203 | 204 |
|
| 204 |
- render(<ContactsPage />) |
|
| 205 |
+ render(withCodes(<ContactsPage />)) |
|
| 205 | 206 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
| 206 | 207 |
|
| 207 | 208 |
const file = new File(['dummy'], 'members.xlsx', {
|
... | ... | @@ -220,7 +221,7 @@ |
| 220 | 221 |
mocks.getContacts.mockResolvedValue([]) |
| 221 | 222 |
mocks.importMembers.mockRejectedValue(new Error('파일 형식이 올바르지 않습니다.'))
|
| 222 | 223 |
|
| 223 |
- render(<ContactsPage />) |
|
| 224 |
+ render(withCodes(<ContactsPage />)) |
|
| 224 | 225 |
await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
| 225 | 226 |
|
| 226 | 227 |
const file = new File(['dummy'], 'members.xlsx', {
|
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
... | ... | @@ -10,32 +10,12 @@ |
| 10 | 10 |
type ContactInput, |
| 11 | 11 |
type MemberImportReport, |
| 12 | 12 |
} from '../api/client' |
| 13 |
+import { useCodes } from '../codes/useCodes'
|
|
| 14 |
+import { badgeClass } from '../codes/tone'
|
|
| 13 | 15 |
import HelpButton from '../help/HelpButton' |
| 14 | 16 |
|
| 15 |
-const CATEGORY_LABELS: Record<ContactCategory, string> = {
|
|
| 16 |
- APPLICANT: '신청기관', |
|
| 17 |
- MJ: '문정원', |
|
| 18 |
- LAWYER: '변호사', |
|
| 19 |
- OPERATOR: '수행기관', |
|
| 20 |
- ITN: '아이티앤 담당자', |
|
| 21 |
-} |
|
| 22 |
- |
|
| 23 |
-const CATEGORY_BADGE_STYLES: Record<ContactCategory, string> = {
|
|
| 24 |
- APPLICANT: 'bg-blue-50 text-blue-700', |
|
| 25 |
- MJ: 'bg-emerald-50 text-emerald-700', |
|
| 26 |
- LAWYER: 'bg-violet-50 text-violet-700', |
|
| 27 |
- OPERATOR: 'bg-sky-50 text-sky-700', |
|
| 28 |
- ITN: 'bg-orange-50 text-orange-700', |
|
| 29 |
-} |
|
| 30 |
- |
|
| 31 |
-const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
|
|
| 32 |
- { key: 'ALL', label: '전체' },
|
|
| 33 |
- { key: 'APPLICANT', label: '신청기관' },
|
|
| 34 |
- { key: 'MJ', label: '문정원' },
|
|
| 35 |
- { key: 'LAWYER', label: '변호사' },
|
|
| 36 |
- { key: 'OPERATOR', label: '수행기관' },
|
|
| 37 |
- { key: 'ITN', label: '아이티앤 담당자' },
|
|
| 38 |
-] |
|
| 17 |
+// 구분 5종(표시명·뱃지색·필터·드롭다운)은 예전에 이 파일 안에서만 네 번 따로 적혀 있었다. |
|
| 18 |
+// 지금은 전부 코드표(CONTACT_CATEGORY)에서 온다. |
|
| 39 | 19 |
|
| 40 | 20 |
function emptyForm(contact: Contact | null): ContactInput {
|
| 41 | 21 |
return {
|
... | ... | @@ -61,6 +41,7 @@ |
| 61 | 41 |
const [form, setForm] = useState<ContactInput>(emptyForm(editing)) |
| 62 | 42 |
const [busy, setBusy] = useState(false) |
| 63 | 43 |
const [error, setError] = useState<string | null>(null) |
| 44 |
+ const categories = useCodes('CONTACT_CATEGORY')
|
|
| 64 | 45 |
|
| 65 | 46 |
useEffect(() => {
|
| 66 | 47 |
function onKeyDown(e: KeyboardEvent) {
|
... | ... | @@ -125,11 +106,11 @@ |
| 125 | 106 |
onChange={(e) => setField('category', e.target.value)}
|
| 126 | 107 |
className="rounded-md border border-gray-300 px-2 py-1.5" |
| 127 | 108 |
> |
| 128 |
- <option value="APPLICANT">신청기관</option> |
|
| 129 |
- <option value="MJ">문정원</option> |
|
| 130 |
- <option value="LAWYER">변호사</option> |
|
| 131 |
- <option value="OPERATOR">수행기관</option> |
|
| 132 |
- <option value="ITN">아이티앤 담당자</option> |
|
| 109 |
+ {categories.map((category) => (
|
|
| 110 |
+ <option key={category.code} value={category.code}>
|
|
| 111 |
+ {category.label}
|
|
| 112 |
+ </option> |
|
| 113 |
+ ))} |
|
| 133 | 114 |
</select> |
| 134 | 115 |
</div> |
| 135 | 116 |
<div className="flex flex-col gap-1"> |
... | ... | @@ -240,6 +221,20 @@ |
| 240 | 221 |
const [importReport, setImportReport] = useState<MemberImportReport | null>(null) |
| 241 | 222 |
const [importError, setImportError] = useState<string | null>(null) |
| 242 | 223 |
|
| 224 |
+ const categories = useCodes('CONTACT_CATEGORY')
|
|
| 225 |
+ /** 코드표에 없는 구분값이 저장돼 있어도 뱃지가 사라지지 않도록 undefined를 허용한다. */ |
|
| 226 |
+ function categoryOf(category: ContactCategory) {
|
|
| 227 |
+ return categories.find((candidate) => candidate.code === category) |
|
| 228 |
+ } |
|
| 229 |
+ // '전체'는 코드가 아니라 화면에서만 쓰는 필터 항목이라 코드표에 넣지 않고 여기서 앞에 붙인다. |
|
| 230 |
+ const filters: { key: ContactCategory | 'ALL'; label: string }[] = [
|
|
| 231 |
+ { key: 'ALL', label: '전체' },
|
|
| 232 |
+ ...categories.map((category) => ({
|
|
| 233 |
+ key: category.code as ContactCategory, |
|
| 234 |
+ label: category.label, |
|
| 235 |
+ })), |
|
| 236 |
+ ] |
|
| 237 |
+ |
|
| 243 | 238 |
async function load() {
|
| 244 | 239 |
setLoading(true) |
| 245 | 240 |
try {
|
... | ... | @@ -307,7 +302,7 @@ |
| 307 | 302 |
<div className="p-8"> |
| 308 | 303 |
<div className="flex flex-wrap items-center justify-between gap-3"> |
| 309 | 304 |
<div className="flex flex-wrap gap-2"> |
| 310 |
- {FILTERS.map((f) => (
|
|
| 305 |
+ {filters.map((f) => (
|
|
| 311 | 306 |
<button |
| 312 | 307 |
key={f.key}
|
| 313 | 308 |
type="button" |
... | ... | @@ -397,9 +392,11 @@ |
| 397 | 392 |
<tr key={contact.id} className="border-b border-gray-100 last:border-b-0">
|
| 398 | 393 |
<td className="px-3 py-2"> |
| 399 | 394 |
<span |
| 400 |
- className={`rounded-full px-2 py-0.5 text-xs font-medium ${CATEGORY_BADGE_STYLES[contact.category]}`}
|
|
| 395 |
+ className={`rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass(
|
|
| 396 |
+ categoryOf(contact.category)?.attrs.tone as string | undefined, |
|
| 397 |
+ )}`} |
|
| 401 | 398 |
> |
| 402 |
- {CATEGORY_LABELS[contact.category]}
|
|
| 399 |
+ {categoryOf(contact.category)?.label ?? contact.category}
|
|
| 403 | 400 |
</span> |
| 404 | 401 |
</td> |
| 405 | 402 |
<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
... | ... | @@ -1,6 +1,7 @@ |
| 1 |
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 1 |
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
| 2 | 2 |
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
| 3 | 3 |
import ProjectsPage from './ProjectsPage' |
| 4 |
+import { withCodes } from '../codes/fixtures'
|
|
| 4 | 5 |
import type { ContactLog, DashboardOrgRow, OrgReport, ProjectRow } from '../api/client'
|
| 5 | 6 |
|
| 6 | 7 |
const mocks = vi.hoisted(() => ({
|
... | ... | @@ -77,7 +78,7 @@ |
| 77 | 78 |
} |
| 78 | 79 |
|
| 79 | 80 |
async function openPanel() {
|
| 80 |
- render(<ProjectsPage onOpenOrg={vi.fn()} />)
|
|
| 81 |
+ render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
|
|
| 81 | 82 |
await screen.findByTestId('project-table')
|
| 82 | 83 |
fireEvent.click(screen.getByRole('button', { name: '연락·보고서' }))
|
| 83 | 84 |
await waitFor(() => expect(mocks.getContactLogs).toHaveBeenCalledWith(1)) |
... | ... | @@ -95,7 +96,7 @@ |
| 95 | 96 |
mocks.getProjects.mockResolvedValue([ |
| 96 | 97 |
row({ lastContactedOn: '2026-07-20', contactCount: 3, reportCount: 1 }),
|
| 97 | 98 |
]) |
| 98 |
- render(<ProjectsPage onOpenOrg={vi.fn()} />)
|
|
| 99 |
+ render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
|
|
| 99 | 100 |
|
| 100 | 101 |
const table = within(await screen.findByTestId('project-table'))
|
| 101 | 102 |
expect(table.getByText('국제방송교류재단')).toBeTruthy()
|
... | ... | @@ -106,7 +107,7 @@ |
| 106 | 107 |
|
| 107 | 108 |
it('기관명을 클릭하면 기관관리로 넘긴다', async () => {
|
| 108 | 109 |
const onOpenOrg = vi.fn() |
| 109 |
- render(<ProjectsPage onOpenOrg={onOpenOrg} />)
|
|
| 110 |
+ render(withCodes(<ProjectsPage onOpenOrg={onOpenOrg} />))
|
|
| 110 | 111 |
|
| 111 | 112 |
fireEvent.click(await screen.findByRole('button', { name: '국제방송교류재단' }))
|
| 112 | 113 |
expect(onOpenOrg).toHaveBeenCalledWith(1) |
... | ... | @@ -117,7 +118,7 @@ |
| 117 | 118 |
row(), |
| 118 | 119 |
row({ org: orgRow({ id: 2, orgNo: '002_00', orgName: '세종학당재단' }), contactCount: 2 }),
|
| 119 | 120 |
]) |
| 120 |
- render(<ProjectsPage onOpenOrg={vi.fn()} />)
|
|
| 121 |
+ render(withCodes(<ProjectsPage onOpenOrg={vi.fn()} />))
|
|
| 121 | 122 |
await screen.findByTestId('project-table')
|
| 122 | 123 |
|
| 123 | 124 |
fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } })
|
--- frontend/src/components/ProjectsPage.tsx
+++ frontend/src/components/ProjectsPage.tsx
... | ... | @@ -13,7 +13,8 @@ |
| 13 | 13 |
type OrgReport, |
| 14 | 14 |
type ProjectRow, |
| 15 | 15 |
} from '../api/client' |
| 16 |
-import { stageLabel } from '../stages'
|
|
| 16 |
+import { useStages } from '../codes/stage'
|
|
| 17 |
+import { useCodes } from '../codes/useCodes'
|
|
| 17 | 18 |
import HelpButton from '../help/HelpButton' |
| 18 | 19 |
|
| 19 | 20 |
/** |
... | ... | @@ -28,8 +29,6 @@ |
| 28 | 29 |
/** 기관명을 클릭했을 때 기관관리 화면으로 넘긴다. */ |
| 29 | 30 |
onOpenOrg: (orgId: number) => void |
| 30 | 31 |
} |
| 31 |
- |
|
| 32 |
-const METHODS = ['전화', '메일', '방문', '기타'] |
|
| 33 | 32 |
|
| 34 | 33 |
function formatDateTime(ms: number): string {
|
| 35 | 34 |
const d = new Date(ms) |
... | ... | @@ -55,6 +54,11 @@ |
| 55 | 54 |
} |
| 56 | 55 |
|
| 57 | 56 |
export default function ProjectsPage({ onOpenOrg }: Props) {
|
| 57 |
+ // 단계 목록과 연락 방법은 코드표에서 온다. 단계는 예전에 12로 박혀 있어서 |
|
| 58 |
+ // 13단계를 늘리면 이 필터만 12개로 남는 문제가 있었다. |
|
| 59 |
+ const stages = useStages() |
|
| 60 |
+ const methods = useCodes('CONTACT_METHOD')
|
|
| 61 |
+ |
|
| 58 | 62 |
const [rows, setRows] = useState<ProjectRow[] | null>(null) |
| 59 | 63 |
const [error, setError] = useState<string | null>(null) |
| 60 | 64 |
const [keyword, setKeyword] = useState('')
|
... | ... | @@ -69,7 +73,7 @@ |
| 69 | 73 |
const [panelError, setPanelError] = useState<string | null>(null) |
| 70 | 74 |
|
| 71 | 75 |
const [logDate, setLogDate] = useState(today()) |
| 72 |
- const [logMethod, setLogMethod] = useState(METHODS[0]) |
|
| 76 |
+ const [logMethod, setLogMethod] = useState(methods[0]?.code ?? '') |
|
| 73 | 77 |
const [logSummary, setLogSummary] = useState('')
|
| 74 | 78 |
const [editingLogId, setEditingLogId] = useState<number | null>(null) |
| 75 | 79 |
|
... | ... | @@ -237,8 +241,8 @@ |
| 237 | 241 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 238 | 242 |
> |
| 239 | 243 |
<option value="">현재 단계 전체</option> |
| 240 |
- {Array.from({ length: 12 }, (_, i) => i + 1).map((n) => (
|
|
| 241 |
- <option key={n} value={String(n)}>{`${n}. ${stageLabel(n)}`}</option>
|
|
| 244 |
+ {stages.numbers.map((n) => (
|
|
| 245 |
+ <option key={n} value={String(n)}>{`${n}. ${stages.label(n)}`}</option>
|
|
| 242 | 246 |
))} |
| 243 | 247 |
</select> |
| 244 | 248 |
<select |
... | ... | @@ -296,7 +300,9 @@ |
| 296 | 300 |
</button> |
| 297 | 301 |
</td> |
| 298 | 302 |
<td className="px-2 py-2 text-gray-600"> |
| 299 |
- {row.org.stage === null ? '단계 미지정' : `${row.org.stage}. ${stageLabel(row.org.stage)}`}
|
|
| 303 |
+ {row.org.stage === null
|
|
| 304 |
+ ? '단계 미지정' |
|
| 305 |
+ : `${row.org.stage}. ${stages.label(row.org.stage)}`}
|
|
| 300 | 306 |
</td> |
| 301 | 307 |
<td className="px-2 py-2 text-gray-600">{row.org.mjName ?? '-'}</td>
|
| 302 | 308 |
<td className="px-2 py-2 text-gray-600">{row.org.lawyerName ?? '-'}</td>
|
... | ... | @@ -384,8 +390,10 @@ |
| 384 | 390 |
onChange={(e) => setLogMethod(e.target.value)}
|
| 385 | 391 |
className="rounded-md border border-gray-300 px-2 py-1.5 text-sm" |
| 386 | 392 |
> |
| 387 |
- {METHODS.map((m) => (
|
|
| 388 |
- <option key={m} value={m}>{m}</option>
|
|
| 393 |
+ {methods.map((m) => (
|
|
| 394 |
+ <option key={m.code} value={m.code}>
|
|
| 395 |
+ {m.label}
|
|
| 396 |
+ </option> |
|
| 389 | 397 |
))} |
| 390 | 398 |
</select> |
| 391 | 399 |
</label> |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?