ITN Dev 07-22
feat: 로그인 화면과 기관 목록 화면 추가
@39bbfed30b08b26f8f14caa603d2b966890edd9d
frontend/src/App.tsx
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
@@ -1,19 +1,37 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import { getOrgs, type Org } from './api/client'
+import LoginPage from './components/LoginPage'
+import OrgList from './components/OrgList'
 
 export default function App() {
   const [orgs, setOrgs] = useState<Org[]>([])
-  const [error, setError] = useState<string | null>(null)
+  const [selectedId, setSelectedId] = useState<number | null>(null)
+  const [authenticated, setAuthenticated] = useState(true)
 
-  useEffect(() => {
-    getOrgs().then(setOrgs).catch((e: Error) => setError(e.message))
+  const reload = useCallback(async () => {
+    try {
+      setOrgs(await getOrgs())
+      setAuthenticated(true)
+    } catch {
+      setAuthenticated(false)
+    }
   }, [])
 
+  useEffect(() => {
+    void reload()
+  }, [reload])
+
+  if (!authenticated) {
+    return <LoginPage onSuccess={() => void reload()} />
+  }
+
   return (
-    <div className="p-8">
-      <h1 className="text-xl font-semibold">ITN-HUB 사업관리시스템</h1>
-      {error && <p className="mt-4 text-red-600">{error}</p>}
-      <p className="mt-4 text-gray-600">기관 {orgs.length}개</p>
+    <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>
+      </main>
     </div>
   )
 }
 
frontend/src/components/LoginPage.tsx (added)
+++ frontend/src/components/LoginPage.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react'
+import { login } from '../api/client'
+
+export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
+  const [username, setUsername] = useState('')
+  const [password, setPassword] = useState('')
+  const [error, setError] = useState<string | null>(null)
+
+  async function submit(e: React.FormEvent) {
+    e.preventDefault()
+    setError(null)
+    try {
+      await login(username, password)
+      onSuccess()
+    } catch (err) {
+      setError((err as Error).message)
+    }
+  }
+
+  return (
+    <div className="flex min-h-screen items-center justify-center bg-gray-50">
+      <form onSubmit={submit} className="w-80 rounded-lg border border-gray-200 bg-white p-6">
+        <h1 className="text-lg font-semibold">ITN-HUB</h1>
+        <p className="mt-1 text-sm text-gray-500">사업관리시스템</p>
+
+        <input
+          className="mt-6 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
+          placeholder="아이디"
+          value={username}
+          onChange={(e) => setUsername(e.target.value)}
+        />
+        <input
+          className="mt-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
+          type="password"
+          placeholder="비밀번호"
+          value={password}
+          onChange={(e) => setPassword(e.target.value)}
+        />
+
+        {error && <p className="mt-3 text-sm text-red-600">{error}</p>}
+
+        <button
+          type="submit"
+          className="mt-4 w-full rounded-md bg-blue-600 py-2 text-sm font-medium text-white"
+        >
+          로그인
+        </button>
+      </form>
+    </div>
+  )
+}
 
frontend/src/components/OrgList.test.tsx (added)
+++ frontend/src/components/OrgList.test.tsx
@@ -0,0 +1,49 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import OrgList from './OrgList'
+import type { Org } from '../api/client'
+
+const orgs: Org[] = [
+  {
+    id: 1, orgNo: '001', orgName: '국제방송교류재단', status: 'ACTIVE',
+    deptName: null, managerName: null, managerTitle: null,
+    managerPhone: null, managerEmail: null,
+    channelIdMj: 'a', channelIdLaw: 'b',
+  },
+  {
+    id: 2, orgNo: '008', orgName: '경찰청_치안정책연구소', status: 'INFO_PENDING',
+    deptName: null, managerName: null, managerTitle: null,
+    managerPhone: null, managerEmail: null,
+    channelIdMj: null, channelIdLaw: null,
+  },
+]
+
+describe('OrgList', () => {
+  it('연번과 기관명을 보여준다', () => {
+    render(<OrgList orgs={orgs} selectedId={null} onSelect={() => {}} />)
+
+    expect(screen.getByText('001')).toBeTruthy()
+    expect(screen.getByText('국제방송교류재단')).toBeTruthy()
+    expect(screen.getByText('경찰청_치안정책연구소')).toBeTruthy()
+  })
+
+  it('기관을 클릭하면 선택 콜백이 호출된다', () => {
+    const onSelect = vi.fn()
+    render(<OrgList orgs={orgs} selectedId={null} onSelect={onSelect} />)
+
+    fireEvent.click(screen.getByText('국제방송교류재단'))
+
+    expect(onSelect).toHaveBeenCalledWith(1)
+  })
+
+  it('검색어로 기관을 걸러낸다', () => {
+    render(<OrgList orgs={orgs} selectedId={null} onSelect={() => {}} />)
+
+    fireEvent.change(screen.getByPlaceholderText('기관명 검색'), {
+      target: { value: '경찰' },
+    })
+
+    expect(screen.queryByText('국제방송교류재단')).toBeNull()
+    expect(screen.getByText('경찰청_치안정책연구소')).toBeTruthy()
+  })
+})
 
frontend/src/components/OrgList.tsx (added)
+++ frontend/src/components/OrgList.tsx
@@ -0,0 +1,46 @@
+import { useState } from 'react'
+import type { Org } from '../api/client'
+import StatusBadge from './StatusBadge'
+
+interface Props {
+  orgs: Org[]
+  selectedId: number | null
+  onSelect: (id: number) => void
+}
+
+export default function OrgList({ orgs, selectedId, onSelect }: Props) {
+  const [keyword, setKeyword] = useState('')
+
+  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="border-b border-gray-200 p-3">
+        <input
+          className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
+          placeholder="기관명 검색"
+          value={keyword}
+          onChange={(e) => setKeyword(e.target.value)}
+        />
+      </div>
+
+      <ul className="flex-1 overflow-y-auto">
+        {visible.map((org) => (
+          <li key={org.id}>
+            <button
+              type="button"
+              onClick={() => onSelect(org.id)}
+              className={`flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 ${
+                selectedId === org.id ? 'bg-blue-50' : ''
+              }`}
+            >
+              <span className="w-9 shrink-0 text-xs text-gray-500">{org.orgNo}</span>
+              <span className="flex-1 truncate text-sm">{org.orgName}</span>
+              <StatusBadge status={org.status} />
+            </button>
+          </li>
+        ))}
+      </ul>
+    </div>
+  )
+}
 
