File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { useCallback, useEffect, useState } from 'react'
import { ApiError, getOrgs, logout, type Org } from './api/client'
import ContactsPage from './components/ContactsPage'
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'
import { useCollapsiblePanel } from './hooks/useCollapsiblePanel'
// 화면 3개: 기관관리(시안 레이아웃의 기관 상세), 채널관리(명부 시드 + 담당자 배정 + 채널 생성),
// 담당자관리(담당자 3종 CRUD - 담당자 정보가 입력되는 유일한 화면).
type Page = 'orgs' | 'channels' | 'contacts'
const PAGE_TITLE: Record<Page, string> = {
orgs: '기관관리',
channels: '채널관리',
contacts: '담당자관리',
}
// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도
// 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다.
//
// 'error'는 401이 아닌 실패(서버 500, 네트워크 단절 등)를 위한 상태다. 이런 오류까지
// '로그아웃'으로 취급해 로그인 화면으로 보내면, 로그인해도 같은 오류가 반복되고
// 원인을 알 수 없게 된다 - 세션은 멀쩡한데 서버/네트워크만 문제인 경우이므로
// 오류 메시지와 재시도 버튼을 보여주는 편이 맞다.
type AuthState = 'checking' | 'authenticated' | 'anonymous' | 'error'
export default function App() {
const [orgs, setOrgs] = useState<Org[]>([])
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 [menuCollapsed, toggleMenu] = useCollapsiblePanel('itnhub.menu', '(max-width: 1023px)')
const [listCollapsed, toggleList] = useCollapsiblePanel('itnhub.orglist', '(max-width: 1279px)')
const reload = useCallback(async () => {
try {
setOrgs(await getOrgs())
setAuth('authenticated')
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
setAuth('anonymous')
} else {
setAuthErrorMessage((e as Error).message)
setAuth('error')
}
}
}, [])
useEffect(() => {
void reload()
}, [reload])
if (auth === 'checking') {
return <div className="p-8 text-sm text-gray-500">불러오는 중…</div>
}
if (auth === 'anonymous') {
return <LoginPage onSuccess={() => void reload()} />
}
if (auth === 'error') {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-8 text-sm">
<p className="text-red-600">{authErrorMessage}</p>
<button
type="button"
onClick={() => void reload()}
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm"
>
다시 시도
</button>
</div>
)
}
const selected = orgs.find((org) => org.id === selectedId) ?? null
async function handleLogout() {
try {
await logout()
} finally {
// 서버 응답과 무관하게 화면은 로그인으로 돌린다. 세션이 이미 만료된 경우에도
// 사용자를 붙잡아 둘 이유가 없다.
setOrgs([])
setSelectedId(null)
setAuth('anonymous')
}
}
return (
<div className="flex h-screen">
<Sidebar
activeKey={page}
collapsed={menuCollapsed}
onToggle={toggleMenu}
onSelect={(key) => {
if (key === 'orgs' || key === 'channels' || key === 'contacts') {
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">{PAGE_TITLE[page]}</span>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">관리자</span>
<button
type="button"
onClick={() => void handleLogout()}
className="rounded-md border border-gray-300 px-2.5 py-1 text-xs text-gray-600 hover:bg-gray-50"
>
로그아웃
</button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{page !== 'contacts' && (
<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">
{page === 'contacts' ? (
<ContactsPage />
) : selected ? (
page === 'channels' ? (
<OrgDetail org={selected} onChanged={() => void reload()} />
) : (
<OrgOverview org={selected} onChanged={() => void reload()} />
)
) : (
<p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p>
)}
</main>
</div>
</div>
</div>
)
}