ITN Dev 07-22
feat: 기관관리 상세를 시안 레이아웃으로 분리하고 채널생성 메뉴 신설
@738fb0262e88e8bf8976855a46c5d6d5c387b3e3
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -3,8 +3,17 @@
 import LoginPage from './components/LoginPage'
 import OrgDetail from './components/OrgDetail'
 import OrgList from './components/OrgList'
+import OrgOverview from './components/OrgOverview'
 import SeedUpload from './components/SeedUpload'
 import Sidebar from './components/Sidebar'
+
+// 화면 2개: 기관관리(시안 레이아웃의 기관 상세)와 채널생성(명부 시드 + 담당자 입력 + 채널 생성).
+type Page = 'orgs' | 'channels'
+
+const PAGE_TITLE: Record<Page, string> = {
+  orgs: '기관관리',
+  channels: '채널생성',
+}
 
 // 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
 // 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다.
@@ -20,6 +29,7 @@
   const [selectedId, setSelectedId] = useState<number | null>(null)
   const [auth, setAuth] = useState<AuthState>('checking')
   const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null)
+  const [page, setPage] = useState<Page>('orgs')
 
   const reload = useCallback(async () => {
     try {
@@ -78,11 +88,18 @@
 
   return (
     <div className="flex h-screen">
-      <Sidebar />
+      <Sidebar
+        activeKey={page}
+        onSelect={(key) => {
+          if (key === 'orgs' || key === 'channels') {
+            setPage(key)
+          }
+        }}
+      />
 
       <div className="flex min-w-0 flex-1 flex-col">
         <header className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-6">
-          <span className="text-sm font-medium">기관관리</span>
+          <span className="text-sm font-medium">{PAGE_TITLE[page]}</span>
           <div className="flex items-center gap-3">
             <span className="text-sm text-gray-500">관리자</span>
             <button
@@ -96,14 +113,18 @@
         </header>
 
         <div className="flex min-h-0 flex-1">
-          <div className="flex w-80 flex-col border-r border-gray-200">
+          <div className="flex w-80 shrink-0 flex-col border-r border-gray-200">
             <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
-            <SeedUpload onUploaded={() => void reload()} />
+            {page === 'channels' && <SeedUpload onUploaded={() => void reload()} />}
           </div>
 
-          <main className="flex-1 overflow-y-auto">
+          <main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50">
             {selected ? (
-              <OrgDetail org={selected} onChanged={() => void reload()} />
+              page === 'channels' ? (
+                <OrgDetail org={selected} onChanged={() => void reload()} />
+              ) : (
+                <OrgOverview org={selected} />
+              )
             ) : (
               <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
             )}
 
frontend/src/components/OrgOverview.test.tsx (added)
+++ frontend/src/components/OrgOverview.test.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import OrgOverview from './OrgOverview'
+import type { Org } from '../api/client'
+
+function org(overrides: Partial<Org> = {}): Org {
+  return {
+    id: 1,
+    orgNo: '001',
+    orgName: '국제방송교류재단',
+    status: 'ACTIVE',
+    deptName: '데이터정보화팀',
+    managerName: '송민지',
+    managerTitle: '과장',
+    managerPhone: '02-3475-5434',
+    managerEmail: 'ming@arirang.com',
+    channelIdMj: 'chan-mj',
+    channelIdLaw: 'chan-law',
+    ...overrides,
+  }
+}
+
+describe('OrgOverview', () => {
+  it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.getByText('001_국제방송교류재단')).toBeTruthy()
+    expect(screen.getByText('데이터정보화팀')).toBeTruthy()
+    expect(screen.getByText('송민지 과장')).toBeTruthy()
+    expect(screen.getByText('02-3475-5434')).toBeTruthy()
+    expect(screen.getByText('ming@arirang.com')).toBeTruthy()
+  })
+
+  it('아직 연동 전인 문정원 담당자와 담당 변호사 카드는 자리만 보여준다', () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.getByText('문정원 담당자')).toBeTruthy()
+    expect(screen.getByText('담당 변호사')).toBeTruthy()
+    expect(screen.getAllByText('추후 연동')).toHaveLength(2)
+  })
+
+  it('업무단계 12개가 순서대로 보인다', () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.getByText('신청')).toBeTruthy()
+    expect(screen.getByText('변호사 배당')).toBeTruthy()
+    expect(screen.getByText('보고서 작성')).toBeTruthy()
+  })
+
+  it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.getByText('게시물 상세 화면은 추후 확정')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('button', { name: '타임라인' }))
+
+    expect(screen.getByText('타임라인 상세 화면은 추후 확정')).toBeTruthy()
+    expect(screen.queryByText('게시물 상세 화면은 추후 확정')).toBeNull()
+  })
+
+  it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', () => {
+    render(<OrgOverview org={org()} />)
+
+    const link = screen.getByRole('link', { name: 'Mattermost 열기' })
+    expect(link).toHaveAttribute(
+      'href',
+      'https://hub.iten.co.kr/itn-hub/channels/org-001-mj',
+    )
+  })
+
+  it('채널이 없으면 Mattermost 열기가 비활성이다', () => {
+    render(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />)
+
+    expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull()
+    expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled()
+  })
+
+  it('치안정책연구소는 policy 채널 링크를 쓴다', () => {
+    render(
+      <OrgOverview
+        org={org({ orgNo: '008', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })}
+      />,
+    )
+
+    expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute(
+      'href',
+      'https://hub.iten.co.kr/itn-hub/channels/org-008-policy-mj',
+    )
+  })
+
+  it('미구현 동작 버튼들은 비활성이다', () => {
+    render(<OrgOverview org={org()} />)
+
+    expect(screen.getByRole('button', { name: '진행단계 변경' })).toBeDisabled()
+    expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled()
+  })
+})
 
