ITN Dev 07-22
feat: 사이드바와 기관 목록 접기/펼치기 및 반응형 자동 접힘 추가
@e5d4fc57e9c4780fe0c9825e3fa8085a017f6d28
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -6,6 +6,7 @@
 import OrgOverview from './components/OrgOverview'
 import SeedUpload from './components/SeedUpload'
 import Sidebar from './components/Sidebar'
+import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
 
 // 화면 2개: 기관관리(시안 레이아웃의 기관 상세)와 채널생성(명부 시드 + 담당자 입력 + 채널 생성).
 type Page = 'orgs' | 'channels'
@@ -30,6 +31,9 @@
   const [auth, setAuth] = useState<AuthState>('checking')
   const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null)
   const [page, setPage] = useState<Page>('orgs')
+  // 창이 좁아지면 자동으로 접힌다. 사용자의 접기/펼치기 선택은 저장된다.
+  const [menuCollapsed, toggleMenu] = useCollapsiblePanel('itnhub.menu', '(max-width: 1023px)')
+  const [listCollapsed, toggleList] = useCollapsiblePanel('itnhub.orglist', '(max-width: 1279px)')
 
   const reload = useCallback(async () => {
     try {
@@ -90,6 +94,8 @@
     <div className="flex h-screen">
       <Sidebar
         activeKey={page}
+        collapsed={menuCollapsed}
+        onToggle={toggleMenu}
         onSelect={(key) => {
           if (key === 'orgs' || key === 'channels') {
             setPage(key)
@@ -113,9 +119,21 @@
         </header>
 
         <div className="flex min-h-0 flex-1">
-          <div className="flex w-80 shrink-0 flex-col border-r border-gray-200">
-            <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
-            {page === 'channels' && <SeedUpload onUploaded={() => void reload()} />}
+          <div
+            className={`flex shrink-0 flex-col border-r border-gray-200 transition-all duration-200 ${
+              listCollapsed ? 'w-14' : 'w-80'
+            }`}
+          >
+            <OrgList
+              orgs={orgs}
+              selectedId={selectedId}
+              onSelect={setSelectedId}
+              collapsed={listCollapsed}
+              onToggle={toggleList}
+            />
+            {page === 'channels' && !listCollapsed && (
+              <SeedUpload onUploaded={() => void reload()} />
+            )}
           </div>
 
           <main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50">
frontend/src/components/OrgList.test.tsx
--- frontend/src/components/OrgList.test.tsx
+++ frontend/src/components/OrgList.test.tsx
@@ -46,4 +46,35 @@
     expect(screen.queryByText('국제방송교류재단')).toBeNull()
     expect(screen.getByText('경찰청_치안정책연구소')).toBeTruthy()
   })
+
+  it('접힌 상태에서는 연번만 보이고 이름은 툴팁으로 남는다', () => {
+    render(
+      <OrgList orgs={orgs} selectedId={null} onSelect={() => {}} collapsed onToggle={() => {}} />,
+    )
+
+    expect(screen.getByText('001')).toBeTruthy()
+    expect(screen.queryByText('국제방송교류재단')).toBeNull()
+    expect(screen.queryByPlaceholderText('기관명 검색')).toBeNull()
+
+    const item = screen.getByRole('button', { name: '001 국제방송교류재단' })
+    expect(item).toHaveAttribute('title', '001 국제방송교류재단 (운영 중)')
+  })
+
+  it('접힌 상태에서도 기관을 선택할 수 있다', () => {
+    const onSelect = vi.fn()
+    render(<OrgList orgs={orgs} selectedId={null} onSelect={onSelect} collapsed />)
+
+    fireEvent.click(screen.getByRole('button', { name: '001 국제방송교류재단' }))
+
+    expect(onSelect).toHaveBeenCalledWith(1)
+  })
+
+  it('접기 버튼을 누르면 onToggle이 호출된다', () => {
+    const onToggle = vi.fn()
+    render(<OrgList orgs={orgs} selectedId={null} onSelect={() => {}} onToggle={onToggle} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '기관 목록 접기' }))
+
+    expect(onToggle).toHaveBeenCalled()
+  })
 })
