ITN Dev 07-24
feat: 채널관리/기관관리 화면에 아이티앤 담당자 배정 UI를 추가
문정원 담당자와 담당 변호사 사이에 아이티앤 담당자 배정 행/카드를
추가하고, 업무메모 담당자 선택 옵션에도 반영한다. 기관관리 담당자
카드 그리드를 4열로 조정하고, 관련 테스트 픽스처와 배정 페이로드
검증을 갱신한다.
@1a88e2d2e2986796145c7ca42bf28c245633583b
frontend/src/api/client.test.ts
--- frontend/src/api/client.test.ts
+++ frontend/src/api/client.test.ts
@@ -59,6 +59,7 @@
     const promise = updateAssignments(1, {
       applicantContactId: null,
       mjContactId: null,
+      itnContactId: null,
       lawyerContactId: null,
       lawyerAssignedDate: null,
     })
@@ -76,6 +77,7 @@
     await updateAssignments(7, {
       applicantContactId: 1,
       mjContactId: null,
+      itnContactId: null,
       lawyerContactId: null,
       lawyerAssignedDate: null,
     })
frontend/src/api/client.ts
--- frontend/src/api/client.ts
+++ frontend/src/api/client.ts
@@ -32,16 +32,18 @@
   status: OrgStatus
   applicant: Contact | null
   mj: Contact | null
+  itn: Contact | null
   lawyer: Contact | null
   lawyerAssignedDate: string | null
   channelIdMj: string | null
   channelIdLaw: string | null
 }
 
-/** 채널관리 화면이 신청기관/문정원/변호사 배정을 한 번에 바꿀 때 보내는 요청. null은 해제다. */
+/** 채널관리 화면이 신청기관/문정원/아이티앤/변호사 배정을 한 번에 바꿀 때 보내는 요청. null은 해제다. */
 export interface AssignmentInput {
   applicantContactId: number | null
   mjContactId: number | null
+  itnContactId: number | null
   lawyerContactId: number | null
   lawyerAssignedDate: string | null
 }
frontend/src/components/ChannelFiles.test.tsx
--- frontend/src/components/ChannelFiles.test.tsx
+++ frontend/src/components/ChannelFiles.test.tsx
@@ -20,6 +20,7 @@
     status: 'ACTIVE',
     applicant: null,
     mj: null,
+    itn: null,
     lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
frontend/src/components/ChannelPosts.test.tsx
--- frontend/src/components/ChannelPosts.test.tsx
+++ frontend/src/components/ChannelPosts.test.tsx
@@ -26,6 +26,7 @@
     status: 'ACTIVE',
     applicant: null,
     mj: null,
+    itn: null,
     lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
frontend/src/components/OrgDetail.test.tsx
--- frontend/src/components/OrgDetail.test.tsx
+++ frontend/src/components/OrgDetail.test.tsx
@@ -38,6 +38,7 @@
     status: 'INFO_PENDING',
     applicant: null,
     mj: null,
+    itn: null,
     lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: null,
@@ -68,7 +69,7 @@
   it('배정되지 않은 역할은 미지정으로 표시된다', () => {
     render(<OrgDetail org={org()} onChanged={() => {}} />)
 
-    expect(screen.getAllByText('미지정')).toHaveLength(3)
+    expect(screen.getAllByText('미지정')).toHaveLength(4)
   })
 
   it('배정된 담당자는 요약(이름·부서·연락처)으로 표시된다', () => {
@@ -115,6 +116,34 @@
       expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
         applicantContactId: 9,
         mjContactId: null,
+        itnContactId: null,
+        lawyerContactId: null,
+        lawyerAssignedDate: null,
+      })
+      expect(onChanged).toHaveBeenCalled()
+    })
+  })
+
+  it('아이티앤 담당자 선택 모달에서 고르면 updateAssignments가 itnContactId로 호출된다', async () => {
+    mocks.getContacts.mockResolvedValue([contact({ id: 11, category: 'ITN', name: '박아이티' })])
+    mocks.updateAssignments.mockResolvedValue(org())
+    const onChanged = vi.fn()
+
+    render(<OrgDetail org={org()} onChanged={onChanged} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 선택' }))
+
+    expect(screen.getByText('아이티앤 담당자 선택')).toBeTruthy()
+    await waitFor(() => expect(screen.getByText('박아이티')).toBeTruthy())
+    expect(mocks.getContacts).toHaveBeenCalledWith('ITN')
+
+    fireEvent.click(screen.getByRole('button', { name: '선택' }))
+
+    await waitFor(() => {
+      expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
+        applicantContactId: null,
+        mjContactId: null,
+        itnContactId: 11,
         lawyerContactId: null,
         lawyerAssignedDate: null,
       })
@@ -143,6 +172,7 @@
       expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
         applicantContactId: null,
         mjContactId: 6,
+        itnContactId: null,
         lawyerContactId: null,
         lawyerAssignedDate: null,
       })
@@ -173,6 +203,7 @@
       expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
         applicantContactId: null,
         mjContactId: null,
+        itnContactId: null,
         lawyerContactId: 7,
         lawyerAssignedDate: '2026-07-21',
       })
