ITN Dev 07-22
feat: 기관 상세 입력폼과 채널 생성 화면 추가
@b87b5e54b01835b83041ce22e0bd7bfc7f23d11a
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -1,19 +1,25 @@
 import { useCallback, useEffect, useState } from 'react'
 import { getOrgs, type Org } from './api/client'
 import LoginPage from './components/LoginPage'
+import OrgDetail from './components/OrgDetail'
 import OrgList from './components/OrgList'
+import SeedUpload from './components/SeedUpload'
+
+// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
+// 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다.
+type AuthState = 'checking' | 'authenticated' | 'anonymous'
 
 export default function App() {
   const [orgs, setOrgs] = useState<Org[]>([])
   const [selectedId, setSelectedId] = useState<number | null>(null)
-  const [authenticated, setAuthenticated] = useState(true)
+  const [auth, setAuth] = useState<AuthState>('checking')
 
   const reload = useCallback(async () => {
     try {
       setOrgs(await getOrgs())
-      setAuthenticated(true)
+      setAuth('authenticated')
     } catch {
-      setAuthenticated(false)
+      setAuth('anonymous')
     }
   }, [])
 
@@ -21,16 +27,29 @@
     void reload()
   }, [reload])
 
-  if (!authenticated) {
+  if (auth === 'checking') {
+    return <div className="p-8 text-sm text-gray-500">불러오는 중…</div>
+  }
+
+  if (auth === 'anonymous') {
     return <LoginPage onSuccess={() => void reload()} />
   }
 
+  const selected = orgs.find((org) => org.id === selectedId) ?? null
+
   return (
     <div className="flex h-screen">
-      <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
-      <main className="flex-1 overflow-y-auto p-8">
-        <h1 className="text-lg font-semibold">기관관리</h1>
-        <p className="mt-2 text-sm text-gray-500">기관 {orgs.length}개</p>
+      <div className="flex w-80 flex-col border-r border-gray-200">
+        <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
+        <SeedUpload onUploaded={() => void reload()} />
+      </div>
+
+      <main className="flex-1 overflow-y-auto">
+        {selected ? (
+          <OrgDetail org={selected} onChanged={() => void reload()} />
+        ) : (
+          <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
+        )}
       </main>
     </div>
   )
 
frontend/src/components/ConfirmDialog.tsx (added)
+++ frontend/src/components/ConfirmDialog.tsx
@@ -0,0 +1,40 @@
+interface Props {
+  title: string
+  lines: string[]
+  confirmLabel: string
+  onConfirm: () => void
+  onCancel: () => void
+}
+
+export default function ConfirmDialog({ title, lines, confirmLabel, onConfirm, onCancel }: Props) {
+  return (
+    <div className="fixed inset-0 flex items-center justify-center bg-black/30">
+      <div className="w-96 rounded-lg bg-white p-5">
+        <h2 className="text-base font-semibold">{title}</h2>
+
+        <ul className="mt-3 space-y-1 rounded-md bg-gray-50 p-3 text-sm">
+          {lines.map((line) => (
+            <li key={line} className="font-mono text-xs">{line}</li>
+          ))}
+        </ul>
+
+        <div className="mt-5 flex justify-end gap-2">
+          <button
+            type="button"
+            onClick={onCancel}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+          >
+            취소
+          </button>
+          <button
+            type="button"
+            onClick={onConfirm}
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white"
+          >
+            {confirmLabel}
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}
 
frontend/src/components/OrgDetail.test.tsx (added)
+++ frontend/src/components/OrgDetail.test.tsx
@@ -0,0 +1,115 @@
+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'
+
+const mocks = vi.hoisted(() => ({
+  updateContact: vi.fn(),
+  provisionChannels: vi.fn(),
+}))
+
+vi.mock('../api/client', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../api/client')>()),
+  updateContact: mocks.updateContact,
+  provisionChannels: mocks.provisionChannels,
+}))
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    status: 'INFO_PENDING',
+    deptName: null,
+    managerName: null,
+    managerTitle: null,
+    managerPhone: null,
+    managerEmail: null,
+    channelIdMj: null,
+    channelIdLaw: null,
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  mocks.updateContact.mockReset()
+  mocks.provisionChannels.mockReset()
+})
+
+describe('OrgDetail', () => {
+  it('정보 대기 상태면 채널 생성 버튼이 비활성이다', () => {
+    render(<OrgDetail org={org()} onChanged={() => {}} />)
+
+    expect(screen.getByRole('button', { name: '채널 생성' })).toBeDisabled()
+  })
+
+  it('생성 가능 상태면 채널 생성 버튼이 활성이다', () => {
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled()
+  })
+
+  it('담당자 정보를 저장하면 API를 호출한다', async () => {
+    mocks.updateContact.mockResolvedValue(org({ status: 'READY' }))
+    const onChanged = vi.fn()
+
+    render(<OrgDetail org={org()} onChanged={onChanged} />)
+
+    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(() => {
+      expect(mocks.updateContact).toHaveBeenCalledWith(1, {
+        deptName: '데이터정보화팀',
+        managerName: '송민지',
+        managerTitle: '과장',
+        managerPhone: '02-3475-5434',
+        managerEmail: 'ming@arirang.com',
+      })
+      expect(onChanged).toHaveBeenCalled()
+    })
+  })
+
+  it('채널 생성은 확인 다이얼로그에 만들어질 채널 2개를 보여준다', () => {
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+
+    expect(screen.getByText('001_국제방송교류재단 (문정원)')).toBeTruthy()
+    expect(screen.getByText('001_국제방송교류재단 (법률검토)')).toBeTruthy()
+    expect(mocks.provisionChannels).not.toHaveBeenCalled()
+  })
+
+  it('확인을 눌러야 채널 생성 API가 호출된다', async () => {
+    mocks.provisionChannels.mockResolvedValue({ mj: 'CREATED', law: 'CREATED', message: '' })
+
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => expect(mocks.provisionChannels).toHaveBeenCalledWith(1))
+  })
+
+  it('부분 실패하면 재시도 안내를 보여준다', async () => {
+    mocks.provisionChannels.mockResolvedValue({
+      mj: 'CREATED',
+      law: 'FAILED',
+      message: '[법률검토] 서버 오류',
+    })
+
+    render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '채널 생성' }))
+    fireEvent.click(screen.getByRole('button', { name: '생성' }))
+
+    await waitFor(() => {
+      expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy()
+      expect(screen.getByText(/다시 눌러도 안전/)).toBeTruthy()
+    })
+  })
+})
 
