feat: 기관 상세 입력폼과 채널 생성 화면 추가
@b87b5e54b01835b83041ce22e0bd7bfc7f23d11a
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -1,19 +1,25 @@ |
| 1 | 1 |
import { useCallback, useEffect, useState } from 'react'
|
| 2 | 2 |
import { getOrgs, type Org } from './api/client'
|
| 3 | 3 |
import LoginPage from './components/LoginPage' |
| 4 |
+import OrgDetail from './components/OrgDetail' |
|
| 4 | 5 |
import OrgList from './components/OrgList' |
| 6 |
+import SeedUpload from './components/SeedUpload' |
|
| 7 |
+ |
|
| 8 |
+// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도 |
|
| 9 |
+// 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다. |
|
| 10 |
+type AuthState = 'checking' | 'authenticated' | 'anonymous' |
|
| 5 | 11 |
|
| 6 | 12 |
export default function App() {
|
| 7 | 13 |
const [orgs, setOrgs] = useState<Org[]>([]) |
| 8 | 14 |
const [selectedId, setSelectedId] = useState<number | null>(null) |
| 9 |
- const [authenticated, setAuthenticated] = useState(true) |
|
| 15 |
+ const [auth, setAuth] = useState<AuthState>('checking')
|
|
| 10 | 16 |
|
| 11 | 17 |
const reload = useCallback(async () => {
|
| 12 | 18 |
try {
|
| 13 | 19 |
setOrgs(await getOrgs()) |
| 14 |
- setAuthenticated(true) |
|
| 20 |
+ setAuth('authenticated')
|
|
| 15 | 21 |
} catch {
|
| 16 |
- setAuthenticated(false) |
|
| 22 |
+ setAuth('anonymous')
|
|
| 17 | 23 |
} |
| 18 | 24 |
}, []) |
| 19 | 25 |
|
... | ... | @@ -21,16 +27,29 @@ |
| 21 | 27 |
void reload() |
| 22 | 28 |
}, [reload]) |
| 23 | 29 |
|
| 24 |
- if (!authenticated) {
|
|
| 30 |
+ if (auth === 'checking') {
|
|
| 31 |
+ return <div className="p-8 text-sm text-gray-500">불러오는 중…</div> |
|
| 32 |
+ } |
|
| 33 |
+ |
|
| 34 |
+ if (auth === 'anonymous') {
|
|
| 25 | 35 |
return <LoginPage onSuccess={() => void reload()} />
|
| 26 | 36 |
} |
| 27 | 37 |
|
| 38 |
+ const selected = orgs.find((org) => org.id === selectedId) ?? null |
|
| 39 |
+ |
|
| 28 | 40 |
return ( |
| 29 | 41 |
<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>
|
|
| 42 |
+ <div className="flex w-80 flex-col border-r border-gray-200"> |
|
| 43 |
+ <OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
|
|
| 44 |
+ <SeedUpload onUploaded={() => void reload()} />
|
|
| 45 |
+ </div> |
|
| 46 |
+ |
|
| 47 |
+ <main className="flex-1 overflow-y-auto"> |
|
| 48 |
+ {selected ? (
|
|
| 49 |
+ <OrgDetail org={selected} onChanged={() => void reload()} />
|
|
| 50 |
+ ) : ( |
|
| 51 |
+ <p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p> |
|
| 52 |
+ )} |
|
| 34 | 53 |
</main> |
| 35 | 54 |
</div> |
| 36 | 55 |
) |
+++ frontend/src/components/ConfirmDialog.tsx
... | ... | @@ -0,0 +1,40 @@ |
| 1 | +interface Props { | |
| 2 | + title: string | |
| 3 | + lines: string[] | |
| 4 | + confirmLabel: string | |
| 5 | + onConfirm: () => void | |
| 6 | + onCancel: () => void | |
| 7 | +} | |
| 8 | + | |
| 9 | +export default function ConfirmDialog({ title, lines, confirmLabel, onConfirm, onCancel }: Props) { | |
| 10 | + return ( | |
| 11 | + <div className="fixed inset-0 flex items-center justify-center bg-black/30"> | |
| 12 | + <div className="w-96 rounded-lg bg-white p-5"> | |
| 13 | + <h2 className="text-base font-semibold">{title}</h2> | |
| 14 | + | |
| 15 | + <ul className="mt-3 space-y-1 rounded-md bg-gray-50 p-3 text-sm"> | |
| 16 | + {lines.map((line) => ( | |
| 17 | + <li key={line} className="font-mono text-xs">{line}</li> | |
| 18 | + ))} | |
| 19 | + </ul> | |
| 20 | + | |
| 21 | + <div className="mt-5 flex justify-end gap-2"> | |
| 22 | + <button | |
| 23 | + type="button" | |
| 24 | + onClick={onCancel} | |
| 25 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" | |
| 26 | + > | |
| 27 | + 취소 | |
| 28 | + </button> | |
| 29 | + <button | |
| 30 | + type="button" | |
| 31 | + onClick={onConfirm} | |
| 32 | + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white" | |
| 33 | + > | |
| 34 | + {confirmLabel} | |
| 35 | + </button> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + ) | |
| 40 | +} |
+++ frontend/src/components/OrgDetail.test.tsx
... | ... | @@ -0,0 +1,115 @@ |
| 1 | +import { fireEvent, render, screen, waitFor } from '@testing-library/react' | |
| 2 | +import { beforeEach, describe, expect, it, vi } from 'vitest' | |
| 3 | +import OrgDetail from './OrgDetail' | |
| 4 | +import type { Org } from '../api/client' | |
| 5 | + | |
| 6 | +const mocks = vi.hoisted(() => ({ | |
| 7 | + updateContact: vi.fn(), | |
| 8 | + provisionChannels: vi.fn(), | |
| 9 | +})) | |
| 10 | + | |
| 11 | +vi.mock('../api/client', async (importOriginal) => ({ | |
| 12 | + ...(await importOriginal<typeof import('../api/client')>()), | |
| 13 | + updateContact: mocks.updateContact, | |
| 14 | + provisionChannels: mocks.provisionChannels, | |
| 15 | +})) | |
| 16 | + | |
| 17 | +function org(overrides: Partial<Org> = {}): Org { | |
| 18 | + return { | |
| 19 | + id: 1, | |
| 20 | + orgNo: '001', | |
| 21 | + orgName: '국제방송교류재단', | |
| 22 | + status: 'INFO_PENDING', | |
| 23 | + deptName: null, | |
| 24 | + managerName: null, | |
| 25 | + managerTitle: null, | |
| 26 | + managerPhone: null, | |
| 27 | + managerEmail: null, | |
| 28 | + channelIdMj: null, | |
| 29 | + channelIdLaw: null, | |
| 30 | + ...overrides, | |
| 31 | + } | |
| 32 | +} | |
| 33 | + | |
| 34 | +beforeEach(() => { | |
| 35 | + mocks.updateContact.mockReset() | |
| 36 | + mocks.provisionChannels.mockReset() | |
| 37 | +}) | |
| 38 | + | |
| 39 | +describe('OrgDetail', () => { | |
| 40 | + it('정보 대기 상태면 채널 생성 버튼이 비활성이다', () => { | |
| 41 | + render(<OrgDetail org={org()} onChanged={() => {}} />) | |
| 42 | + | |
| 43 | + expect(screen.getByRole('button', { name: '채널 생성' })).toBeDisabled() | |
| 44 | + }) | |
| 45 | + | |
| 46 | + it('생성 가능 상태면 채널 생성 버튼이 활성이다', () => { | |
| 47 | + render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />) | |
| 48 | + | |
| 49 | + expect(screen.getByRole('button', { name: '채널 생성' })).toBeEnabled() | |
| 50 | + }) | |
| 51 | + | |
| 52 | + it('담당자 정보를 저장하면 API를 호출한다', async () => { | |
| 53 | + mocks.updateContact.mockResolvedValue(org({ status: 'READY' })) | |
| 54 | + const onChanged = vi.fn() | |
| 55 | + | |
| 56 | + render(<OrgDetail org={org()} onChanged={onChanged} />) | |
| 57 | + | |
| 58 | + fireEvent.change(screen.getByLabelText('부서명'), { target: { value: '데이터정보화팀' } }) | |
| 59 | + fireEvent.change(screen.getByLabelText('담당자명'), { target: { value: '송민지' } }) | |
| 60 | + fireEvent.change(screen.getByLabelText('직급/직함'), { target: { value: '과장' } }) | |
| 61 | + fireEvent.change(screen.getByLabelText('연락처'), { target: { value: '02-3475-5434' } }) | |
| 62 | + fireEvent.change(screen.getByLabelText('이메일'), { target: { value: 'ming@arirang.com' } }) | |
| 63 | + fireEvent.click(screen.getByRole('button', { name: '정보 저장' })) | |
| 64 | + | |
| 65 | + await waitFor(() => { | |
| 66 | + expect(mocks.updateContact).toHaveBeenCalledWith(1, { | |
| 67 | + deptName: '데이터정보화팀', | |
| 68 | + managerName: '송민지', | |
| 69 | + managerTitle: '과장', | |
| 70 | + managerPhone: '02-3475-5434', | |
| 71 | + managerEmail: 'ming@arirang.com', | |
| 72 | + }) | |
| 73 | + expect(onChanged).toHaveBeenCalled() | |
| 74 | + }) | |
| 75 | + }) | |
| 76 | + | |
| 77 | + it('채널 생성은 확인 다이얼로그에 만들어질 채널 2개를 보여준다', () => { | |
| 78 | + render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />) | |
| 79 | + | |
| 80 | + fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) | |
| 81 | + | |
| 82 | + expect(screen.getByText('001_국제방송교류재단 (문정원)')).toBeTruthy() | |
| 83 | + expect(screen.getByText('001_국제방송교류재단 (법률검토)')).toBeTruthy() | |
| 84 | + expect(mocks.provisionChannels).not.toHaveBeenCalled() | |
| 85 | + }) | |
| 86 | + | |
| 87 | + it('확인을 눌러야 채널 생성 API가 호출된다', async () => { | |
| 88 | + mocks.provisionChannels.mockResolvedValue({ mj: 'CREATED', law: 'CREATED', message: '' }) | |
| 89 | + | |
| 90 | + render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />) | |
| 91 | + | |
| 92 | + fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) | |
| 93 | + fireEvent.click(screen.getByRole('button', { name: '생성' })) | |
| 94 | + | |
| 95 | + await waitFor(() => expect(mocks.provisionChannels).toHaveBeenCalledWith(1)) | |
| 96 | + }) | |
| 97 | + | |
| 98 | + it('부분 실패하면 재시도 안내를 보여준다', async () => { | |
| 99 | + mocks.provisionChannels.mockResolvedValue({ | |
| 100 | + mj: 'CREATED', | |
| 101 | + law: 'FAILED', | |
| 102 | + message: '[법률검토] 서버 오류', | |
| 103 | + }) | |
| 104 | + | |
| 105 | + render(<OrgDetail org={org({ status: 'READY' })} onChanged={() => {}} />) | |
| 106 | + | |
| 107 | + fireEvent.click(screen.getByRole('button', { name: '채널 생성' })) | |
| 108 | + fireEvent.click(screen.getByRole('button', { name: '생성' })) | |
| 109 | + | |
| 110 | + await waitFor(() => { | |
| 111 | + expect(screen.getByText(/법률검토 채널 생성에 실패/)).toBeTruthy() | |
| 112 | + expect(screen.getByText(/다시 눌러도 안전/)).toBeTruthy() | |
| 113 | + }) | |
| 114 | + }) | |
| 115 | +}) |
+++ frontend/src/components/OrgDetail.tsx
... | ... | @@ -0,0 +1,162 @@ |
| 1 | +import { useEffect, useState } from 'react' | |
| 2 | +import { | |
| 3 | + provisionChannels, | |
| 4 | + updateContact, | |
| 5 | + type Contact, | |
| 6 | + type Org, | |
| 7 | + type ProvisionResult, | |
| 8 | +} from '../api/client' | |
| 9 | +import ConfirmDialog from './ConfirmDialog' | |
| 10 | +import StatusBadge from './StatusBadge' | |
| 11 | + | |
| 12 | +const FIELDS: { key: keyof Contact; label: string }[] = [ | |
| 13 | + { key: 'deptName', label: '부서명' }, | |
| 14 | + { key: 'managerName', label: '담당자명' }, | |
| 15 | + { key: 'managerTitle', label: '직급/직함' }, | |
| 16 | + { key: 'managerPhone', label: '연락처' }, | |
| 17 | + { key: 'managerEmail', label: '이메일' }, | |
| 18 | +] | |
| 19 | + | |
| 20 | +function emptyContact(org: Org): Contact { | |
| 21 | + return { | |
| 22 | + deptName: org.deptName ?? '', | |
| 23 | + managerName: org.managerName ?? '', | |
| 24 | + managerTitle: org.managerTitle ?? '', | |
| 25 | + managerPhone: org.managerPhone ?? '', | |
| 26 | + managerEmail: org.managerEmail ?? '', | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +export default function OrgDetail({ org, onChanged }: { org: Org; onChanged: () => void }) { | |
| 31 | + const [contact, setContact] = useState<Contact>(emptyContact(org)) | |
| 32 | + const [confirming, setConfirming] = useState(false) | |
| 33 | + const [result, setResult] = useState<ProvisionResult | null>(null) | |
| 34 | + const [error, setError] = useState<string | null>(null) | |
| 35 | + | |
| 36 | + useEffect(() => { | |
| 37 | + setContact(emptyContact(org)) | |
| 38 | + setResult(null) | |
| 39 | + setError(null) | |
| 40 | + }, [org]) | |
| 41 | + | |
| 42 | + const displayMj = `${org.orgNo}_${org.orgName} (문정원)` | |
| 43 | + const displayLaw = `${org.orgNo}_${org.orgName} (법률검토)` | |
| 44 | + const canProvision = org.status !== 'INFO_PENDING' | |
| 45 | + | |
| 46 | + async function save() { | |
| 47 | + setError(null) | |
| 48 | + try { | |
| 49 | + await updateContact(org.id, contact) | |
| 50 | + onChanged() | |
| 51 | + } catch (e) { | |
| 52 | + setError((e as Error).message) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + async function provision() { | |
| 57 | + setConfirming(false) | |
| 58 | + setError(null) | |
| 59 | + try { | |
| 60 | + setResult(await provisionChannels(org.id)) | |
| 61 | + onChanged() | |
| 62 | + } catch (e) { | |
| 63 | + setError((e as Error).message) | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <div className="p-8"> | |
| 69 | + <div className="flex items-center gap-3"> | |
| 70 | + <h1 className="text-lg font-semibold"> | |
| 71 | + {org.orgNo} {org.orgName} | |
| 72 | + </h1> | |
| 73 | + <StatusBadge status={org.status} /> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <section className="mt-6 max-w-xl rounded-lg border border-gray-200 p-5"> | |
| 77 | + <h2 className="text-sm font-medium">신청기관 담당자</h2> | |
| 78 | + | |
| 79 | + <div className="mt-4 space-y-3"> | |
| 80 | + {FIELDS.map((field) => ( | |
| 81 | + <div key={field.key} className="flex items-center gap-3"> | |
| 82 | + <label htmlFor={field.key} className="w-20 shrink-0 text-sm text-gray-500"> | |
| 83 | + {field.label} | |
| 84 | + </label> | |
| 85 | + <input | |
| 86 | + id={field.key} | |
| 87 | + className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm" | |
| 88 | + value={contact[field.key]} | |
| 89 | + onChange={(e) => setContact({ ...contact, [field.key]: e.target.value })} | |
| 90 | + /> | |
| 91 | + </div> | |
| 92 | + ))} | |
| 93 | + </div> | |
| 94 | + | |
| 95 | + <div className="mt-5 flex gap-2"> | |
| 96 | + <button | |
| 97 | + type="button" | |
| 98 | + onClick={() => void save()} | |
| 99 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm" | |
| 100 | + > | |
| 101 | + 정보 저장 | |
| 102 | + </button> | |
| 103 | + <button | |
| 104 | + type="button" | |
| 105 | + disabled={!canProvision} | |
| 106 | + onClick={() => setConfirming(true)} | |
| 107 | + className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300" | |
| 108 | + > | |
| 109 | + 채널 생성 | |
| 110 | + </button> | |
| 111 | + </div> | |
| 112 | + </section> | |
| 113 | + | |
| 114 | + {error && <p className="mt-4 text-sm text-red-600">{error}</p>} | |
| 115 | + | |
| 116 | + {result && ( | |
| 117 | + <div className="mt-4 max-w-xl rounded-lg border border-gray-200 p-4 text-sm"> | |
| 118 | + <p>문정원 채널: {outcomeLabel(result.mj)}</p> | |
| 119 | + <p>법률검토 채널: {outcomeLabel(result.law)}</p> | |
| 120 | + | |
| 121 | + {result.law === 'FAILED' && ( | |
| 122 | + <p className="mt-2 text-amber-700"> | |
| 123 | + 법률검토 채널 생성에 실패했습니다. 문정원 채널은 그대로 남아 있으니 | |
| 124 | + [채널 생성]을 다시 눌러도 안전합니다. 없는 채널만 다시 만듭니다. | |
| 125 | + </p> | |
| 126 | + )} | |
| 127 | + {result.mj === 'FAILED' && ( | |
| 128 | + <p className="mt-2 text-amber-700"> | |
| 129 | + 문정원 채널 생성에 실패했습니다. [채널 생성]을 다시 눌러도 안전합니다. | |
| 130 | + </p> | |
| 131 | + )} | |
| 132 | + {result.message && ( | |
| 133 | + <pre className="mt-2 whitespace-pre-wrap text-xs text-gray-500">{result.message}</pre> | |
| 134 | + )} | |
| 135 | + </div> | |
| 136 | + )} | |
| 137 | + | |
| 138 | + {confirming && ( | |
| 139 | + <ConfirmDialog | |
| 140 | + title="아래 채널 2개를 Mattermost에 생성합니다." | |
| 141 | + lines={[displayMj, displayLaw]} | |
| 142 | + confirmLabel="생성" | |
| 143 | + onConfirm={() => void provision()} | |
| 144 | + onCancel={() => setConfirming(false)} | |
| 145 | + /> | |
| 146 | + )} | |
| 147 | + </div> | |
| 148 | + ) | |
| 149 | +} | |
| 150 | + | |
| 151 | +function outcomeLabel(outcome: ProvisionResult['mj']): string { | |
| 152 | + switch (outcome) { | |
| 153 | + case 'CACHED': | |
| 154 | + return '이미 있음 (변경 없음)' | |
| 155 | + case 'RECOVERED': | |
| 156 | + return '기존 채널 연결됨' | |
| 157 | + case 'CREATED': | |
| 158 | + return '새로 생성됨' | |
| 159 | + case 'FAILED': | |
| 160 | + return '실패' | |
| 161 | + } | |
| 162 | +} |
--- frontend/src/components/OrgList.tsx
+++ frontend/src/components/OrgList.tsx
... | ... | @@ -14,7 +14,7 @@ |
| 14 | 14 |
const visible = orgs.filter((org) => org.orgName.includes(keyword)) |
| 15 | 15 |
|
| 16 | 16 |
return ( |
| 17 |
- <div className="flex h-full w-80 flex-col border-r border-gray-200"> |
|
| 17 |
+ <div className="flex h-full flex-col overflow-hidden"> |
|
| 18 | 18 |
<div className="border-b border-gray-200 p-3"> |
| 19 | 19 |
<input |
| 20 | 20 |
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" |
+++ frontend/src/components/SeedUpload.tsx
... | ... | @@ -0,0 +1,48 @@ |
| 1 | +import { useRef, useState } from 'react' | |
| 2 | +import { uploadSeed, type SeedReport } from '../api/client' | |
| 3 | + | |
| 4 | +export default function SeedUpload({ onUploaded }: { onUploaded: () => void }) { | |
| 5 | + const inputRef = useRef<HTMLInputElement>(null) | |
| 6 | + const [report, setReport] = useState<SeedReport | null>(null) | |
| 7 | + const [error, setError] = useState<string | null>(null) | |
| 8 | + | |
| 9 | + async function handle(file: File) { | |
| 10 | + setError(null) | |
| 11 | + try { | |
| 12 | + setReport(await uploadSeed(file)) | |
| 13 | + onUploaded() | |
| 14 | + } catch (e) { | |
| 15 | + setError((e as Error).message) | |
| 16 | + } | |
| 17 | + } | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <div className="border-t border-gray-200 p-3"> | |
| 21 | + <input | |
| 22 | + ref={inputRef} | |
| 23 | + type="file" | |
| 24 | + accept=".xlsx,.xlsm" | |
| 25 | + className="hidden" | |
| 26 | + onChange={(e) => { | |
| 27 | + const file = e.target.files?.[0] | |
| 28 | + if (file) void handle(file) | |
| 29 | + e.target.value = '' | |
| 30 | + }} | |
| 31 | + /> | |
| 32 | + <button | |
| 33 | + type="button" | |
| 34 | + onClick={() => inputRef.current?.click()} | |
| 35 | + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" | |
| 36 | + > | |
| 37 | + 신청목록 시트 올리기 | |
| 38 | + </button> | |
| 39 | + | |
| 40 | + {report && ( | |
| 41 | + <p className="mt-2 text-xs text-gray-500"> | |
| 42 | + 신규 {report.created} / 갱신 {report.updated} / 전체 {report.total} | |
| 43 | + </p> | |
| 44 | + )} | |
| 45 | + {error && <p className="mt-2 text-xs text-red-600">{error}</p>} | |
| 46 | + </div> | |
| 47 | + ) | |
| 48 | +} |
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?