frontend/src/components/OrgDetail.tsx
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
@@ -13,10 +13,10 @@
 import StatusBadge from './StatusBadge'
 
 // 채널관리 상세. 담당자 정보는 여기서 입력하지 않는다(담당자관리에서만 입력) - 이 화면은
-// 이미 등록된 담당자를 ContactPickerModal로 골라 신청기관/문정원/변호사 역할에 배정만 한다.
+// 이미 등록된 담당자를 ContactPickerModal로 골라 신청기관/문정원/아이티앤/변호사 역할에 배정만 한다.
 
 interface RoleConfig {
-  key: 'applicant' | 'mj' | 'lawyer'
+  key: 'applicant' | 'mj' | 'itn' | 'lawyer'
   category: ContactCategory
   label: string
   pickerTitle: string
@@ -39,6 +39,13 @@
     field: 'mjContactId',
   },
   {
+    key: 'itn',
+    category: 'ITN',
+    label: '아이티앤 담당자',
+    pickerTitle: '아이티앤 담당자 선택',
+    field: 'itnContactId',
+  },
+  {
     key: 'lawyer',
     category: 'LAWYER',
     label: '담당 변호사',
@@ -53,6 +60,8 @@
       return org.applicant
     case 'mj':
       return org.mj
+    case 'itn':
+      return org.itn
     case 'lawyer':
       return org.lawyer
   }
@@ -90,12 +99,13 @@
     return {
       applicantContactId: org.applicant?.id ?? null,
       mjContactId: org.mj?.id ?? null,
+      itnContactId: org.itn?.id ?? null,
       lawyerContactId: org.lawyer?.id ?? null,
       lawyerAssignedDate: org.lawyerAssignedDate ?? null,
     }
   }
 
-  /** 매 호출이 세 배정값 + 배정일을 통째로 다시 보낸다(부분 갱신이 아니다). */
+  /** 매 호출이 네 배정값 + 배정일을 통째로 다시 보낸다(부분 갱신이 아니다). */
   async function persistAssignments(overrides: Partial<AssignmentInput>) {
     setError(null)
     setBusy(true)
frontend/src/components/OrgList.test.tsx
--- frontend/src/components/OrgList.test.tsx
+++ frontend/src/components/OrgList.test.tsx
@@ -6,12 +6,12 @@
 const orgs: Org[] = [
   {
     id: 1, orgNo: '001_00', orgName: '국제방송교류재단', status: 'ACTIVE',
-    applicant: null, mj: null, lawyer: null, lawyerAssignedDate: null,
+    applicant: null, mj: null, itn: null, lawyer: null, lawyerAssignedDate: null,
     channelIdMj: 'a', channelIdLaw: 'b',
   },
   {
     id: 2, orgNo: '008_01', orgName: '경찰청_치안정책연구소', status: 'INFO_PENDING',
-    applicant: null, mj: null, lawyer: null, lawyerAssignedDate: null,
+    applicant: null, mj: null, itn: null, lawyer: null, lawyerAssignedDate: null,
     channelIdMj: null, channelIdLaw: null,
   },
 ]
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -51,6 +51,7 @@
       email: 'ming@arirang.com',
     }),
     mj: null,
+    itn: null,
     lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
