# Admin Multi-Tab Dashboard ## 언제 쓰는가 - 같은 데이터셋을 서로 다른 관점(리스트/차트/클러스터 등)으로 동시에 보여줘야 할 때 - 각 관점마다 필터는 공유하되, 집계·시각화 방식이 달라야 할 때 - Admin/Analytics 페이지에서 뷰 전환 + 필터 + 요약 KPI를 한 화면에 배치해야 할 때 ## 핵심 구조 - **3층 구조**: SummaryBar(숫자 요약) · FilterBar(공유 필터) · ViewSegment(뷰 전환 스위치) - **컨테이너 단일 책임**: 필터 state 보유, 서버 fetch(쿼리스트링 동기화) 는 컨테이너만, 프레젠테이션 컴포넌트는 pure - **데이터 플로우**: - 서버 파라미터 변경(필터) → 디바운스 후 refetch → AbortController로 이전 요청 취소 - 클라이언트 파생(정렬·bucket 필터 등) → `useMemo`로 displayData 계산 - 뷰 전환은 클라이언트 파생만 교체, 서버 fetch 불필요 - **일부 뷰 강제 파라미터**: 특정 뷰(예: Cluster)는 groupType을 강제 고정 - **파일 구조**: - `XxxView.jsx` — 컨테이너 - `XxxSummaryBar.jsx` · `XxxFilterBar.jsx` · 각 뷰별 프레젠테이션 파일 분리 - `xxxConstants.js` — 공유 색상 토큰·정렬 comparator ## 코드 예시 ```jsx // 컨테이너 const [view, setView] = useState('grouped') const [windowDays, setWindowDays] = useState(30) const [minCount, setMinCount] = useState(3) const fetchAbortRef = useRef(null) useEffect(() => { const h = setTimeout(() => { if (fetchAbortRef.current) fetchAbortRef.current.abort() const ctrl = new AbortController() fetchAbortRef.current = ctrl const params = new URLSearchParams({ windowDays, minCount, groupType: view === 'cluster' ? 'cluster' : groupType }) fetch(`/api/xxx?${params}`, { signal: ctrl.signal }) .then(r => r.json()).then(setData) .catch(e => e.name !== 'AbortError' && setError(e)) }, 300) return () => clearTimeout(h) }, [windowDays, minCount, view]) const display = useMemo(() => [...data].sort(sortComparators[sortBy]), [data, sortBy]) ``` ## 주의사항 - 서버 파라미터와 클라이언트 파생의 책임을 섞지 말 것 (서버 refetch 비용 ↑ 또는 재계산 낭비 ↑) - AbortController를 쓰지 않으면 이전 요청이 뒤늦게 도착해 state를 덮어씌우는 경쟁 조건 발생 - 필터 초기값은 URL query 또는 localStorage에서 읽어 새로고침 후 복원 가능하게 - 뷰 전환 시 뷰별 특수 필터(cluster 모드 등)를 자동 적용해도 사용자의 수동 선택값은 보존 - 뷰별로 서버 응답이 크게 다르다면 탭 단위로 fetch 분리하는 것이 나을 수 있음 ## 재사용 방법 1. 컨테이너(`XxxView.jsx`)를 먼저 복사, 뷰 키와 필터 키만 교체 2. SummaryBar/FilterBar는 각 도메인의 KPI·필터 항목에 맞춰 이름만 바꿔 사용 3. 공통 정렬 comparator·색상 토큰은 `xxxConstants.js`에 모으고 프레젠테이션에서 import 4. 참고 구현: `Obsidian_manage/src/components/growth/patterns/PatternsView.jsx`