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