ITN Dev 07-23
feat: 담당자관리 화면에 수행기관 구분과 회원명단 업로드를 추가
ContactCategory에 OPERATOR(수행기관)를 추가하고 필터 칩/배지/등록 폼에 반영했다.
[담당자 추가] 옆에 [회원명단 업로드] 버튼을 추가해 회원정보 xlsx를 올리면
등록/갱신/기관지정/건너뜀 건수를 보여주도록 했다(SeedUpload와 동일한 hidden
input 패턴).
@eda83b962b2ac3555ebe7b4214b351faeeb6d56d
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -1,6 +1,6 @@
 export type OrgStatus = 'INFO_PENDING' | 'READY' | 'PARTIAL' | 'ACTIVE'
 
-export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER'
+export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' | 'OPERATOR'
 
 /** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */
 export interface Contact {
@@ -276,6 +276,19 @@
   return request<SeedReport>('/api/seed', { method: 'POST', body: form })
 }
 
+export interface MemberImportReport {
+  created: number
+  updated: number
+  skipped: number
+  assigned: number
+}
+
+export function importMembers(file: File): Promise<MemberImportReport> {
+  const form = new FormData()
+  form.append('file', file)
+  return request<MemberImportReport>('/api/contacts/import', { method: 'POST', body: form })
+}
+
 /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */
 export async function logout(): Promise<void> {
   const response = await fetch('/api/auth/logout', {
frontend/src/components/ContactsPage.test.tsx
--- frontend/src/components/ContactsPage.test.tsx
+++ frontend/src/components/ContactsPage.test.tsx
@@ -8,6 +8,7 @@
   createContact: vi.fn(),
   updateContactInfo: vi.fn(),
   deleteContact: vi.fn(),
+  importMembers: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -16,6 +17,7 @@
   createContact: mocks.createContact,
   updateContactInfo: mocks.updateContactInfo,
   deleteContact: mocks.deleteContact,
+  importMembers: mocks.importMembers,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -37,6 +39,7 @@
   mocks.createContact.mockReset()
   mocks.updateContactInfo.mockReset()
   mocks.deleteContact.mockReset()
+  mocks.importMembers.mockReset()
 })
 
 describe('ContactsPage', () => {
@@ -77,6 +80,22 @@
 
     expect(screen.queryByText('송민지')).toBeNull()
     expect(screen.getByText('이변호')).toBeTruthy()
+  })
+
+  it('수행기관 필터 칩을 누르면 수행기관만 보인다', async () => {
+    mocks.getContacts.mockResolvedValue([
+      contact({ id: 1, name: '송민지', category: 'APPLICANT' }),
+      contact({ id: 2, name: '홍수행', category: 'OPERATOR', affiliation: '수행사', deptName: '운영팀' }),
+    ])
+
+    render(<ContactsPage />)
+
+    await waitFor(() => expect(screen.getByText('송민지')).toBeTruthy())
+
+    fireEvent.click(screen.getByRole('button', { name: '수행기관' }))
+
+    expect(screen.queryByText('송민지')).toBeNull()
+    expect(screen.getByText('홍수행')).toBeTruthy()
   })
 
   it('담당자 추가를 누르면 모달이 뜨고, 구분과 성명을 채워야 저장이 활성화된다', async () => {
@@ -177,4 +196,41 @@
 
     confirmSpy.mockRestore()
   })
+
+  it('회원명단 업로드 버튼으로 파일을 고르면 importMembers가 호출되고 결과가 표시된다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+    mocks.importMembers.mockResolvedValue({ created: 2, updated: 1, skipped: 1, assigned: 1 })
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
+
+    const file = new File(['dummy'], 'members.xlsx', {
+      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    })
+    const input = document.querySelector('input[type="file"]') as HTMLInputElement
+    fireEvent.change(input, { target: { files: [file] } })
+
+    await waitFor(() => expect(mocks.importMembers).toHaveBeenCalledWith(file))
+    expect(
+      screen.getByText('등록 2 · 갱신 1 · 기관지정 1 · 건너뜀 1'),
+    ).toBeTruthy()
+  })
+
+  it('회원명단 업로드가 실패하면 오류 메시지를 보여준다', async () => {
+    mocks.getContacts.mockResolvedValue([])
+    mocks.importMembers.mockRejectedValue(new Error('파일 형식이 올바르지 않습니다.'))
+
+    render(<ContactsPage />)
+    await waitFor(() => expect(mocks.getContacts).toHaveBeenCalled())
+
+    const file = new File(['dummy'], 'members.xlsx', {
+      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    })
+    const input = document.querySelector('input[type="file"]') as HTMLInputElement
+    fireEvent.change(input, { target: { files: [file] } })
+
+    await waitFor(() => {
+      expect(screen.getByText('파일 형식이 올바르지 않습니다.')).toBeTruthy()
+    })
+  })
 })