frontend/src/components/OrgList.tsx
--- frontend/src/components/OrgList.tsx
+++ frontend/src/components/OrgList.tsx
@@ -1,28 +1,95 @@
 import { useState } from 'react'
 import type { Org } from '../api/client'
-import StatusBadge from './StatusBadge'
+import StatusBadge, { STATUS_DOT, STATUS_LABELS } from './StatusBadge'
 
 interface Props {
   orgs: Org[]
   selectedId: number | null
   onSelect: (id: number) => void
+  collapsed?: boolean
+  onToggle?: () => void
 }
 
-export default function OrgList({ orgs, selectedId, onSelect }: Props) {
+function Chevron({ left }: { left: boolean }) {
+  return (
+    <svg
+      className="h-4 w-4"
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+    >
+      {left ? <path d="M15 18l-6-6 6-6" /> : <path d="M9 18l6-6-6-6" />}
+    </svg>
+  )
+}
+
+export default function OrgList({ orgs, selectedId, onSelect, collapsed = false, onToggle }: Props) {
   const [keyword, setKeyword] = useState('')
 
   const visible = orgs.filter((org) => org.orgName.includes(keyword))
 
+  if (collapsed) {
+    // 접힌 상태: 연번 + 상태 점만. 이름은 툴팁으로 확인한다.
+    return (
+      <div className="flex h-full flex-col overflow-hidden">
+        <div className="flex justify-center border-b border-gray-200 p-2">
+          <button
+            type="button"
+            onClick={onToggle}
+            aria-label="기관 목록 펼치기"
+            title="기관 목록 펼치기"
+            className="rounded-md p-1.5 text-gray-500 hover:bg-gray-100"
+          >
+            <Chevron left={false} />
+          </button>
+        </div>
+
+        <ul className="flex-1 overflow-y-auto">
+          {orgs.map((org) => (
+            <li key={org.id}>
+              <button
+                type="button"
+                onClick={() => onSelect(org.id)}
+                title={`${org.orgNo} ${org.orgName} (${STATUS_LABELS[org.status]})`}
+                aria-label={`${org.orgNo} ${org.orgName}`}
+                className={`flex w-full flex-col items-center gap-1 py-2 hover:bg-gray-50 ${
+                  selectedId === org.id ? 'bg-blue-50' : ''
+                }`}
+              >
+                <span className="text-[11px] font-medium text-gray-600">{org.orgNo}</span>
+                <span className={`h-1.5 w-1.5 rounded-full ${STATUS_DOT[org.status]}`} />
+              </button>
+            </li>
+          ))}
+        </ul>
+      </div>
+    )
+  }
+
   return (
     <div className="flex h-full flex-col overflow-hidden">
-      <div className="border-b border-gray-200 p-3">
+      <div className="flex items-center gap-2 border-b border-gray-200 p-3">
         <input
-          className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
+          className="w-full min-w-0 rounded-md border border-gray-300 px-3 py-2 text-sm"
           placeholder="기관명 검색"
           aria-label="기관명 검색"
           value={keyword}
           onChange={(e) => setKeyword(e.target.value)}
         />
+        {onToggle && (
+          <button
+            type="button"
+            onClick={onToggle}
+            aria-label="기관 목록 접기"
+            title="기관 목록 접기"
+            className="shrink-0 rounded-md p-1.5 text-gray-500 hover:bg-gray-100"
+          >
+            <Chevron left />
+          </button>
+        )}
       </div>
 
       <ul className="flex-1 overflow-y-auto">
frontend/src/components/Sidebar.test.tsx
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
@@ -54,4 +54,24 @@
     expect(screen.getByText('ITN-HUB')).toBeTruthy()
     expect(screen.getByText('사업관리시스템')).toBeTruthy()
   })
+
+  it('접힌 상태에서는 라벨이 사라지고 툴팁으로 남는다', () => {
+    render(<Sidebar collapsed onToggle={() => {}} />)
+
+    expect(screen.queryByText('기관관리')).toBeNull()
+    expect(screen.queryByText('ITN-HUB')).toBeNull()
+    expect(screen.getByText('IH')).toBeTruthy()
+
+    expect(screen.getByRole('link', { name: '기관관리' })).toHaveAttribute('title', '기관관리')
+    expect(screen.getByTitle('Dashboard (준비 중)')).toBeTruthy()
+  })
+
+  it('접기 버튼을 누르면 onToggle이 호출된다', () => {
+    const onToggle = vi.fn()
+    render(<Sidebar onToggle={onToggle} />)
+
+    fireEvent.click(screen.getByRole('button', { name: '메뉴 접기' }))
+
+    expect(onToggle).toHaveBeenCalled()
+  })
 })