frontend/src/components/OrgDetail.tsx (added)
+++ frontend/src/components/OrgDetail.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useState } from 'react'
+import {
+  provisionChannels,
+  updateContact,
+  type Contact,
+  type Org,
+  type ProvisionResult,
+} from '../api/client'
+import ConfirmDialog from './ConfirmDialog'
+import StatusBadge from './StatusBadge'
+
+const FIELDS: { key: keyof Contact; label: string }[] = [
+  { key: 'deptName', label: '부서명' },
+  { key: 'managerName', label: '담당자명' },
+  { key: 'managerTitle', label: '직급/직함' },
+  { key: 'managerPhone', label: '연락처' },
+  { key: 'managerEmail', label: '이메일' },
+]
+
+function emptyContact(org: Org): Contact {
+  return {
+    deptName: org.deptName ?? '',
+    managerName: org.managerName ?? '',
+    managerTitle: org.managerTitle ?? '',
+    managerPhone: org.managerPhone ?? '',
+    managerEmail: org.managerEmail ?? '',
+  }
+}
+
+export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) {
+  const [contact, setContact] = useState<Contact>(emptyContact(org))
+  const [confirming, setConfirming] = useState(false)
+  const [result, setResult] = useState<ProvisionResult | null>(null)
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    setContact(emptyContact(org))
+    setResult(null)
+    setError(null)
+  }, [org])
+
+  const displayMj = `${org.orgNo}_${org.orgName} (문정원)`
+  const displayLaw = `${org.orgNo}_${org.orgName} (법률검토)`
+  const canProvision = org.status !== 'INFO_PENDING'
+
+  async function save() {
+    setError(null)
+    try {
+      await updateContact(org.id, contact)
+      onChanged()
+    } catch (e) {
+      setError((e as Error).message)
+    }
+  }
+
+  async function provision() {
+    setConfirming(false)
+    setError(null)
+    try {
+      setResult(await provisionChannels(org.id))
+      onChanged()
+    } catch (e) {
+      setError((e as Error).message)
+    }
+  }
+
+  return (
+    <div className="p-8">
+      <div className="flex items-center gap-3">
+        <h1 className="text-lg font-semibold">
+          {org.orgNo} {org.orgName}
+        </h1>
+        <StatusBadge status={org.status} />
+      </div>
+
+      <section className="mt-6 max-w-xl rounded-lg border border-gray-200 p-5">
+        <h2 className="text-sm font-medium">신청기관 담당자</h2>
+
+        <div className="mt-4 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}
+                className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm"
+                value={contact[field.key]}
+                onChange={(e) => setContact({ ...contact, [field.key]: e.target.value })}
+              />
+            </div>
+          ))}
+        </div>
+
+        <div className="mt-5 flex gap-2">
+          <button
+            type="button"
+            onClick={() => void save()}
+            className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
+          >
+            정보 저장
+          </button>
+          <button
+            type="button"
+            disabled={!canProvision}
+            onClick={() => setConfirming(true)}
+            className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
+          >
+            채널 생성
+          </button>
+        </div>
+      </section>
+
+      {error && <p className="mt-4 text-sm text-red-600">{error}</p>}
+
+      {result && (
+        <div className="mt-4 max-w-xl rounded-lg border border-gray-200 p-4 text-sm">
+          <p>문정원 채널: {outcomeLabel(result.mj)}</p>
+          <p>법률검토 채널: {outcomeLabel(result.law)}</p>
+
+          {result.law === 'FAILED' && (
+            <p className="mt-2 text-amber-700">
+              법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니
+              [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다.
+            </p>
+          )}
+          {result.mj === 'FAILED' && (
+            <p className="mt-2 text-amber-700">
+              문정원 채널 생성에 실패했습니다. [채널 생성]을 다시 눌러도 안전합니다.
+            </p>
+          )}
+          {result.message && (
+            <pre className="mt-2 whitespace-pre-wrap text-xs text-gray-500">{result.message}</pre>
+          )}
+        </div>
+      )}
+
+      {confirming && (
+        <ConfirmDialog
+          title="아래 채널 2개를 Mattermost에 생성합니다."
+          lines={[displayMj, displayLaw]}
+          confirmLabel="생성"
+          onConfirm={() => void provision()}
+          onCancel={() => setConfirming(false)}
+        />
+      )}
+    </div>
+  )
+}
+
+function outcomeLabel(outcome: ProvisionResult['mj']): string {
+  switch (outcome) {
+    case 'CACHED':
+      return '이미 있음 (변경 없음)'
+    case 'RECOVERED':
+      return '기존 채널 연결됨'
+    case 'CREATED':
+      return '새로 생성됨'
+    case 'FAILED':
+      return '실패'
+  }
+}
frontend/src/components/OrgList.tsx
--- frontend/src/components/OrgList.tsx
+++ frontend/src/components/OrgList.tsx
@@ -14,7 +14,7 @@
   const visible = orgs.filter((org) => org.orgName.includes(keyword))
 
   return (
-    <div className="flex h-full w-80 flex-col border-r border-gray-200">
+    <div className="flex h-full flex-col overflow-hidden">
       <div className="border-b border-gray-200 p-3">
         <input
           className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
 
frontend/src/components/SeedUpload.tsx (added)
+++ frontend/src/components/SeedUpload.tsx
@@ -0,0 +1,48 @@
+import { useRef, useState } from 'react'
+import { uploadSeed, type SeedReport } from '../api/client'
+
+export default function SeedUpload({ onUploaded }: { onUploaded: () => void }) {
+  const inputRef = useRef<HTMLInputElement>(null)
+  const [report, setReport] = useState<SeedReport | null>(null)
+  const [error, setError] = useState<string | null>(null)
+
+  async function handle(file: File) {
+    setError(null)
+    try {
+      setReport(await uploadSeed(file))
+      onUploaded()
+    } catch (e) {
+      setError((e as Error).message)
+    }
+  }
+
+  return (
+    <div className="border-t border-gray-200 p-3">
+      <input
+        ref={inputRef}
+        type="file"
+        accept=".xlsx,.xlsm"
+        className="hidden"
+        onChange={(e) => {
+          const file = e.target.files?.[0]
+          if (file) void handle(file)
+          e.target.value = ''
+        }}
+      />
+      <button
+        type="button"
+        onClick={() => inputRef.current?.click()}
+        className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
+      >
+        신청목록 시트 올리기
+      </button>
+
+      {report && (
+        <p className="mt-2 text-xs text-gray-500">
+          신규 {report.created} / 갱신 {report.updated} / 전체 {report.total}
+        </p>
+      )}
+      {error && <p className="mt-2 text-xs text-red-600">{error}</p>}
+    </div>
+  )
+}
Add a comment
List