@@ -79,18 +80,20 @@
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('문정원 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
+  it('문정원 담당자와 아이티앤 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
     render(<OrgOverview org={org()} />)
 
     const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
+    const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement
     const lawyerCard = screen.getByText('담당 변호사').closest('section') as HTMLElement
 
     expect(within(mjCard).getAllByText('-')).toHaveLength(4)
+    expect(within(itnCard).getAllByText('-')).toHaveLength(4)
     expect(within(lawyerCard).getAllByText('-')).toHaveLength(4)
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('담당자 3종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
+  it('담당자 4종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
     render(
       <OrgOverview
         org={org({
@@ -101,6 +104,14 @@
             deptName: '문화체육관광부 저작권정책과',
             phone: '02-1234-5678',
             email: 'mj@mcst.go.kr',
+          }),
+          itn: contact({
+            id: 4,
+            category: 'ITN',
+            name: '박아이티',
+            deptName: '개발팀',
+            phone: '02-1111-2222',
+            email: 'itn@iten.co.kr',
           }),
           lawyer: contact({
             id: 3,
@@ -116,6 +127,7 @@
 
     const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement
     const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
+    const itnCard = screen.getByText('아이티앤 담당자').closest('section') as HTMLElement
     const lawyerCard = screen.getByText('담당 변호사').closest('section') as HTMLElement
 
     expect(within(applicantCard).getByText('데이터정보화팀')).toBeTruthy()
@@ -125,6 +137,11 @@
     expect(within(mjCard).getByText('김문정')).toBeTruthy()
     expect(within(mjCard).getByText('02-1234-5678')).toBeTruthy()
     expect(within(mjCard).getByText('mj@mcst.go.kr')).toBeTruthy()
+
+    expect(within(itnCard).getByText('개발팀')).toBeTruthy()
+    expect(within(itnCard).getByText('박아이티')).toBeTruthy()
+    expect(within(itnCard).getByText('02-1111-2222')).toBeTruthy()
+    expect(within(itnCard).getByText('itn@iten.co.kr')).toBeTruthy()
 
     expect(within(lawyerCard).getByText('이변호')).toBeTruthy()
     expect(within(lawyerCard).getByText('02-9876-5432')).toBeTruthy()
@@ -139,6 +156,7 @@
 
     expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
     expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
+    expect(screen.getByRole('button', { name: '아이티앤 담당자 생성' })).toBeTruthy()
     expect(screen.getByRole('button', { name: '담당 변호사 생성' })).toBeTruthy()
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
@@ -160,6 +178,32 @@
       expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
         applicantContactId: 1,
         mjContactId: 7,
+        itnContactId: null,
+        lawyerContactId: null,
+        lawyerAssignedDate: null,
+      }),
+    )
+    expect(onChanged).toHaveBeenCalled()
+  })
+
+  it('아이티앤 담당자 생성 버튼을 누르면 선택 모달이 열리고 담당자를 고르면 itnContactId로 배정 API를 부른다', async () => {
+    mocks.getContacts.mockResolvedValue([
+      contact({ id: 8, category: 'ITN', name: '박아이티', deptName: '개발팀' }),
+    ])
+    const onChanged = vi.fn()
+
+    render(<OrgOverview org={org()} onChanged={onChanged} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '아이티앤 담당자 생성' }))
+
+    await waitFor(() => expect(screen.getByText('아이티앤 담당자 선택')).toBeTruthy())
+    fireEvent.click(await screen.findByRole('button', { name: '선택' }))
+
+    await waitFor(() =>
+      expect(mocks.updateAssignments).toHaveBeenCalledWith(1, {
+        applicantContactId: 1,
+        mjContactId: null,
+        itnContactId: 8,
         lawyerContactId: null,
         lawyerAssignedDate: null,
       }),
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -38,11 +38,12 @@
 
 // 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다)
 const CARD_ROLES: Record<
-  'applicant' | 'mj' | 'lawyer',
+  'applicant' | 'mj' | 'itn' | 'lawyer',
   { category: ContactCategory; pickerTitle: string }
 > = {
   applicant: { category: 'APPLICANT', pickerTitle: '신청기관 담당자 선택' },
   mj: { category: 'MJ', pickerTitle: '문정원 담당자 선택' },
+  itn: { category: 'ITN', pickerTitle: '아이티앤 담당자 선택' },
   lawyer: { category: 'LAWYER', pickerTitle: '담당 변호사 선택' },
 }
 
@@ -111,11 +112,11 @@
   onChanged?: () => void
 }) {
   const [activeTab, setActiveTab] = useState(TABS[0])
-  const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'lawyer' | null>(null)
+  const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'itn' | 'lawyer' | null>(null)
   const [assignBusy, setAssignBusy] = useState(false)
   const [assignError, setAssignError] = useState<string | null>(null)
 
-  async function assign(role: 'applicant' | 'mj' | 'lawyer', contact: Contact) {
+  async function assign(role: 'applicant' | 'mj' | 'itn' | 'lawyer', contact: Contact) {
     setPickerRole(null)
     setAssignError(null)
     setAssignBusy(true)
@@ -123,6 +124,7 @@
       await updateAssignments(org.id, {
         applicantContactId: role === 'applicant' ? contact.id : (org.applicant?.id ?? null),
         mjContactId: role === 'mj' ? contact.id : (org.mj?.id ?? null),
+        itnContactId: role === 'itn' ? contact.id : (org.itn?.id ?? null),
         lawyerContactId: role === 'lawyer' ? contact.id : (org.lawyer?.id ?? null),
         lawyerAssignedDate: org.lawyerAssignedDate ?? null,
       })
@@ -180,9 +182,9 @@
           </div>
         </div>
 
-        {/* 담당자 카드 3개 (신청기관/문정원/변호사). 담당자 정보 입력은 담당자관리에서만 하고,
+        {/* 담당자 카드 4개 (신청기관/문정원/아이티앤/변호사). 담당자 정보 입력은 담당자관리에서만 하고,
             여기서는 [생성/변경]으로 이미 등록된 담당자를 골라 배정만 한다(채널관리와 같은 API). */}
-        <div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
+        <div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
           <ContactCard
             title="신청기관 담당자"
             assigned={org.applicant !== null}
@@ -215,6 +217,18 @@
             ]}
           />
           <ContactCard
+            title="아이티앤 담당자"
+            assigned={org.itn !== null}
+            busy={assignBusy}
+            onEdit={() => setPickerRole('itn')}
+            fields={[
+              { label: '부서', value: org.itn?.deptName ?? null },
+              { label: '담당자', value: org.itn?.name ?? null },
+              { label: '연락처', value: org.itn?.phone ?? null },
+              { label: '이메일', value: org.itn?.email ?? null },
+            ]}
+          />
+          <ContactCard
             title="담당 변호사"
             assigned={org.lawyer !== null}
             busy={assignBusy}
frontend/src/components/WorkMemos.test.tsx
--- frontend/src/components/WorkMemos.test.tsx
+++ frontend/src/components/WorkMemos.test.tsx
@@ -47,6 +47,7 @@
       email: 'ming@arirang.com',
     }),
     mj: null,
+    itn: null,
     lawyer: null,
     lawyerAssignedDate: null,
     channelIdMj: 'chan-mj',
@@ -113,13 +114,14 @@
     expect(screen.getByLabelText('담당자 이름')).toBeTruthy()
   })
 