frontend/src/components/Sidebar.tsx
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
@@ -113,17 +113,36 @@
 interface SidebarProps {
   activeKey?: string
   onSelect?: (key: string) => void
+  collapsed?: boolean
+  onToggle?: () => void
 }
 
-export default function Sidebar({ activeKey = 'orgs', onSelect }: SidebarProps) {
+export default function Sidebar({
+  activeKey = 'orgs',
+  onSelect,
+  collapsed = false,
+  onToggle,
+}: 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">
-        <p className="text-lg font-bold tracking-tight">ITN-HUB</p>
-        <p className="text-xs text-gray-500">사업관리시스템</p>
-      </div>
+    <aside
+      className={`flex shrink-0 flex-col border-r border-gray-200 bg-white transition-all duration-200 ${
+        collapsed ? 'w-14' : 'w-56'
+      }`}
+    >
+      {collapsed ? (
+        <div className="flex justify-center pb-4 pt-5" title="ITN-HUB 사업관리시스템">
+          <span className="flex h-8 w-8 items-center justify-center rounded-md bg-gray-900 text-xs font-bold text-white">
+            IH
+          </span>
+        </div>
+      ) : (
+        <div className="px-5 pb-4 pt-5">
+          <p className="text-lg font-bold tracking-tight">ITN-HUB</p>
+          <p className="text-xs text-gray-500">사업관리시스템</p>
+        </div>
+      )}
 
-      <nav aria-label="주 메뉴">
+      <nav aria-label="주 메뉴" className="flex-1">
         <ul className="space-y-0.5 px-2">
           {MENU.map((item) =>
             item.enabled ? (
@@ -134,32 +153,68 @@
                     e.preventDefault()
                     onSelect?.(item.key)
                   }}
+                  title={collapsed ? item.label : undefined}
                   aria-current={item.key === activeKey ? 'page' : undefined}
                   className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm ${
+                    collapsed ? 'justify-center px-0' : ''
+                  } ${
                     item.key === activeKey
                       ? 'bg-gray-100 font-medium text-gray-900'
                       : 'text-gray-700 hover:bg-gray-50'
                   }`}
                 >
                   {item.icon}
-                  {item.label}
+                  {!collapsed && item.label}
                 </a>
               </li>
             ) : (
               <li key={item.key}>
                 <span
-                  title="준비 중"
+                  title={collapsed ? `${item.label} (준비 중)` : '준비 중'}
                   aria-disabled="true"
-                  className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-400"
+                  className={`flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-gray-400 ${
+                    collapsed ? 'justify-center px-0' : ''
+                  }`}
                 >
                   {item.icon}
-                  {item.label}
+                  {!collapsed && item.label}
                 </span>
               </li>
             ),
           )}
         </ul>
       </nav>
+
+      {onToggle && (
+        <div className={`border-t border-gray-100 p-2 ${collapsed ? 'flex justify-center' : ''}`}>
+          <button
+            type="button"
+            onClick={onToggle}
+            aria-label={collapsed ? '메뉴 펼치기' : '메뉴 접기'}
+            title={collapsed ? '메뉴 펼치기' : '메뉴 접기'}
+            className={`flex items-center gap-2 rounded-md p-2 text-sm text-gray-500 hover:bg-gray-100 ${
+              collapsed ? '' : 'w-full'
+            }`}
+          >
+            <svg
+              className="h-4 w-4"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+            >
+              {collapsed ? (
+                <path d="M9 18l6-6-6-6" />
+              ) : (
+                <path d="M15 18l-6-6 6-6" />
+              )}
+            </svg>
+            {!collapsed && '메뉴 접기'}
+          </button>
+        </div>
+      )}
     </aside>
   )
 }
frontend/src/components/StatusBadge.tsx
--- frontend/src/components/StatusBadge.tsx
+++ frontend/src/components/StatusBadge.tsx
@@ -1,12 +1,22 @@
 import type { OrgStatus } from '../api/client'
 
-const LABELS: Record<OrgStatus, string> = {
+export const STATUS_LABELS: Record<OrgStatus, string> = {
   INFO_PENDING: '정보 대기',
   READY: '생성 가능',
   PARTIAL: '부분 생성',
   ACTIVE: '운영 중',
 }
 
+/** 접힌 목록 등 좁은 자리에서 뱃지 대신 쓰는 상태 점 색 */
+export const STATUS_DOT: Record<OrgStatus, string> = {
+  INFO_PENDING: 'bg-gray-300',
+  READY: 'bg-blue-500',
+  PARTIAL: 'bg-amber-500',
+  ACTIVE: 'bg-emerald-500',
+}
+
+const LABELS = STATUS_LABELS
+
 const STYLES: Record<OrgStatus, string> = {
   INFO_PENDING: 'bg-gray-100 text-gray-600',
   READY: 'bg-blue-50 text-blue-700',
 
frontend/src/hooks/useCollapsiblePanel.ts (added)
+++ frontend/src/hooks/useCollapsiblePanel.ts
@@ -0,0 +1,44 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * 접을 수 있는 패널의 상태.
+ * - 사용자가 접거나 편 선택은 localStorage에 남아 새로고침 후에도 유지된다
+ * - 화면이 좁아져 미디어쿼리가 일치하면 자동으로 접힌다 (반응형)
+ * - 다시 넓어지면 사용자가 저장해 둔 선호로 복귀한다
+ */
+export function useCollapsiblePanel(storageKey: string, autoCollapseQuery: string) {
+  const [collapsed, setCollapsed] = useState<boolean>(() => {
+    if (typeof window === 'undefined') {
+      return false
+    }
+    if (window.localStorage.getItem(storageKey) === '1') {
+      return true
+    }
+    return (
+      typeof window.matchMedia === 'function' && window.matchMedia(autoCollapseQuery).matches
+    )
+  })
+
+  useEffect(() => {
+    // jsdom(테스트 환경)에는 matchMedia가 없다
+    if (typeof window.matchMedia !== 'function') {
+      return
+    }
+    const mq = window.matchMedia(autoCollapseQuery)
+    const onChange = (e: MediaQueryListEvent) => {
+      setCollapsed(e.matches ? true : window.localStorage.getItem(storageKey) === '1')
+    }
+    mq.addEventListener('change', onChange)
+    return () => mq.removeEventListener('change', onChange)
+  }, [storageKey, autoCollapseQuery])
+
+  const toggle = () => {
+    setCollapsed((prev) => {
+      const next = !prev
+      window.localStorage.setItem(storageKey, next ? '1' : '0')
+      return next
+    })
+  }
+
+  return [collapsed, toggle] as const
+}
Add a comment
List