feat: 담당자관리 화면에 수행기관 구분과 회원명단 업로드를 추가
ContactCategory에 OPERATOR(수행기관)를 추가하고 필터 칩/배지/등록 폼에 반영했다. [담당자 추가] 옆에 [회원명단 업로드] 버튼을 추가해 회원정보 xlsx를 올리면 등록/갱신/기관지정/건너뜀 건수를 보여주도록 했다(SeedUpload와 동일한 hidden input 패턴).
@eda83b962b2ac3555ebe7b4214b351faeeb6d56d
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
export type OrgStatus = 'INFO_PENDING' | 'READY' | 'PARTIAL' | 'ACTIVE' |
| 2 | 2 |
|
| 3 |
-export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' |
|
| 3 |
+export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' | 'OPERATOR' |
|
| 4 | 4 |
|
| 5 | 5 |
/** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */ |
| 6 | 6 |
export interface Contact {
|
... | ... | @@ -276,6 +276,19 @@ |
| 276 | 276 |
return request<SeedReport>('/api/seed', { method: 'POST', body: form })
|
| 277 | 277 |
} |
| 278 | 278 |
|
| 279 |
+export interface MemberImportReport {
|
|
| 280 |
+ created: number |
|
| 281 |
+ updated: number |
|
| 282 |
+ skipped: number |
|
| 283 |
+ assigned: number |
|
| 284 |
+} |
|
| 285 |
+ |
|
| 286 |
+export function importMembers(file: File): Promise<MemberImportReport> {
|
|
| 287 |
+ const form = new FormData() |
|
| 288 |
+ form.append('file', file)
|
|
| 289 |
+ return request<MemberImportReport>('/api/contacts/import', { method: 'POST', body: form })
|
|
| 290 |
+} |
|
| 291 |
+ |
|
| 279 | 292 |
/** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ |
| 280 | 293 |
export async function logout(): Promise<void> {
|
| 281 | 294 |
const response = await fetch('/api/auth/logout', {
|
--- frontend/src/components/ContactsPage.test.tsx
+++ frontend/src/components/ContactsPage.test.tsx
... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 |
createContact: vi.fn(), |
| 9 | 9 |
updateContactInfo: vi.fn(), |
| 10 | 10 |
deleteContact: vi.fn(), |
| 11 |
+ importMembers: vi.fn(), |
|
| 11 | 12 |
})) |
| 12 | 13 |
|
| 13 | 14 |
vi.mock('../api/client', async (importOriginal) => ({
|
... | ... | @@ -16,6 +17,7 @@ |
| 16 | 17 |
createContact: mocks.createContact, |
| 17 | 18 |
updateContactInfo: mocks.updateContactInfo, |
| 18 | 19 |
deleteContact: mocks.deleteContact, |
| 20 |
+ importMembers: mocks.importMembers, |
|
| 19 | 21 |
})) |
| 20 | 22 |
|
| 21 | 23 |
function contact(overrides: Partial<Contact> = {}): Contact {
|
... | ... | @@ -37,6 +39,7 @@ |
| 37 | 39 |
mocks.createContact.mockReset() |
| 38 | 40 |
mocks.updateContactInfo.mockReset() |
| 39 | 41 |
mocks.deleteContact.mockReset() |
| 42 |
+ mocks.importMembers.mockReset() |
|
| 40 | 43 |
}) |
| 41 | 44 |
|
| 42 | 45 |
describe('ContactsPage', () => {
|
... | ... | @@ -77,6 +80,22 @@ |
| 77 | 80 |
|
| 78 | 81 |
expect(screen.queryByText('송민지')).toBeNull()
|
| 79 | 82 |
expect(screen.getByText('이변호')).toBeTruthy()
|
| 83 |
+ }) |
|
| 84 |
+ |
|
| 85 |
+ it('수행기관 필터 칩을 누르면 수행기관만 보인다', async () => {
|
|
| 86 |
+ mocks.getContacts.mockResolvedValue([ |
|
| 87 |
+ contact({ id: 1, name: '송민지', category: 'APPLICANT' }),
|
|
| 88 |
+ contact({ id: 2, name: '홍수행', category: 'OPERATOR', affiliation: '수행사', deptName: '운영팀' }),
|
|
| 89 |
+ ]) |
|
| 90 |
+ |
|
| 91 |
+ render(<ContactsPage />) |
|
| 92 |
+ |
|
| 93 |
+ await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
|
|
| 94 |
+ |
|
| 95 |
+ fireEvent.click(screen.getByRole('button', { name: '수행기관' }))
|
|
| 96 |
+ |
|
| 97 |
+ expect(screen.queryByText('송민지')).toBeNull()
|
|
| 98 |
+ expect(screen.getByText('홍수행')).toBeTruthy()
|
|
| 80 | 99 |
}) |
| 81 | 100 |
|
| 82 | 101 |
it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
|
... | ... | @@ -177,4 +196,41 @@ |
| 177 | 196 |
|
| 178 | 197 |
confirmSpy.mockRestore() |
| 179 | 198 |
}) |
| 199 |
+ |
|
| 200 |
+ it('회원명단 업로드 버튼으로 파일을 고르면 importMembers가 호출되고 결과가 표시된다', async () => {
|
|
| 201 |
+ mocks.getContacts.mockResolvedValue([]) |
|
| 202 |
+ mocks.importMembers.mockResolvedValue({ created: 2, updated: 1, skipped: 1, assigned: 1 })
|
|
| 203 |
+ |
|
| 204 |
+ render(<ContactsPage />) |
|
| 205 |
+ await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
|
| 206 |
+ |
|
| 207 |
+ const file = new File(['dummy'], 'members.xlsx', {
|
|
| 208 |
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
|
| 209 |
+ }) |
|
| 210 |
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
| 211 |
+ fireEvent.change(input, { target: { files: [file] } })
|
|
| 212 |
+ |
|
| 213 |
+ await waitFor(() => expect(mocks.importMembers).toHaveBeenCalledWith(file)) |
|
| 214 |
+ expect( |
|
| 215 |
+ screen.getByText('등록 2 · 갱신 1 · 기관지정 1 · 건너뜀 1'),
|
|
| 216 |
+ ).toBeTruthy() |
|
| 217 |
+ }) |
|
| 218 |
+ |
|
| 219 |
+ it('회원명단 업로드가 실패하면 오류 메시지를 보여준다', async () => {
|
|
| 220 |
+ mocks.getContacts.mockResolvedValue([]) |
|
| 221 |
+ mocks.importMembers.mockRejectedValue(new Error('파일 형식이 올바르지 않습니다.'))
|
|
| 222 |
+ |
|
| 223 |
+ render(<ContactsPage />) |
|
| 224 |
+ await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled()) |
|
| 225 |
+ |
|
| 226 |
+ const file = new File(['dummy'], 'members.xlsx', {
|
|
| 227 |
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
|
| 228 |
+ }) |
|
| 229 |
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
|
| 230 |
+ fireEvent.change(input, { target: { files: [file] } })
|
|
| 231 |
+ |
|
| 232 |
+ await waitFor(() => {
|
|
| 233 |
+ expect(screen.getByText('파일 형식이 올바르지 않습니다.')).toBeTruthy()
|
|
| 234 |
+ }) |
|
| 235 |
+ }) |
|
| 180 | 236 |
}) |
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
... | ... | @@ -1,24 +1,28 @@ |
| 1 |
-import { useEffect, useState } from 'react'
|
|
| 1 |
+import { useEffect, useRef, useState } from 'react'
|
|
| 2 | 2 |
import {
|
| 3 | 3 |
createContact, |
| 4 | 4 |
deleteContact, |
| 5 | 5 |
getContacts, |
| 6 |
+ importMembers, |
|
| 6 | 7 |
updateContactInfo, |
| 7 | 8 |
type Contact, |
| 8 | 9 |
type ContactCategory, |
| 9 | 10 |
type ContactInput, |
| 11 |
+ type MemberImportReport, |
|
| 10 | 12 |
} from '../api/client' |
| 11 | 13 |
|
| 12 | 14 |
const CATEGORY_LABELS: Record<ContactCategory, string> = {
|
| 13 | 15 |
APPLICANT: '신청기관', |
| 14 | 16 |
MJ: '문정원', |
| 15 | 17 |
LAWYER: '변호사', |
| 18 |
+ OPERATOR: '수행기관', |
|
| 16 | 19 |
} |
| 17 | 20 |
|
| 18 | 21 |
const CATEGORY_BADGE_STYLES: Record<ContactCategory, string> = {
|
| 19 | 22 |
APPLICANT: 'bg-blue-50 text-blue-700', |
| 20 | 23 |
MJ: 'bg-emerald-50 text-emerald-700', |
| 21 | 24 |
LAWYER: 'bg-violet-50 text-violet-700', |
| 25 |
+ OPERATOR: 'bg-sky-50 text-sky-700', |
|
| 22 | 26 |
} |
| 23 | 27 |
|
| 24 | 28 |
const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
|
... | ... | @@ -26,6 +30,7 @@ |
| 26 | 30 |
{ key: 'APPLICANT', label: '신청기관' },
|
| 27 | 31 |
{ key: 'MJ', label: '문정원' },
|
| 28 | 32 |
{ key: 'LAWYER', label: '변호사' },
|
| 33 |
+ { key: 'OPERATOR', label: '수행기관' },
|
|
| 29 | 34 |
] |
| 30 | 35 |
|
| 31 | 36 |
function emptyForm(contact: Contact | null): ContactInput {
|
... | ... | @@ -119,6 +124,7 @@ |
| 119 | 124 |
<option value="APPLICANT">신청기관</option> |
| 120 | 125 |
<option value="MJ">문정원</option> |
| 121 | 126 |
<option value="LAWYER">변호사</option> |
| 127 |
+ <option value="OPERATOR">수행기관</option> |
|
| 122 | 128 |
</select> |
| 123 | 129 |
</div> |
| 124 | 130 |
<div className="flex flex-col gap-1"> |
... | ... | @@ -224,6 +230,11 @@ |
| 224 | 230 |
const [editing, setEditing] = useState<Contact | null>(null) |
| 225 | 231 |
const [error, setError] = useState<string | null>(null) |
| 226 | 232 |
|
| 233 |
+ const importInputRef = useRef<HTMLInputElement>(null) |
|
| 234 |
+ const [importBusy, setImportBusy] = useState(false) |
|
| 235 |
+ const [importReport, setImportReport] = useState<MemberImportReport | null>(null) |
|
| 236 |
+ const [importError, setImportError] = useState<string | null>(null) |
|
| 237 |
+ |
|
| 227 | 238 |
async function load() {
|
| 228 | 239 |
setLoading(true) |
| 229 | 240 |
try {
|
... | ... | @@ -273,6 +284,20 @@ |
| 273 | 284 |
} |
| 274 | 285 |
} |
| 275 | 286 |
|
| 287 |
+ async function handleImportFile(file: File) {
|
|
| 288 |
+ setImportBusy(true) |
|
| 289 |
+ setImportError(null) |
|
| 290 |
+ try {
|
|
| 291 |
+ const report = await importMembers(file) |
|
| 292 |
+ setImportReport(report) |
|
| 293 |
+ await load() |
|
| 294 |
+ } catch (e) {
|
|
| 295 |
+ setImportError(e instanceof Error ? e.message : '회원명단 업로드에 실패했습니다.') |
|
| 296 |
+ } finally {
|
|
| 297 |
+ setImportBusy(false) |
|
| 298 |
+ } |
|
| 299 |
+ } |
|
| 300 |
+ |
|
| 276 | 301 |
return ( |
| 277 | 302 |
<div className="p-8"> |
| 278 | 303 |
<div className="flex flex-wrap items-center justify-between gap-3"> |
... | ... | @@ -294,15 +319,42 @@ |
| 294 | 319 |
))} |
| 295 | 320 |
</div> |
| 296 | 321 |
|
| 297 |
- <button |
|
| 298 |
- type="button" |
|
| 299 |
- onClick={openAdd}
|
|
| 300 |
- className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700" |
|
| 301 |
- > |
|
| 302 |
- 담당자 추가 |
|
| 303 |
- </button> |
|
| 322 |
+ <div className="flex items-center gap-2"> |
|
| 323 |
+ <input |
|
| 324 |
+ ref={importInputRef}
|
|
| 325 |
+ type="file" |
|
| 326 |
+ accept=".xlsx,.xlsm" |
|
| 327 |
+ className="hidden" |
|
| 328 |
+ onChange={(e) => {
|
|
| 329 |
+ const file = e.target.files?.[0] |
|
| 330 |
+ if (file) void handleImportFile(file) |
|
| 331 |
+ e.target.value = '' |
|
| 332 |
+ }} |
|
| 333 |
+ /> |
|
| 334 |
+ <button |
|
| 335 |
+ type="button" |
|
| 336 |
+ disabled={importBusy}
|
|
| 337 |
+ onClick={() => importInputRef.current?.click()}
|
|
| 338 |
+ className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50" |
|
| 339 |
+ > |
|
| 340 |
+ {importBusy ? '업로드 중…' : '회원명단 업로드'}
|
|
| 341 |
+ </button> |
|
| 342 |
+ <button |
|
| 343 |
+ type="button" |
|
| 344 |
+ onClick={openAdd}
|
|
| 345 |
+ className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700" |
|
| 346 |
+ > |
|
| 347 |
+ 담당자 추가 |
|
| 348 |
+ </button> |
|
| 349 |
+ </div> |
|
| 304 | 350 |
</div> |
| 305 | 351 |
|
| 352 |
+ {importReport && (
|
|
| 353 |
+ <p className="mt-4 text-sm text-gray-600"> |
|
| 354 |
+ {`등록 ${importReport.created} · 갱신 ${importReport.updated} · 기관지정 ${importReport.assigned} · 건너뜀 ${importReport.skipped}`}
|
|
| 355 |
+ </p> |
|
| 356 |
+ )} |
|
| 357 |
+ {importError && <p className="mt-4 text-sm text-red-600">{importError}</p>}
|
|
| 306 | 358 |
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
| 307 | 359 |
|
| 308 | 360 |
<div className="mt-5 overflow-x-auto rounded-lg border border-gray-200 bg-white"> |
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?