-  it('신청기관 담당자, 문정원 담당자, 담당 변호사가 모두 있으면 셋 다 선택 옵션으로 나온다', async () => {
+  it('신청기관 담당자, 문정원 담당자, 아이티앤 담당자, 담당 변호사가 모두 있으면 넷 다 선택 옵션으로 나온다', async () => {
     mocks.getMemos.mockResolvedValue([])
 
     render(
       <WorkMemos
         org={org({
           mj: contact({ id: 2, category: 'MJ', name: '김문정' }),
+          itn: contact({ id: 4, category: 'ITN', name: '박아이티' }),
           lawyer: contact({ id: 3, category: 'LAWYER', name: '이변호' }),
         })}
       />,
@@ -129,6 +131,7 @@
 
     expect(screen.getByRole('option', { name: '송민지 과장' })).toBeTruthy()
     expect(screen.getByRole('option', { name: '김문정' })).toBeTruthy()
+    expect(screen.getByRole('option', { name: '박아이티' })).toBeTruthy()
     expect(screen.getByRole('option', { name: '이변호' })).toBeTruthy()
     expect(screen.getByRole('option', { name: '직접 입력' })).toBeTruthy()
   })
frontend/src/components/WorkMemos.tsx
--- frontend/src/components/WorkMemos.tsx
+++ frontend/src/components/WorkMemos.tsx
@@ -22,10 +22,15 @@
   return org.applicant.title ? `${org.applicant.name} ${org.applicant.title}` : org.applicant.name
 }
 
-/** 신청기관 담당자, 문정원 담당자, 담당 변호사 중 배정되어 있는 사람만 후보로 낸다.
+/** 신청기관 담당자, 문정원 담당자, 아이티앤 담당자, 담당 변호사 중 배정되어 있는 사람만 후보로 낸다.
  * 이름이 같은 사람이 중복 등록돼 있어도 셀렉트 옵션은 한 번만 보이도록 중복을 없앤다. */
 function contactOptions(org: Org): string[] {
-  const candidates = [applicantOptionLabel(org), org.mj?.name ?? null, org.lawyer?.name ?? null]
+  const candidates = [
+    applicantOptionLabel(org),
+    org.mj?.name ?? null,
+    org.itn?.name ?? null,
+    org.lawyer?.name ?? null,
+  ]
   const seen = new Set<string>()
   const options: string[] = []
   for (const candidate of candidates) {
Add a comment
List