feat: 기관관리 상세를 시안 레이아웃으로 분리하고 채널생성 메뉴 신설
@738fb0262e88e8bf8976855a46c5d6d5c387b3e3
--- frontend/src/App.tsx
+++ frontend/src/App.tsx
... | ... | @@ -3,8 +3,17 @@ |
| 3 | 3 |
import LoginPage from './components/LoginPage' |
| 4 | 4 |
import OrgDetail from './components/OrgDetail' |
| 5 | 5 |
import OrgList from './components/OrgList' |
| 6 |
+import OrgOverview from './components/OrgOverview' |
|
| 6 | 7 |
import SeedUpload from './components/SeedUpload' |
| 7 | 8 |
import Sidebar from './components/Sidebar' |
| 9 |
+ |
|
| 10 |
+// 화면 2개: 기관관리(시안 레이아웃의 기관 상세)와 채널생성(명부 시드 + 담당자 입력 + 채널 생성). |
|
| 11 |
+type Page = 'orgs' | 'channels' |
|
| 12 |
+ |
|
| 13 |
+const PAGE_TITLE: Record<Page, string> = {
|
|
| 14 |
+ orgs: '기관관리', |
|
| 15 |
+ channels: '채널생성', |
|
| 16 |
+} |
|
| 8 | 17 |
|
| 9 | 18 |
// 'checking'이 필요하다. true로 시작하면 첫 렌더에서 getOrgs()가 아직 안 끝났는데도 |
| 10 | 19 |
// 로그인한 것처럼 빈 껍데기를 먼저 보여주고, 401이 온 뒤에야 로그인 화면으로 튄다. |
... | ... | @@ -20,6 +29,7 @@ |
| 20 | 29 |
const [selectedId, setSelectedId] = useState<number | null>(null) |
| 21 | 30 |
const [auth, setAuth] = useState<AuthState>('checking')
|
| 22 | 31 |
const [authErrorMessage, setAuthErrorMessage] = useState<string | null>(null) |
| 32 |
+ const [page, setPage] = useState<Page>('orgs')
|
|
| 23 | 33 |
|
| 24 | 34 |
const reload = useCallback(async () => {
|
| 25 | 35 |
try {
|
... | ... | @@ -78,11 +88,18 @@ |
| 78 | 88 |
|
| 79 | 89 |
return ( |
| 80 | 90 |
<div className="flex h-screen"> |
| 81 |
- <Sidebar /> |
|
| 91 |
+ <Sidebar |
|
| 92 |
+ activeKey={page}
|
|
| 93 |
+ onSelect={(key) => {
|
|
| 94 |
+ if (key === 'orgs' || key === 'channels') {
|
|
| 95 |
+ setPage(key) |
|
| 96 |
+ } |
|
| 97 |
+ }} |
|
| 98 |
+ /> |
|
| 82 | 99 |
|
| 83 | 100 |
<div className="flex min-w-0 flex-1 flex-col"> |
| 84 | 101 |
<header className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-6"> |
| 85 |
- <span className="text-sm font-medium">기관관리</span> |
|
| 102 |
+ <span className="text-sm font-medium">{PAGE_TITLE[page]}</span>
|
|
| 86 | 103 |
<div className="flex items-center gap-3"> |
| 87 | 104 |
<span className="text-sm text-gray-500">관리자</span> |
| 88 | 105 |
<button |
... | ... | @@ -96,14 +113,18 @@ |
| 96 | 113 |
</header> |
| 97 | 114 |
|
| 98 | 115 |
<div className="flex min-h-0 flex-1"> |
| 99 |
- <div className="flex w-80 flex-col border-r border-gray-200"> |
|
| 116 |
+ <div className="flex w-80 shrink-0 flex-col border-r border-gray-200"> |
|
| 100 | 117 |
<OrgList orgs={orgs} selectedId={selectedId} onSelect={setSelectedId} />
|
| 101 |
- <SeedUpload onUploaded={() => void reload()} />
|
|
| 118 |
+ {page === 'channels' && <SeedUpload onUploaded={() => void reload()} />}
|
|
| 102 | 119 |
</div> |
| 103 | 120 |
|
| 104 |
- <main className="flex-1 overflow-y-auto"> |
|
| 121 |
+ <main className="min-w-0 flex-1 overflow-y-auto bg-gray-50/50"> |
|
| 105 | 122 |
{selected ? (
|
| 106 |
- <OrgDetail org={selected} onChanged={() => void reload()} />
|
|
| 123 |
+ page === 'channels' ? ( |
|
| 124 |
+ <OrgDetail org={selected} onChanged={() => void reload()} />
|
|
| 125 |
+ ) : ( |
|
| 126 |
+ <OrgOverview org={selected} />
|
|
| 127 |
+ ) |
|
| 107 | 128 |
) : ( |
| 108 | 129 |
<p className="p-8 text-sm text-gray-500">왼쪽에서 기관을 선택하세요.</p> |
| 109 | 130 |
)} |
+++ frontend/src/components/OrgOverview.test.tsx
... | ... | @@ -0,0 +1,97 @@ |
| 1 | +import { fireEvent, render, screen } from '@testing-library/react' | |
| 2 | +import { describe, expect, it } from 'vitest' | |
| 3 | +import OrgOverview from './OrgOverview' | |
| 4 | +import type { Org } from '../api/client' | |
| 5 | + | |
| 6 | +function org(overrides: Partial<Org> = {}): Org { | |
| 7 | + return { | |
| 8 | + id: 1, | |
| 9 | + orgNo: '001', | |
| 10 | + orgName: '국제방송교류재단', | |
| 11 | + status: 'ACTIVE', | |
| 12 | + deptName: '데이터정보화팀', | |
| 13 | + managerName: '송민지', | |
| 14 | + managerTitle: '과장', | |
| 15 | + managerPhone: '02-3475-5434', | |
| 16 | + managerEmail: 'ming@arirang.com', | |
| 17 | + channelIdMj: 'chan-mj', | |
| 18 | + channelIdLaw: 'chan-law', | |
| 19 | + ...overrides, | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +describe('OrgOverview', () => { | |
| 24 | + it('연번_기관명 헤더와 신청기관 담당자 정보를 보여준다', () => { | |
| 25 | + render(<OrgOverview org={org()} />) | |
| 26 | + | |
| 27 | + expect(screen.getByText('001_국제방송교류재단')).toBeTruthy() | |
| 28 | + expect(screen.getByText('데이터정보화팀')).toBeTruthy() | |
| 29 | + expect(screen.getByText('송민지 과장')).toBeTruthy() | |
| 30 | + expect(screen.getByText('02-3475-5434')).toBeTruthy() | |
| 31 | + expect(screen.getByText('ming@arirang.com')).toBeTruthy() | |
| 32 | + }) | |
| 33 | + | |
| 34 | + it('아직 연동 전인 문정원 담당자와 담당 변호사 카드는 자리만 보여준다', () => { | |
| 35 | + render(<OrgOverview org={org()} />) | |
| 36 | + | |
| 37 | + expect(screen.getByText('문정원 담당자')).toBeTruthy() | |
| 38 | + expect(screen.getByText('담당 변호사')).toBeTruthy() | |
| 39 | + expect(screen.getAllByText('추후 연동')).toHaveLength(2) | |
| 40 | + }) | |
| 41 | + | |
| 42 | + it('업무단계 12개가 순서대로 보인다', () => { | |
| 43 | + render(<OrgOverview org={org()} />) | |
| 44 | + | |
| 45 | + expect(screen.getByText('신청')).toBeTruthy() | |
| 46 | + expect(screen.getByText('변호사 배당')).toBeTruthy() | |
| 47 | + expect(screen.getByText('보고서 작성')).toBeTruthy() | |
| 48 | + }) | |
| 49 | + | |
| 50 | + it('탭을 누르면 해당 탭의 자리표시 내용으로 바뀐다', () => { | |
| 51 | + render(<OrgOverview org={org()} />) | |
| 52 | + | |
| 53 | + expect(screen.getByText('게시물 상세 화면은 추후 확정')).toBeTruthy() | |
| 54 | + | |
| 55 | + fireEvent.click(screen.getByRole('button', { name: '타임라인' })) | |
| 56 | + | |
| 57 | + expect(screen.getByText('타임라인 상세 화면은 추후 확정')).toBeTruthy() | |
| 58 | + expect(screen.queryByText('게시물 상세 화면은 추후 확정')).toBeNull() | |
| 59 | + }) | |
| 60 | + | |
| 61 | + it('채널이 있으면 Mattermost 열기가 채널 링크로 활성화된다', () => { | |
| 62 | + render(<OrgOverview org={org()} />) | |
| 63 | + | |
| 64 | + const link = screen.getByRole('link', { name: 'Mattermost 열기' }) | |
| 65 | + expect(link).toHaveAttribute( | |
| 66 | + 'href', | |
| 67 | + 'https://hub.iten.co.kr/itn-hub/channels/org-001-mj', | |
| 68 | + ) | |
| 69 | + }) | |
| 70 | + | |
| 71 | + it('채널이 없으면 Mattermost 열기가 비활성이다', () => { | |
| 72 | + render(<OrgOverview org={org({ channelIdMj: null, channelIdLaw: null, status: 'READY' })} />) | |
| 73 | + | |
| 74 | + expect(screen.queryByRole('link', { name: 'Mattermost 열기' })).toBeNull() | |
| 75 | + expect(screen.getByRole('button', { name: 'Mattermost 열기' })).toBeDisabled() | |
| 76 | + }) | |
| 77 | + | |
| 78 | + it('치안정책연구소는 policy 채널 링크를 쓴다', () => { | |
| 79 | + render( | |
| 80 | + <OrgOverview | |
| 81 | + org={org({ orgNo: '008', orgName: '경찰청_치안정책연구소', channelIdMj: 'x' })} | |
| 82 | + />, | |
| 83 | + ) | |
| 84 | + | |
| 85 | + expect(screen.getByRole('link', { name: 'Mattermost 열기' })).toHaveAttribute( | |
| 86 | + 'href', | |
| 87 | + 'https://hub.iten.co.kr/itn-hub/channels/org-008-policy-mj', | |
| 88 | + ) | |
| 89 | + }) | |
| 90 | + | |
| 91 | + it('미구현 동작 버튼들은 비활성이다', () => { | |
| 92 | + render(<OrgOverview org={org()} />) | |
| 93 | + | |
| 94 | + expect(screen.getByRole('button', { name: '진행단계 변경' })).toBeDisabled() | |
| 95 | + expect(screen.getByRole('button', { name: '보고서 관리' })).toBeDisabled() | |
| 96 | + }) | |
| 97 | +}) |
+++ frontend/src/components/OrgOverview.tsx
... | ... | @@ -0,0 +1,198 @@ |
| 1 | +import { useState } from 'react' | |
| 2 | +import type { Org } from '../api/client' | |
| 3 | +import StatusBadge from './StatusBadge' | |
| 4 | + | |
| 5 | +// 기관관리 상세 화면. 3단계 대시보드 시안의 "기관관리" 레이아웃을 따른다. | |
| 6 | +// 웹 DB에 있는 것(연번·기관명·신청기관 담당자·채널)만 실데이터고, | |
| 7 | +// 문정원 담당자/담당 변호사/업무단계/통계/탭 내용은 아직 백엔드가 없어 | |
| 8 | +// 시안과 같은 골격에 "추후 연동" 자리만 잡아 둔다. | |
| 9 | + | |
| 10 | +const STEPS = [ | |
| 11 | + '신청', | |
| 12 | + '목록접수', | |
| 13 | + '예비검토', | |
| 14 | + '변호사 배당', | |
| 15 | + '법률검토(권리확인)', | |
| 16 | + 'RE:확인', | |
| 17 | + '법률검토(권리확인) 완료', | |
| 18 | + '법률검토(권리처리)', | |
| 19 | + 'RE:처리', | |
| 20 | + '법률검토(권리처리) 완료', | |
| 21 | + '법률검토 최종완료', | |
| 22 | + '보고서 작성', | |
| 23 | +] | |
| 24 | + | |
| 25 | +const TABS = ['게시물', '자료', '권리확인', '권리처리', 'RE', '타임라인', '업무메모'] | |
| 26 | + | |
| 27 | +const DISABLED_ACTIONS = ['진행단계 변경', '담당 변호사 변경', '자료 업로드', '최초 게시글 보기', '보고서 관리'] | |
| 28 | + | |
| 29 | +// Mattermost 웹 주소. 채널 화면은 팀명 + 채널 내부명으로 열린다. | |
| 30 | +// 내부명 규칙은 백엔드 ChannelNaming.java가 정본이며 여기서는 열람 링크용으로만 복제한다. | |
| 31 | +const MATTERMOST_BASE = 'https://hub.iten.co.kr/itn-hub/channels/' | |
| 32 | +const SPECIAL_ORG_NAME = '경찰청_치안정책연구소' | |
| 33 | + | |
| 34 | +function mattermostChannelUrl(org: Org): string { | |
| 35 | + const slug = org.orgNo.trim().toLowerCase().replace(/_/g, '-').replace(/ /g, '-') | |
| 36 | + const name = | |
| 37 | + org.orgName.trim() === SPECIAL_ORG_NAME ? `org-${slug}-policy-mj` : `org-${slug}-mj` | |
| 38 | + return MATTERMOST_BASE + name | |
| 39 | +} | |
| 40 | + | |
| 41 | +function Field({ label, value }: { label: string; value: string | null }) { | |
| 42 | + return ( | |
| 43 | + <div className="flex gap-3 text-sm"> | |
| 44 | + <span className="w-14 shrink-0 text-gray-400">{label}</span> | |
| 45 | + <span className={value ? 'font-medium text-gray-900' : 'text-gray-300'}> | |
| 46 | + {value ?? '-'} | |
| 47 | + </span> | |
| 48 | + </div> | |
| 49 | + ) | |
| 50 | +} | |
| 51 | + | |
| 52 | +function PendingCard({ title }: { title: string }) { | |
| 53 | + return ( | |
| 54 | + <section className="rounded-lg border border-gray-200 p-4"> | |
| 55 | + <h2 className="text-sm font-semibold">{title}</h2> | |
| 56 | + <p className="mt-3 text-sm text-gray-300">추후 연동</p> | |
| 57 | + </section> | |
| 58 | + ) | |
| 59 | +} | |
| 60 | + | |
| 61 | +export default function OrgOverview({ org }: { org: Org }) { | |
| 62 | + const [activeTab, setActiveTab] = useState(TABS[0]) | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <div className="p-6"> | |
| 66 | + <div className="rounded-xl border border-gray-200 bg-white p-6"> | |
| 67 | + {/* 헤더 */} | |
| 68 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 69 | + <div className="flex items-center gap-3"> | |
| 70 | + <h1 className="text-lg font-bold"> | |
| 71 | + {org.orgNo}_{org.orgName} | |
| 72 | + </h1> | |
| 73 | + <StatusBadge status={org.status} /> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div className="flex flex-wrap gap-2"> | |
| 77 | + {DISABLED_ACTIONS.map((label) => ( | |
| 78 | + <button | |
| 79 | + key={label} | |
| 80 | + type="button" | |
| 81 | + disabled | |
| 82 | + title="준비 중" | |
| 83 | + className="cursor-not-allowed rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-300" | |
| 84 | + > | |
| 85 | + {label} | |
| 86 | + </button> | |
| 87 | + ))} | |
| 88 | + {org.channelIdMj ? ( | |
| 89 | + <a | |
| 90 | + href={mattermostChannelUrl(org)} | |
| 91 | + target="_blank" | |
| 92 | + rel="noreferrer" | |
| 93 | + className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50" | |
| 94 | + > | |
| 95 | + Mattermost 열기 | |
| 96 | + </a> | |
| 97 | + ) : ( | |
| 98 | + <button | |
| 99 | + type="button" | |
| 100 | + disabled | |
| 101 | + title="채널이 아직 없습니다" | |
| 102 | + className="cursor-not-allowed rounded-md border border-gray-200 px-3 py-1.5 text-sm text-gray-300" | |
| 103 | + > | |
| 104 | + Mattermost 열기 | |
| 105 | + </button> | |
| 106 | + )} | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | + | |
| 110 | + {/* 담당자 카드 3개 */} | |
| 111 | + <div className="mt-5 grid grid-cols-1 gap-3 lg:grid-cols-3"> | |
| 112 | + <section className="rounded-lg border border-gray-200 p-4"> | |
| 113 | + <h2 className="text-sm font-semibold">신청기관 담당자</h2> | |
| 114 | + <div className="mt-3 space-y-1.5"> | |
| 115 | + <Field label="부서" value={org.deptName} /> | |
| 116 | + <Field | |
| 117 | + label="담당자" | |
| 118 | + value={ | |
| 119 | + org.managerName | |
| 120 | + ? org.managerTitle | |
| 121 | + ? `${org.managerName} ${org.managerTitle}` | |
| 122 | + : org.managerName | |
| 123 | + : null | |
| 124 | + } | |
| 125 | + /> | |
| 126 | + <Field label="연락처" value={org.managerPhone} /> | |
| 127 | + <Field label="이메일" value={org.managerEmail} /> | |
| 128 | + </div> | |
| 129 | + </section> | |
| 130 | + | |
| 131 | + <PendingCard title="문정원 담당자" /> | |
| 132 | + <PendingCard title="담당 변호사" /> | |
| 133 | + </div> | |
| 134 | + | |
| 135 | + {/* 업무단계 현황 */} | |
| 136 | + <section className="mt-5"> | |
| 137 | + <h2 className="text-sm font-semibold">업무단계 현황</h2> | |
| 138 | + <ol className="mt-3 flex gap-2 overflow-x-auto pb-2"> | |
| 139 | + {STEPS.map((step, i) => ( | |
| 140 | + <li | |
| 141 | + key={step} | |
| 142 | + className="flex w-24 shrink-0 flex-col items-center gap-1 rounded-lg border border-gray-200 px-2 py-3 text-center" | |
| 143 | + > | |
| 144 | + <span className="flex h-6 w-6 items-center justify-center rounded-full border border-gray-300 text-xs text-gray-500"> | |
| 145 | + {i + 1} | |
| 146 | + </span> | |
| 147 | + <span className="text-[11px] leading-tight text-gray-600">{step}</span> | |
| 148 | + <span className="text-[11px] text-gray-300">-</span> | |
| 149 | + </li> | |
| 150 | + ))} | |
| 151 | + </ol> | |
| 152 | + </section> | |
| 153 | + | |
| 154 | + {/* 통계 4칸 */} | |
| 155 | + <div className="mt-4 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 156 | + {[ | |
| 157 | + ['사전검토', '검토 대상'], | |
| 158 | + ['권리확인', '완료 / 전체'], | |
| 159 | + ['권리처리', '완료 / 전체'], | |
| 160 | + ['RE', '확인·처리 합계'], | |
| 161 | + ].map(([title, caption]) => ( | |
| 162 | + <div key={title} className="rounded-lg border border-gray-200 p-4"> | |
| 163 | + <p className="text-xs text-gray-400">{title}</p> | |
| 164 | + <p className="mt-1 text-sm font-semibold"> | |
| 165 | + {caption} <span className="ml-1 text-lg text-gray-300">-</span> | |
| 166 | + </p> | |
| 167 | + </div> | |
| 168 | + ))} | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + {/* 탭 */} | |
| 172 | + <div className="mt-6 border-b border-gray-200"> | |
| 173 | + <nav className="flex gap-1" aria-label="기관 상세 탭"> | |
| 174 | + {TABS.map((tab) => ( | |
| 175 | + <button | |
| 176 | + key={tab} | |
| 177 | + type="button" | |
| 178 | + onClick={() => setActiveTab(tab)} | |
| 179 | + aria-current={activeTab === tab ? 'true' : undefined} | |
| 180 | + className={`border-b-2 px-4 py-2 text-sm ${ | |
| 181 | + activeTab === tab | |
| 182 | + ? 'border-blue-600 font-semibold text-blue-600' | |
| 183 | + : 'border-transparent text-gray-500 hover:text-gray-700' | |
| 184 | + }`} | |
| 185 | + > | |
| 186 | + {tab} | |
| 187 | + </button> | |
| 188 | + ))} | |
| 189 | + </nav> | |
| 190 | + </div> | |
| 191 | + | |
| 192 | + <div className="mt-4 rounded-lg border border-dashed border-gray-200 p-10 text-center text-sm text-gray-400"> | |
| 193 | + {activeTab} 상세 화면은 추후 확정 | |
| 194 | + </div> | |
| 195 | + </div> | |
| 196 | + </div> | |
| 197 | + ) | |
| 198 | +} |
--- frontend/src/components/Sidebar.test.tsx
+++ frontend/src/components/Sidebar.test.tsx
... | ... | @@ -1,14 +1,15 @@ |
| 1 |
-import { render, screen } from '@testing-library/react'
|
|
| 2 |
-import { describe, expect, it } from 'vitest'
|
|
| 1 |
+import { fireEvent, render, screen } from '@testing-library/react'
|
|
| 2 |
+import { describe, expect, it, vi } from 'vitest'
|
|
| 3 | 3 |
import Sidebar from './Sidebar' |
| 4 | 4 |
|
| 5 | 5 |
describe('Sidebar', () => {
|
| 6 |
- it('시안의 메뉴 8개를 순서대로 보여준다', () => {
|
|
| 6 |
+ it('메뉴를 순서대로 보여준다', () => {
|
|
| 7 | 7 |
render(<Sidebar />) |
| 8 | 8 |
|
| 9 | 9 |
const labels = [ |
| 10 | 10 |
'Dashboard', |
| 11 | 11 |
'기관관리', |
| 12 |
+ '채널생성', |
|
| 12 | 13 |
'사업관리', |
| 13 | 14 |
'권리확인', |
| 14 | 15 |
'권리처리', |
... | ... | @@ -20,11 +21,22 @@ |
| 20 | 21 |
expect(items).toEqual(labels) |
| 21 | 22 |
}) |
| 22 | 23 |
|
| 23 |
- it('기관관리가 현재 페이지로 표시된다', () => {
|
|
| 24 |
- render(<Sidebar />) |
|
| 24 |
+ it('activeKey에 해당하는 메뉴가 현재 페이지로 표시된다', () => {
|
|
| 25 |
+ render(<Sidebar activeKey="channels" />) |
|
| 25 | 26 |
|
| 26 |
- const active = screen.getByRole('link', { name: '기관관리' })
|
|
| 27 |
- expect(active).toHaveAttribute('aria-current', 'page')
|
|
| 27 |
+ expect(screen.getByRole('link', { name: '채널생성' }))
|
|
| 28 |
+ .toHaveAttribute('aria-current', 'page')
|
|
| 29 |
+ expect(screen.getByRole('link', { name: '기관관리' }))
|
|
| 30 |
+ .not.toHaveAttribute('aria-current')
|
|
| 31 |
+ }) |
|
| 32 |
+ |
|
| 33 |
+ it('활성 메뉴를 누르면 onSelect가 호출된다', () => {
|
|
| 34 |
+ const onSelect = vi.fn() |
|
| 35 |
+ render(<Sidebar activeKey="orgs" onSelect={onSelect} />)
|
|
| 36 |
+ |
|
| 37 |
+ fireEvent.click(screen.getByRole('link', { name: '채널생성' }))
|
|
| 38 |
+ |
|
| 39 |
+ expect(onSelect).toHaveBeenCalledWith('channels')
|
|
| 28 | 40 |
}) |
| 29 | 41 |
|
| 30 | 42 |
it('미구현 메뉴는 비활성으로 표시되고 링크가 아니다', () => {
|
--- frontend/src/components/Sidebar.tsx
+++ frontend/src/components/Sidebar.tsx
... | ... | @@ -36,6 +36,16 @@ |
| 36 | 36 |
), |
| 37 | 37 |
}, |
| 38 | 38 |
{
|
| 39 |
+ key: 'channels', |
|
| 40 |
+ label: '채널생성', |
|
| 41 |
+ enabled: true, |
|
| 42 |
+ icon: ( |
|
| 43 |
+ <svg className={ICON_CLASS} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
| 44 |
+ <path d="M9 3L7 21M17 3l-2 18M4 8h17M3 16h17" /> |
|
| 45 |
+ </svg> |
|
| 46 |
+ ), |
|
| 47 |
+ }, |
|
| 48 |
+ {
|
|
| 39 | 49 |
key: 'projects', |
| 40 | 50 |
label: '사업관리', |
| 41 | 51 |
enabled: false, |
... | ... | @@ -100,7 +110,12 @@ |
| 100 | 110 |
}, |
| 101 | 111 |
] |
| 102 | 112 |
|
| 103 |
-export default function Sidebar({ activeKey = 'orgs' }: { activeKey?: string }) {
|
|
| 113 |
+interface SidebarProps {
|
|
| 114 |
+ activeKey?: string |
|
| 115 |
+ onSelect?: (key: string) => void |
|
| 116 |
+} |
|
| 117 |
+ |
|
| 118 |
+export default function Sidebar({ activeKey = 'orgs', onSelect }: SidebarProps) {
|
|
| 104 | 119 |
return ( |
| 105 | 120 |
<aside className="flex w-56 shrink-0 flex-col border-r border-gray-200 bg-white"> |
| 106 | 121 |
<div className="px-5 pb-4 pt-5"> |
... | ... | @@ -115,7 +130,10 @@ |
| 115 | 130 |
<li key={item.key}>
|
| 116 | 131 |
<a |
| 117 | 132 |
href="/" |
| 118 |
- onClick={(e) => e.preventDefault()}
|
|
| 133 |
+ onClick={(e) => {
|
|
| 134 |
+ e.preventDefault() |
|
| 135 |
+ onSelect?.(item.key) |
|
| 136 |
+ }} |
|
| 119 | 137 |
aria-current={item.key === activeKey ? 'page' : undefined}
|
| 120 | 138 |
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm ${
|
| 121 | 139 |
item.key === activeKey |
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?