frontend/src/components/OrgOverview.tsx (added)
+++ frontend/src/components/OrgOverview.tsx
@@ -0,0 +1,198 @@
+import { useState } from 'react'
+import type { Org } from '../api/client'
+import StatusBadge from './StatusBadge'
+
+// 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다.
+// 웹 DB에 있는 것(연번·기관명·신청기관 담당자·채널)만 실데이터고,
+// 문정원 담당자/담당 변호사/업무단계/통계/탭 내용은 아직 백엔드가 없어
+// 시안과 같은 골격에 "추후 연동" 자리만 잡아 둔다.
+
+const STEPS = [
+  '신청',
+  '목록접수',
+  '예비검토',
+  '변호사 배당',
+  '법률검토(권리확인)',
+  'RE:확인',
+  '법률검토(권리확인) 완료',
+  '법률검토(권리처리)',
+  'RE:처리',
+  '법률검토(권리처리) 완료',
+  '법률검토 최종완료',
+  '보고서 작성',
+]
+
+const TABS = ['게시물', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모']
+
+const DISABLED_ACTIONS = ['진행단계 변경', '담당 변호사 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리']
+
+// Mattermost 웹 주소. 채널 화면은 팀명 + 채널 내부명으로 열린다.
+// 내부명 규칙은 백엔드 ChannelNaming.java가 정본이며 여기서는 열람 링크용으로만 복제한다.
+const MATTERMOST_BASE = 'https://hub.iten.co.kr/itn-hub/channels/'
+const SPECIAL_ORG_NAME = '경찰청_치안정책연구소'
+
+function mattermostChannelUrl(org: Org): string {
+  const slug = org.orgNo.trim().toLowerCase().replace(/_/g, '-').replace(/ /g, '-')
+  const name =
+    org.orgName.trim() === SPECIAL_ORG_NAME ? `org-${slug}-policy-mj` : `org-${slug}-mj`
+  return MATTERMOST_BASE + name
+}
+
+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>
+  )
+}
+
+function PendingCard({ title }: { title: string }) {
+  return (
+    <section className="rounded-lg border border-gray-200 p-4">
+      <h2 className="text-sm font-semibold">{title}</h2>
+      <p className="mt-3 text-sm text-gray-300">추후 연동</p>
+    </section>
+  )
+}
+
+export default function OrgOverview({ org }: { org: Org }) {
+  const [activeTab, setActiveTab] = useState(TABS[0])
+
+  return (
+    <div className="p-6">
+      <div className="rounded-xl border border-gray-200 bg-white p-6">
+        {/* 헤더 */}
+        <div className="flex flex-wrap items-center justify-between gap-3">
+          <div className="flex items-center gap-3">
+            <h1 className="text-lg font-bold">
+              {org.orgNo}_{org.orgName}
+            </h1>
+            <StatusBadge status={org.status} />
+          </div>
+
+          <div className="flex flex-wrap gap-2">
+            {DISABLED_ACTIONS.map((label) => (
+              <button
+                key={label}
+                type="button"
+                disabled
+                title="준비 중"
+                className="cursor-not-allowed rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-300"
+              >
+                {label}
+              </button>
+            ))}
+            {org.channelIdMj ? (
+              <a
+                href={mattermostChannelUrl(org)}
+                target="_blank"
+                rel="noreferrer"
+                className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
+              >
+                Mattermost 열기
+              </a>
+            ) : (
+              <button
+                type="button"
+                disabled
+                title="채널이 아직 없습니다"
+                className="cursor-not-allowed rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-300"
+              >
+                Mattermost 열기
+              </button>
+            )}
+          </div>
+        </div>
+
+        {/* 담당자 카드 3개 */}
+        <div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3">
+          <section className="rounded-lg border border-gray-200 p-4">
+            <h2 className="text-sm font-semibold">신청기관 담당자</h2>
+            <div className="mt-3 space-y-1.5">
+              <Field label="부서" value={org.deptName} />
+              <Field
+                label="담당자"
+                value={
+                  org.managerName
+                    ? org.managerTitle
+                      ? `${org.managerName} ${org.managerTitle}`
+                      : org.managerName
+                    : null
+                }
+              />
+              <Field label="연락처" value={org.managerPhone} />
+              <Field label="이메일" value={org.managerEmail} />
+            </div>
+          </section>
+
+          <PendingCard title="문정원 담당자" />
+          <PendingCard title="담당 변호사" />
+        </div>
+
+        {/* 업무단계 현황 */}
+        <section className="mt-5">
+          <h2 className="text-sm font-semibold">업무단계 현황</h2>
+          <ol className="mt-3 flex gap-2 overflow-x-auto pb-2">
+            {STEPS.map((step, i) => (
+              <li
+                key={step}
+                className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-lg border border-gray-200 px-2 py-3 text-center"
+              >
+                <span className="flex h-6 w-6 items-center justify-center rounded-full border border-gray-300 text-xs text-gray-500">
+                  {i + 1}
+                </span>
+                <span className="text-[11px] leading-tight text-gray-600">{step}</span>
+                <span className="text-[11px] text-gray-300">-</span>
+              </li>
+            ))}
+          </ol>
+        </section>
+
+        {/* 통계 4칸 */}
+        <div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
+          {[
+            ['사전검토', '검토 대상'],
+            ['권리확인', '완료 / 전체'],
+            ['권리처리', '완료 / 전체'],
+            ['RE', '확인·처리 합계'],
+          ].map(([title, caption]) => (
+            <div key={title} className="rounded-lg border border-gray-200 p-4">
+              <p className="text-xs text-gray-400">{title}</p>
+              <p className="mt-1 text-sm font-semibold">
+                {caption} <span className="ml-1 text-lg text-gray-300">-</span>
+              </p>
+            </div>
+          ))}
+        </div>
+
+        {/* 탭 */}
+        <div className="mt-6 border-b border-gray-200">
+          <nav className="flex gap-1" aria-label="기관 상세 탭">
+            {TABS.map((tab) => (
+              <button
+                key={tab}
+                type="button"
+                onClick={() => setActiveTab(tab)}
+                aria-current={activeTab === tab ? 'true' : undefined}
+                className={`border-b-2 px-4 py-2 text-sm ${
+                  activeTab === tab
+                    ? 'border-blue-600 font-semibold text-blue-600'
+                    : 'border-transparent text-gray-500 hover:text-gray-700'
+                }`}
+              >
+                {tab}
+              </button>
+            ))}
+          </nav>
+        </div>
+
+        <div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400">
+          {activeTab} 상세 화면은 추후 확정
+        </div>
+      </div>
+    </div>
+  )
+}
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -1,14 +1,15 @@
-import { render, screen } from '@testing-library/react'
-import { describe, expect, it } from 'vitest'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
 import Sidebar from './Sidebar'
 
 describe('Sidebar', () => {
-  it('시안의 메뉴 8개를 순서대로 보여준다', () => {
+  it('메뉴를 순서대로 보여준다', () => {
     render(<Sidebar />)
 
     const labels = [
       'Dashboard',
       '기관관리',
+      '채널생성',
       '사업관리',
       '권리확인',
       '권리처리',
@@ -20,11 +21,22 @@
     expect(items).toEqual(labels)
   })
 
-  it('기관관리가 현재 페이지로 표시된다', () => {
-    render(<Sidebar />)
+  it('activeKey에 해당하는 메뉴가 현재 페이지로 표시된다', () => {
+    render(<Sidebar activeKey="channels" />)
 
-    const active = screen.getByRole('link', { name: '기관관리' })
-    expect(active).toHaveAttribute('aria-current', 'page')
+    expect(screen.getByRole('link', { name: '채널생성' }))
+      .toHaveAttribute('aria-current', 'page')
+    expect(screen.getByRole('link', { name: '기관관리' }))
+      .not.toHaveAttribute('aria-current')
+  })
+
+  it('활성 메뉴를 누르면 onSelect가 호출된다', () => {
+    const onSelect = vi.fn()
+    render(<Sidebar activeKey="orgs" onSelect={onSelect} />)
+
+    fireEvent.click(screen.getByRole('link', { name: '채널생성' }))
+
+    expect(onSelect).toHaveBeenCalledWith('channels')
   })
 
   it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -36,6 +36,16 @@
     ),
   },
   {
+    key: 'channels',
+    label: '채널생성',
+    enabled: true,
+    icon: (
+      <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+        <path d="M9 3L7 21M17 3l-2 18M4 8h17M3 16h17" />
+      </svg>
+    ),
+  },
+  {
     key: 'projects',
     label: '사업관리',
     enabled: false,
@@ -100,7 +110,12 @@
   },
 ]
 
-export default function Sidebar({ activeKey = 'orgs' }: { activeKey?: string }) {
+interface SidebarProps {
+  activeKey?: string
+  onSelect?: (key: string) => void
+}
+
+export default function Sidebar({ activeKey = 'orgs', onSelect }: SidebarProps) {
   return (
     <aside className="flex w-56 shrink-0 flex-col border-r border-gray-200 bg-white">
       <div className="px-5 pb-4 pt-5">
@@ -115,7 +130,10 @@
               <li key={item.key}>
                 <a
                   href="/"
-                  onClick={(e) => e.preventDefault()}
+                  onClick={(e) => {
+                    e.preventDefault()
+                    onSelect?.(item.key)
+                  }}
                   aria-current={item.key === activeKey ? 'page' : undefined}
                   className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm ${
                     item.key === activeKey
Add a comment
List