ITN Dev 07-23
feat: 기관관리 담당자 카드에 생성/변경 버튼과 선택 모달 연결
@8b2d35fffc5bf16704842c089368d6ad14989af8
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -148,7 +148,7 @@
               page === 'channels' ? (
                 <OrgDetail org={selected} onChanged={() => void reload()} />
               ) : (
-                <OrgOverview org={selected} />
+                <OrgOverview org={selected} onChanged={() => void reload()} />
               )
             ) : (
               <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -7,6 +7,8 @@
   getPosts: vi.fn(),
   getFiles: vi.fn(),
   getMemos: vi.fn(),
+  getContacts: vi.fn(),
+  updateAssignments: vi.fn(),
 }))
 
 vi.mock('../api/client', async (importOriginal) => ({
@@ -14,6 +16,8 @@
   getPosts: mocks.getPosts,
   getFiles: mocks.getFiles,
   getMemos: mocks.getMemos,
+  getContacts: mocks.getContacts,
+  updateAssignments: mocks.updateAssignments,
 }))
 
 function contact(overrides: Partial<Contact> = {}): Contact {
@@ -59,6 +63,8 @@
   mocks.getPosts.mockReset().mockResolvedValue([])
   mocks.getFiles.mockReset().mockResolvedValue([])
   mocks.getMemos.mockReset().mockResolvedValue([])
+  mocks.getContacts.mockReset().mockResolvedValue([])
+  mocks.updateAssignments.mockReset().mockResolvedValue(undefined)
 })
 
 describe('OrgOverview', () => {
@@ -128,6 +134,47 @@
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
+  it('배정된 카드에는 변경, 빈 카드에는 생성 버튼이 보인다', async () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.getByRole('button', { name: '신청기관 담당자 변경' })).toBeTruthy()
+    expect(screen.getByRole('button', { name: '문정원 담당자 생성' })).toBeTruthy()
+    expect(screen.getByRole('button', { name: '담당 변호사 생성' })).toBeTruthy()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
+  })
+
+  it('생성 버튼을 누르면 선택 모달이 열리고 담당자를 고르면 배정 API를 부른다', async () => {
+    mocks.getContacts.mockResolvedValue([
+      contact({ id: 7, category: 'MJ', 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: 7,
+        lawyerContactId: null,
+        lawyerAssignedDate: null,
+      }),
+    )
+    expect(onChanged).toHaveBeenCalled()
+  })
+
+  it('상단 동작 버튼에서 담당 변호사 변경은 사라졌다', async () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.queryByRole('button', { name: '담당 변호사 변경' })).toBeNull()
+    expect(screen.getByRole('button', { name: '진행단계 변경' })).toBeDisabled()
+    await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
+  })
+
   it('업무단계 12개가 순서대로 보인다', async () => {
     render(<OrgOverview org={org()} />)
 
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -1,7 +1,13 @@
 import { useState } from 'react'
-import type { Org } from '../api/client'
+import {
+  updateAssignments,
+  type Contact,
+  type ContactCategory,
+  type Org,
+} from '../api/client'
 import ChannelFiles from './ChannelFiles'
 import ChannelPosts from './ChannelPosts'
+import ContactPickerModal from './ContactPickerModal'
 import StatusBadge from './StatusBadge'
 import WorkMemos from './WorkMemos'
 
@@ -27,7 +33,18 @@
 
 const TABS = ['채팅', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
 
-const DISABLED_ACTIONS = ['진행단계 변경', '담당 변호사 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리']
+// 담당 변호사 변경은 카드의 [생성/변경] 버튼으로 대체되어 목록에서 뺐다
+const DISABLED_ACTIONS = ['진행단계 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리']
+
+// 카드에서 담당자를 지정/변경할 때 쓰는 역할 정의 (채널관리의 배정과 같은 API를 쓴다)
+const CARD_ROLES: Record<
+  'applicant' | 'mj' | 'lawyer',
+  { category: ContactCategory; pickerTitle: string }
+> = {
+  applicant: { category: 'APPLICANT', pickerTitle: '신청기관 담당자 선택' },
+  mj: { category: 'MJ', pickerTitle: '문정원 담당자 선택' },
+  lawyer: { category: 'LAWYER', pickerTitle: '담당 변호사 선택' },
+}
 
 // Mattermost 웹 주소. 채널 화면은 팀명 + 채널 내부명으로 열린다.
 // 내부명 규칙은 백엔드 ChannelNaming.java가 정본이며 여기서는 열람 링크용으로만 복제한다.
@@ -53,13 +70,30 @@
 function ContactCard({
   title,
   fields,
+  assigned,
+  busy,
+  onEdit,
 }: {
   title: string
   fields: { label: string; value: string | null }[]
+  assigned: boolean
+  busy: boolean
+  onEdit: () => void
 }) {
   return (
     <section className="rounded-lg border border-gray-200 p-4">
-      <h2 className="text-sm font-semibold">{title}</h2>
+      <div className="flex items-center justify-between">
+        <h2 className="text-sm font-semibold">{title}</h2>
+        <button
+          type="button"
+          disabled={busy}
+          aria-label={`${title} ${assigned ? '변경' : '생성'}`}
+          onClick={onEdit}
+          className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-40"
+        >
+          {assigned ? '변경' : '생성'}
+        </button>
+      </div>
       <div className="mt-3 space-y-1.5">
         {fields.map((f) => (
           <Field key={f.label} label={f.label} value={f.value} />
@@ -69,8 +103,36 @@
   )
 }
 
-export default function OrgOverview({ org }: { org: Org }) {
+export default function OrgOverview({
+  org,
+  onChanged,
+}: {
+  org: Org
+  onChanged?: () => void
+}) {
   const [activeTab, setActiveTab] = useState(TABS[0])
+  const [pickerRole, setPickerRole] = useState<'applicant' | 'mj' | 'lawyer' | null>(null)
+  const [assignBusy, setAssignBusy] = useState(false)
+  const [assignError, setAssignError] = useState<string | null>(null)
+
+  async function assign(role: 'applicant' | 'mj' | 'lawyer', contact: Contact) {
+    setPickerRole(null)
+    setAssignError(null)
+    setAssignBusy(true)
+    try {
+      await updateAssignments(org.id, {
+        applicantContactId: role === 'applicant' ? contact.id : (org.applicant?.id ?? null),
+        mjContactId: role === 'mj' ? contact.id : (org.mj?.id ?? null),
+        lawyerContactId: role === 'lawyer' ? contact.id : (org.lawyer?.id ?? null),
+        lawyerAssignedDate: org.lawyerAssignedDate ?? null,
+      })
+      onChanged?.()
+    } catch (e) {
+      setAssignError(e instanceof Error ? e.message : '담당자 지정에 실패했습니다.')
+    } finally {
+      setAssignBusy(false)
+    }
+  }
 
   return (
     <div className="p-6">
@@ -118,11 +180,14 @@
           </div>
         </div>
 
-        {/* 담당자 카드 3개 (신청기관/문정원/변호사) - 담당자 정보는 담당자관리에서만 입력하고,
-            여기서는 org.applicant/mj/lawyer로 배정된 담당자를 읽기 전용으로 보여준다. */}
+        {/* 담당자 카드 3개 (신청기관/문정원/변호사). 담당자 정보 입력은 담당자관리에서만 하고,
+            여기서는 [생성/변경]으로 이미 등록된 담당자를 골라 배정만 한다(채널관리와 같은 API). */}
         <div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
           <ContactCard
             title="신청기관 담당자"
+            assigned={org.applicant !== null}
+            busy={assignBusy}
+            onEdit={() => setPickerRole('applicant')}
             fields={[
               { label: '부서', value: org.applicant?.deptName ?? null },
               {
@@ -139,6 +204,9 @@
           />
           <ContactCard
             title="문정원 담당자"
+            assigned={org.mj !== null}
+            busy={assignBusy}
+            onEdit={() => setPickerRole('mj')}
             fields={[
               { label: '부서', value: org.mj?.deptName ?? null },
               { label: '담당자', value: org.mj?.name ?? null },
@@ -148,6 +216,9 @@
           />
           <ContactCard
             title="담당 변호사"
+            assigned={org.lawyer !== null}
+            busy={assignBusy}
+            onEdit={() => setPickerRole('lawyer')}
             fields={[
               { label: '성명', value: org.lawyer?.name ?? null },
               { label: '연락처', value: org.lawyer?.phone ?? null },
@@ -157,6 +228,17 @@
           />
         </div>
 
+        {assignError && <p className="mt-2 text-sm text-red-600">{assignError}</p>}
+
+        {pickerRole && (
+          <ContactPickerModal
+            category={CARD_ROLES[pickerRole].category}
+            title={CARD_ROLES[pickerRole].pickerTitle}
+            onSelect={(contact) => void assign(pickerRole, contact)}
+            onClose={() => setPickerRole(null)}
+          />
+        )}
+
         {/* 업무단계 현황 */}
         <section className="mt-5">
           <h2 className="text-sm font-semibold">업무단계 현황</h2>
Add a comment
List