frontend/src/components/StatusBadge.test.tsx (added)
+++ frontend/src/components/StatusBadge.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import StatusBadge from './StatusBadge'
+
+describe('StatusBadge', () => {
+  it('상태를 한국어 라벨로 보여준다', () => {
+    render(<StatusBadge status="INFO_PENDING" />)
+    expect(screen.getByText('정보 대기')).toBeTruthy()
+  })
+
+  it('부분 생성 상태를 구분해서 보여준다', () => {
+    render(<StatusBadge status="PARTIAL" />)
+    expect(screen.getByText('부분 생성')).toBeTruthy()
+  })
+
+  it('운영 중 상태를 보여준다', () => {
+    render(<StatusBadge status="ACTIVE" />)
+    expect(screen.getByText('운영 중')).toBeTruthy()
+  })
+})
 
frontend/src/components/StatusBadge.tsx (added)
+++ frontend/src/components/StatusBadge.tsx
@@ -0,0 +1,23 @@
+import type { OrgStatus } from '../api/client'
+
+const LABELS: Record<OrgStatus, string> = {
+  INFO_PENDING: '정보 대기',
+  READY: '생성 가능',
+  PARTIAL: '부분 생성',
+  ACTIVE: '운영 중',
+}
+
+const STYLES: Record<OrgStatus, string> = {
+  INFO_PENDING: 'bg-gray-100 text-gray-600',
+  READY: 'bg-blue-50 text-blue-700',
+  PARTIAL: 'bg-amber-50 text-amber-700',
+  ACTIVE: 'bg-emerald-50 text-emerald-700',
+}
+
+export default function StatusBadge({ status }: { status: OrgStatus }) {
+  return (
+    <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${STYLES[status]}`}>
+      {LABELS[status]}
+    </span>
+  )
+}
 
frontend/src/setupTests.ts (added)
+++ frontend/src/setupTests.ts
@@ -0,0 +1,1 @@
+import '@testing-library/jest-dom/vitest'
frontend/vite.config.ts
--- frontend/vite.config.ts
+++ frontend/vite.config.ts
@@ -15,6 +15,6 @@
   test: {
     environment: 'jsdom',
     globals: true,
-    setupFiles: [],
+    setupFiles: ['./src/setupTests.ts'],
   },
 })
Add a comment
List