frontend/src/components/ContactsPage.tsx
--- frontend/src/components/ContactsPage.tsx
+++ frontend/src/components/ContactsPage.tsx
@@ -1,24 +1,28 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import {
   createContact,
   deleteContact,
   getContacts,
+  importMembers,
   updateContactInfo,
   type Contact,
   type ContactCategory,
   type ContactInput,
+  type MemberImportReport,
 } from '../api/client'
 
 const CATEGORY_LABELS: Record<ContactCategory, string> = {
   APPLICANT: '신청기관',
   MJ: '문정원',
   LAWYER: '변호사',
+  OPERATOR: '수행기관',
 }
 
 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',
 }
 
 const FILTERS: { key: ContactCategory | 'ALL'; label: string }[] = [
@@ -26,6 +30,7 @@
   { key: 'APPLICANT', label: '신청기관' },
   { key: 'MJ', label: '문정원' },
   { key: 'LAWYER', label: '변호사' },
+  { key: 'OPERATOR', label: '수행기관' },
 ]
 
 function emptyForm(contact: Contact | null): ContactInput {
@@ -119,6 +124,7 @@
               <option value="APPLICANT">신청기관</option>
               <option value="MJ">문정원</option>
               <option value="LAWYER">변호사</option>
+              <option value="OPERATOR">수행기관</option>
             </select>
           </div>
           <div className="flex flex-col gap-1">
@@ -224,6 +230,11 @@
   const [editing, setEditing] = useState<Contact | null>(null)
   const [error, setError] = useState<string | null>(null)
 
+  const importInputRef = useRef<HTMLInputElement>(null)
+  const [importBusy, setImportBusy] = useState(false)
+  const [importReport, setImportReport] = useState<MemberImportReport | null>(null)
+  const [importError, setImportError] = useState<string | null>(null)
+
   async function load() {
     setLoading(true)
     try {
@@ -273,6 +284,20 @@
     }
   }
 
+  async function handleImportFile(file: File) {
+    setImportBusy(true)
+    setImportError(null)
+    try {
+      const report = await importMembers(file)
+      setImportReport(report)
+      await load()
+    } catch (e) {
+      setImportError(e instanceof Error ? e.message : '회원명단 업로드에 실패했습니다.')
+    } finally {
+      setImportBusy(false)
+    }
+  }
+
   return (
     <div className="p-8">
       <div className="flex flex-wrap items-center justify-between gap-3">
@@ -294,15 +319,42 @@
           ))}
         </div>
 
-        <button
-          type="button"
-          onClick={openAdd}
-          className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
-        >
-          담당자 추가
-        </button>
+        <div className="flex items-center gap-2">
+          <input
+            ref={importInputRef}
+            type="file"
+            accept=".xlsx,.xlsm"
+            className="hidden"
+            onChange={(e) => {
+              const file = e.target.files?.[0]
+              if (file) void handleImportFile(file)
+              e.target.value = ''
+            }}
+          />
+          <button
+            type="button"
+            disabled={importBusy}
+            onClick={() => importInputRef.current?.click()}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
+          >
+            {importBusy ? '업로드 중…' : '회원명단 업로드'}
+          </button>
+          <button
+            type="button"
+            onClick={openAdd}
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
+          >
+            담당자 추가
+          </button>
+        </div>
       </div>
 
+      {importReport && (
+        <p className="mt-4 text-sm text-gray-600">
+          {`등록 ${importReport.created} · 갱신 ${importReport.updated} · 기관지정 ${importReport.assigned} · 건너뜀 ${importReport.skipped}`}
+        </p>
+      )}
+      {importError && <p className="mt-4 text-sm text-red-600">{importError}</p>}
       {error && <p className="mt-4 text-sm text-red-600">{error}</p>}
 
       <div className="mt-5 overflow-x-auto rounded-lg border border-gray-200 bg-white">
Add a comment
List