ITN Dev 07-23
feat: 채널관리 담당자 입력을 행 단위 표로, 기관관리는 카드형으로 복귀
@91cdf521ba7b69568e72a14ee20af052fba49817
frontend/src/components/OrgDetail.test.tsx
--- frontend/src/components/OrgDetail.test.tsx
+++ frontend/src/components/OrgDetail.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { beforeEach, describe, expect, it, vi } from 'vitest'
 import OrgDetail from './OrgDetail'
 import type { Org } from '../api/client'
@@ -39,14 +39,6 @@
   }
 }
 
-/** 세 담당자 그룹은 부서명/연락처/이메일 같은 라벨을 공유하므로, 그룹 제목으로 찾은
- * 컨테이너 안에서만 찾도록 스코프를 좁힌다 - 그렇지 않으면 getByLabelText가
- * "여러 개 일치"로 실패한다. */
-function group(title: string) {
-  const heading = screen.getByText(title)
-  return within(heading.closest('div') as HTMLElement)
-}
-
 beforeEach(() => {
   mocks.updateContact.mockReset()
   mocks.provisionChannels.mockReset()
@@ -70,13 +62,11 @@
     const onChanged = vi.fn()
 
     render(<OrgDetail org={org()} onChanged={onChanged} />)
-
-    const applicant = group('신청기관 담당자')
-    fireEvent.change(applicant.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } })
-    fireEvent.change(applicant.getByLabelText('담당자명'), { target: { value: '송민지' } })
-    fireEvent.change(applicant.getByLabelText('직급/직함'), { target: { value: '과장' } })
-    fireEvent.change(applicant.getByLabelText('연락처'), { target: { value: '02-3475-5434' } })
-    fireEvent.change(applicant.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 부서명'), { target: { value: '데이터정보화팀' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 담당자명'), { target: { value: '송민지' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 직급/직함'), { target: { value: '과장' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 연락처'), { target: { value: '02-3475-5434' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 이메일'), { target: { value: 'ming@arirang.com' } })
     fireEvent.click(screen.getByRole('button', { name: '정보 저장' }))
 
     await waitFor(() => {
@@ -104,24 +94,18 @@
     const onChanged = vi.fn()
 
     render(<OrgDetail org={org()} onChanged={onChanged} />)
-
-    const applicant = group('신청기관 담당자')
-    fireEvent.change(applicant.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } })
-    fireEvent.change(applicant.getByLabelText('담당자명'), { target: { value: '송민지' } })
-    fireEvent.change(applicant.getByLabelText('연락처'), { target: { value: '02-3475-5434' } })
-    fireEvent.change(applicant.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } })
-
-    const mj = group('문정원 담당자')
-    fireEvent.change(mj.getByLabelText('부서명'), { target: { value: '문화체육관광부 저작권정책과' } })
-    fireEvent.change(mj.getByLabelText('담당자명'), { target: { value: '김문정' } })
-    fireEvent.change(mj.getByLabelText('연락처'), { target: { value: '02-1234-5678' } })
-    fireEvent.change(mj.getByLabelText('이메일'), { target: { value: 'mj@mcst.go.kr' } })
-
-    const lawyer = group('담당 변호사')
-    fireEvent.change(lawyer.getByLabelText('성명'), { target: { value: '이변호' } })
-    fireEvent.change(lawyer.getByLabelText('연락처'), { target: { value: '02-9876-5432' } })
-    fireEvent.change(lawyer.getByLabelText('이메일'), { target: { value: 'lawyer@lawfirm.kr' } })
-    fireEvent.change(lawyer.getByLabelText('배정일'), { target: { value: '2026-07-21' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 부서명'), { target: { value: '데이터정보화팀' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 담당자명'), { target: { value: '송민지' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 연락처'), { target: { value: '02-3475-5434' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 이메일'), { target: { value: 'ming@arirang.com' } })
+    fireEvent.change(screen.getByLabelText('문정원 담당자 부서명'), { target: { value: '문화체육관광부 저작권정책과' } })
+    fireEvent.change(screen.getByLabelText('문정원 담당자 담당자명'), { target: { value: '김문정' } })
+    fireEvent.change(screen.getByLabelText('문정원 담당자 연락처'), { target: { value: '02-1234-5678' } })
+    fireEvent.change(screen.getByLabelText('문정원 담당자 이메일'), { target: { value: 'mj@mcst.go.kr' } })
+    fireEvent.change(screen.getByLabelText('담당 변호사 성명'), { target: { value: '이변호' } })
+    fireEvent.change(screen.getByLabelText('담당 변호사 연락처'), { target: { value: '02-9876-5432' } })
+    fireEvent.change(screen.getByLabelText('담당 변호사 이메일'), { target: { value: 'lawyer@lawfirm.kr' } })
+    fireEvent.change(screen.getByLabelText('담당 변호사 배정일'), { target: { value: '2026-07-21' } })
 
     fireEvent.click(screen.getByRole('button', { name: '정보 저장' }))
 
@@ -253,24 +237,18 @@
 
   it('직급/직함이 비어있어도 나머지 필수 항목만 채우면 정보 저장 버튼이 활성화된다', () => {
     render(<OrgDetail org={org()} onChanged={() => {}} />)
-
-    const applicant = group('신청기관 담당자')
-    fireEvent.change(applicant.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } })
-    fireEvent.change(applicant.getByLabelText('담당자명'), { target: { value: '송민지' } })
-    fireEvent.change(applicant.getByLabelText('연락처'), { target: { value: '02-3475-5434' } })
-    fireEvent.change(applicant.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 부서명'), { target: { value: '데이터정보화팀' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 담당자명'), { target: { value: '송민지' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 연락처'), { target: { value: '02-3475-5434' } })
+    fireEvent.change(screen.getByLabelText('신청기관 담당자 이메일'), { target: { value: 'ming@arirang.com' } })
 
     expect(screen.getByRole('button', { name: '정보 저장' })).toBeEnabled()
   })
 
   it('문정원 담당자와 담당 변호사 항목을 채워도 신청기관 필수 항목이 비어있으면 저장 버튼은 비활성이다', () => {
     render(<OrgDetail org={org()} onChanged={() => {}} />)
-
-    const mj = group('문정원 담당자')
-    fireEvent.change(mj.getByLabelText('담당자명'), { target: { value: '김문정' } })
-
-    const lawyer = group('담당 변호사')
-    fireEvent.change(lawyer.getByLabelText('성명'), { target: { value: '이변호' } })
+    fireEvent.change(screen.getByLabelText('문정원 담당자 담당자명'), { target: { value: '김문정' } })
+    fireEvent.change(screen.getByLabelText('담당 변호사 성명'), { target: { value: '이변호' } })
 
     expect(screen.getByRole('button', { name: '정보 저장' })).toBeDisabled()
   })
frontend/src/components/OrgDetail.tsx
--- frontend/src/components/OrgDetail.tsx
+++ frontend/src/components/OrgDetail.tsx
@@ -9,26 +9,50 @@
 import ConfirmDialog from './ConfirmDialog'
 import StatusBadge from './StatusBadge'
 
-const APPLICANT_FIELDS: { key: keyof Contact; label: string }[] = [
-  { key: 'deptName', label: '부서명' },
-  { key: 'managerName', label: '담당자명' },
-  { key: 'managerTitle', label: '직급/직함' },
-  { key: 'managerPhone', label: '연락처' },
-  { key: 'managerEmail', label: '이메일' },
-]
+// 엑셀처럼 한 사람이 한 행이다. 셀이 null이면 그 역할에 해당 항목이 없다는 뜻(- 표시).
+// aria-label은 "행 제목 + 항목"으로 유일하게 만들어 라벨 중복 없이 접근 가능하게 한다.
+const CONTACT_COLUMNS = ['부서명', '담당자명', '직급/직함', '연락처', '이메일', '배정일']
 
-const MJ_FIELDS: { key: keyof Contact; label: string; type?: string }[] = [
-  { key: 'mjDeptName', label: '부서명' },
-  { key: 'mjManagerName', label: '담당자명' },
-  { key: 'mjManagerPhone', label: '연락처' },
-  { key: 'mjManagerEmail', label: '이메일' },
-]
+interface ContactCell {
+  key: keyof Contact
+  label: string
+  type?: string
+}
 
-const LAWYER_FIELDS: { key: keyof Contact; label: string; type?: string }[] = [
-  { key: 'lawyerName', label: '성명' },
-  { key: 'lawyerPhone', label: '연락처' },
-  { key: 'lawyerEmail', label: '이메일' },
-  { key: 'lawyerAssignedDate', label: '배정일', type: 'date' },
+const CONTACT_ROWS: { title: string; cells: (ContactCell | null)[] }[] = [
+  {
+    title: '신청기관 담당자',
+    cells: [
+      { key: 'deptName', label: '신청기관 담당자 부서명' },
+      { key: 'managerName', label: '신청기관 담당자 담당자명' },
+      { key: 'managerTitle', label: '신청기관 담당자 직급/직함' },
+      { key: 'managerPhone', label: '신청기관 담당자 연락처' },
+      { key: 'managerEmail', label: '신청기관 담당자 이메일' },
+      null,
+    ],
+  },
+  {
+    title: '문정원 담당자',
+    cells: [
+      { key: 'mjDeptName', label: '문정원 담당자 부서명' },
+      { key: 'mjManagerName', label: '문정원 담당자 담당자명' },
+      null,
+      { key: 'mjManagerPhone', label: '문정원 담당자 연락처' },
+      { key: 'mjManagerEmail', label: '문정원 담당자 이메일' },
+      null,
+    ],
+  },
+  {
+    title: '담당 변호사',
+    cells: [
+      null,
+      { key: 'lawyerName', label: '담당 변호사 성명' },
+      null,
+      { key: 'lawyerPhone', label: '담당 변호사 연락처' },
+      { key: 'lawyerEmail', label: '담당 변호사 이메일' },
+      { key: 'lawyerAssignedDate', label: '담당 변호사 배정일', type: 'date' },
+    ],
+  },
 ]
 
 function emptyContact(org: Org): Contact {
@@ -56,36 +80,54 @@
   'managerEmail',
 ]
 
-function ContactFieldGroup({
-  title,
-  fields,
+function ContactEditTable({
   contact,
   onChange,
 }: {
-  title: string
-  fields: { key: keyof Contact; label: string; type?: string }[]
   contact: Contact
   onChange: (key: keyof Contact, value: string) => void
 }) {
   return (
-    <div className="rounded-lg border border-gray-200 p-4">
-      <h3 className="text-sm font-medium text-gray-700">{title}</h3>
-      <div className="mt-3 space-y-3">
-        {fields.map((field) => (
-          <div key={field.key} className="flex items-center gap-3">
-            <label htmlFor={field.key} className="w-20 shrink-0 text-sm text-gray-500">
-              {field.label}
-            </label>
-            <input
-              id={field.key}
-              type={field.type ?? 'text'}
-              className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm"
-              value={contact[field.key]}
-              onChange={(e) => onChange(field.key, e.target.value)}
-            />
-          </div>
-        ))}
-      </div>
+    <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
+      <table className="w-full min-w-[860px] text-sm">
+        <thead>
+          <tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
+            <th className="whitespace-nowrap px-3 py-2 font-medium">구분</th>
+            {CONTACT_COLUMNS.map((col) => (
+              <th key={col} className="whitespace-nowrap px-3 py-2 font-medium">
+                {col}
+              </th>
+            ))}
+          </tr>
+        </thead>
+        <tbody>
+          {CONTACT_ROWS.map((row) => (
+            <tr key={row.title} className="border-b border-gray-100 last:border-b-0">
+              <th
+                scope="row"
+                className="whitespace-nowrap px-3 py-2 text-left text-sm font-medium text-gray-700"
+              >
+                {row.title}
+              </th>
+              {row.cells.map((cell, i) => (
+                <td key={cell?.key ?? `empty-${i}`} className="px-2 py-1.5">
+                  {cell ? (
+                    <input
+                      aria-label={cell.label}
+                      type={cell.type ?? 'text'}
+                      className="w-full min-w-[7rem] rounded-md border border-gray-300 px-2.5 py-1.5 text-sm"
+                      value={contact[cell.key]}
+                      onChange={(e) => onChange(cell.key, e.target.value)}
+                    />
+                  ) : (
+                    <span className="block text-center text-gray-300">-</span>
+                  )}
+                </td>
+              ))}
+            </tr>
+          ))}
+        </tbody>
+      </table>
     </div>
   )
 }
@@ -154,25 +196,11 @@
         <StatusBadge status={org.status} />
       </div>
 
-      <section className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
-        <ContactFieldGroup
-          title="신청기관 담당자"
-          fields={APPLICANT_FIELDS}
-          contact={contact}
-          onChange={setField}
-        />
-        <ContactFieldGroup
-          title="문정원 담당자"
-          fields={MJ_FIELDS}
-          contact={contact}
-          onChange={setField}
-        />
-        <ContactFieldGroup
-          title="담당 변호사"
-          fields={LAWYER_FIELDS}
-          contact={contact}
-          onChange={setField}
-        />
+      <section className="mt-6">
+        <ContactEditTable contact={contact} onChange={setField} />
+        <p className="mt-2 text-xs text-gray-400">
+          신청기관 담당자의 부서명·담당자명·연락처·이메일은 채널 생성을 위해 필수입니다.
+        </p>
       </section>
 
       <div className="mt-5 flex gap-2">
frontend/src/components/OrgOverview.test.tsx
--- frontend/src/components/OrgOverview.test.tsx
+++ frontend/src/components/OrgOverview.test.tsx
@@ -59,21 +59,18 @@
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('문정원 담당자와 담당 변호사 정보가 비어 있으면 표에 -로 표시된다', async () => {
+  it('문정원 담당자와 담당 변호사 정보가 비어 있으면 카드에 -로 표시된다', async () => {
     render(<OrgOverview org={org()} />)
 
-    const rows = screen.getAllByRole('row')
-    const mjRow = rows.find((row) => row.textContent?.includes('문정원 담당자'))
-    const lawyerRow = rows.find((row) => row.textContent?.includes('담당 변호사'))
+    const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
+    const lawyerCard = screen.getByText('담당 변호사').closest('section') as HTMLElement
 
-    expect(mjRow).toBeTruthy()
-    expect(lawyerRow).toBeTruthy()
-    expect(within(mjRow as HTMLElement).getAllByText('-')).toHaveLength(5)
-    expect(within(lawyerRow as HTMLElement).getAllByText('-')).toHaveLength(5)
+    expect(within(mjCard).getAllByText('-')).toHaveLength(4)
+    expect(within(lawyerCard).getAllByText('-')).toHaveLength(4)
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
 
-  it('담당자 3종이 값을 가지고 있으면 표의 각 행에 값이 표시된다', async () => {
+  it('담당자 3종이 값을 가지고 있으면 각 카드에 값이 표시된다', async () => {
     render(
       <OrgOverview
         org={org({
@@ -89,23 +86,22 @@
       />,
     )
 
-    const rows = screen.getAllByRole('row')
-    const applicantRow = rows.find((row) => row.textContent?.includes('신청기관 담당자'))
-    const mjRow = rows.find((row) => row.textContent?.includes('문정원 담당자'))
-    const lawyerRow = rows.find((row) => row.textContent?.includes('담당 변호사'))
+    const applicantCard = screen.getByText('신청기관 담당자').closest('section') as HTMLElement
+    const mjCard = screen.getByText('문정원 담당자').closest('section') as HTMLElement
+    const lawyerCard = screen.getByText('담당 변호사').closest('section') as HTMLElement
 
-    expect(within(applicantRow as HTMLElement).getByText('데이터정보화팀')).toBeTruthy()
-    expect(within(applicantRow as HTMLElement).getByText('송민지 과장')).toBeTruthy()
+    expect(within(applicantCard).getByText('데이터정보화팀')).toBeTruthy()
+    expect(within(applicantCard).getByText('송민지 과장')).toBeTruthy()
 
-    expect(within(mjRow as HTMLElement).getByText('문화체육관광부 저작권정책과')).toBeTruthy()
-    expect(within(mjRow as HTMLElement).getByText('김문정')).toBeTruthy()
-    expect(within(mjRow as HTMLElement).getByText('02-1234-5678')).toBeTruthy()
-    expect(within(mjRow as HTMLElement).getByText('mj@mcst.go.kr')).toBeTruthy()
+    expect(within(mjCard).getByText('문화체육관광부 저작권정책과')).toBeTruthy()
+    expect(within(mjCard).getByText('김문정')).toBeTruthy()
+    expect(within(mjCard).getByText('02-1234-5678')).toBeTruthy()
+    expect(within(mjCard).getByText('mj@mcst.go.kr')).toBeTruthy()
 
-    expect(within(lawyerRow as HTMLElement).getByText('이변호')).toBeTruthy()
-    expect(within(lawyerRow as HTMLElement).getByText('02-9876-5432')).toBeTruthy()
-    expect(within(lawyerRow as HTMLElement).getByText('lawyer@lawfirm.kr')).toBeTruthy()
-    expect(within(lawyerRow as HTMLElement).getByText('2026-07-21')).toBeTruthy()
+    expect(within(lawyerCard).getByText('이변호')).toBeTruthy()
+    expect(within(lawyerCard).getByText('02-9876-5432')).toBeTruthy()
+    expect(within(lawyerCard).getByText('lawyer@lawfirm.kr')).toBeTruthy()
+    expect(within(lawyerCard).getByText('2026-07-21')).toBeTruthy()
 
     await waitFor(() => expect(mocks.getPosts).toHaveBeenCalled())
   })
frontend/src/components/OrgOverview.tsx
--- frontend/src/components/OrgOverview.tsx
+++ frontend/src/components/OrgOverview.tsx
@@ -41,50 +41,34 @@
   return MATTERMOST_BASE + name
 }
 
-function Cell({ value }: { value: string | null }) {
-  return <span className={value ? 'text-gray-900' : 'text-gray-300'}>{value ?? '-'}</span>
+function Field({ label, value }: { label: string; value: string | null }) {
+  return (
+    <div className="flex gap-3 text-sm">
+      <span className="w-14 shrink-0 text-gray-400">{label}</span>
+      <span className={value ? 'font-medium text-gray-900' : 'text-gray-300'}>
+        {value ?? '-'}
+      </span>
+    </div>
+  )
 }
 
-interface ContactRow {
-  role: string
-  dept: string | null
-  manager: string | null
-  phone: string | null
-  email: string | null
-  assignedDate: string | null
-}
-
-function contactRows(org: Org): ContactRow[] {
-  return [
-    {
-      role: '신청기관 담당자',
-      dept: org.deptName,
-      manager: org.managerName
-        ? org.managerTitle
-          ? `${org.managerName} ${org.managerTitle}`
-          : org.managerName
-        : null,
-      phone: org.managerPhone,
-      email: org.managerEmail,
-      assignedDate: null,
-    },
-    {
-      role: '문정원 담당자',
-      dept: org.mjDeptName,
-      manager: org.mjManagerName,
-      phone: org.mjManagerPhone,
-      email: org.mjManagerEmail,
-      assignedDate: null,
-    },
-    {
-      role: '담당 변호사',
-      dept: null,
-      manager: org.lawyerName,
-      phone: org.lawyerPhone,
-      email: org.lawyerEmail,
-      assignedDate: org.lawyerAssignedDate,
-    },
-  ]
+function ContactCard({
+  title,
+  fields,
+}: {
+  title: string
+  fields: { label: string; value: string | null }[]
+}) {
+  return (
+    <section className="rounded-lg border border-gray-200 p-4">
+      <h2 className="text-sm font-semibold">{title}</h2>
+      <div className="mt-3 space-y-1.5">
+        {fields.map((f) => (
+          <Field key={f.label} label={f.label} value={f.value} />
+        ))}
+      </div>
+    </section>
+  )
 }
 
 export default function OrgOverview({ org }: { org: Org }) {
@@ -136,43 +120,43 @@
           </div>
         </div>
 
-        {/* 담당자 3종 (신청기관/문정원/변호사) */}
-        <section className="mt-5 overflow-x-auto rounded-lg border border-gray-200">
-          <table className="w-full text-left text-sm">
-            <thead>
-              <tr className="border-b border-gray-200 text-xs text-gray-400">
-                <th className="px-4 py-2 font-medium">구분</th>
-                <th className="px-4 py-2 font-medium">부서</th>
-                <th className="px-4 py-2 font-medium">담당자</th>
-                <th className="px-4 py-2 font-medium">연락처</th>
-                <th className="px-4 py-2 font-medium">이메일</th>
-                <th className="px-4 py-2 font-medium">배정일</th>
-              </tr>
-            </thead>
-            <tbody>
-              {contactRows(org).map((row) => (
-                <tr key={row.role} className="border-b border-gray-100 last:border-b-0">
-                  <td className="px-4 py-2 font-medium text-gray-700">{row.role}</td>
-                  <td className="px-4 py-2">
-                    <Cell value={row.dept} />
-                  </td>
-                  <td className="px-4 py-2">
-                    <Cell value={row.manager} />
-                  </td>
-                  <td className="px-4 py-2">
-                    <Cell value={row.phone} />
-                  </td>
-                  <td className="px-4 py-2">
-                    <Cell value={row.email} />
-                  </td>
-                  <td className="px-4 py-2">
-                    <Cell value={row.assignedDate} />
-                  </td>
-                </tr>
-              ))}
-            </tbody>
-          </table>
-        </section>
+        {/* 담당자 카드 3개 (신청기관/문정원/변호사) */}
+        <div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
+          <ContactCard
+            title="신청기관 담당자"
+            fields={[
+              { label: '부서', value: org.deptName },
+              {
+                label: '담당자',
+                value: org.managerName
+                  ? org.managerTitle
+                    ? `${org.managerName} ${org.managerTitle}`
+                    : org.managerName
+                  : null,
+              },
+              { label: '연락처', value: org.managerPhone },
+              { label: '이메일', value: org.managerEmail },
+            ]}
+          />
+          <ContactCard
+            title="문정원 담당자"
+            fields={[
+              { label: '부서', value: org.mjDeptName },
+              { label: '담당자', value: org.mjManagerName },
+              { label: '연락처', value: org.mjManagerPhone },
+              { label: '이메일', value: org.mjManagerEmail },
+            ]}
+          />
+          <ContactCard
+            title="담당 변호사"
+            fields={[
+              { label: '성명', value: org.lawyerName },
+              { label: '연락처', value: org.lawyerPhone },
+              { label: '이메일', value: org.lawyerEmail },
+              { label: '배정일', value: org.lawyerAssignedDate },
+            ]}
+          />
+        </div>
 
         {/* 업무단계 현황 */}
         <section className="mt-5">
Add a comment
List