From 643b750958bb5ff146d36515428d2edfa4638e10 Mon Sep 17 00:00:00 2001 From: bat1120 Date: Wed, 6 May 2026 14:37:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin/fe):=20=EC=8A=88=ED=8D=BC=EC=96=B4?= =?UTF-8?q?=EB=93=9C=EB=AF=BC=20brand=20picker=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 슈퍼어드민이 시뮬 입력폼에서 14571개 brand 검색·선택 가능. - frontend/src/auth/AuthContext.tsx: User.role 에 'superadmin' 추가 - frontend/src/types/admin.ts: AdminBrand, SupportedIndustry, AdminBrandsResponse 타입 - frontend/src/api/client.ts: listAdminBrands, listAdminIndustries 메서드 - frontend/src/hooks/useAdminBrands.ts: debounce 300ms + AbortController + 403 핸들링 - frontend/src/components/admin/AdminBrandPicker.tsx: 모달 (검색 + 업종 필터칩 + 페이징) - 가맹점 수 / 평균 매출 표시 - source 라벨 (FTC / 회원사) - ESC/backdrop 클릭으로 닫기 - frontend/src/App.tsx (SimulatorDashboard): - role==='superadmin' 일 때만 [브랜드 선택] FormField 노출 - adminBrand 선택 시 brand_name 오버라이드 + businessType 자동 설정 - 해제 버튼으로 picker 비활성화 가능 검증: tsc --noEmit clean / vite build 성공 / prettier 통과. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/App.tsx | 67 ++++- frontend/src/api/client.ts | 20 ++ frontend/src/auth/AuthContext.tsx | 2 +- .../src/components/admin/AdminBrandPicker.tsx | 249 ++++++++++++++++++ frontend/src/hooks/useAdminBrands.ts | 98 +++++++ frontend/src/types/admin.ts | 48 ++++ 6 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/admin/AdminBrandPicker.tsx create mode 100644 frontend/src/hooks/useAdminBrands.ts create mode 100644 frontend/src/types/admin.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49b90f78..1d358240 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,6 +86,8 @@ import { getLivePopulation } from './api/client'; import { useCombinedSimResult, buildCombinedResult } from './hooks/useCombinedSimResult'; import NetworkBackground from './components/NetworkBackground'; import WaveBackground from './pages/landing/WaveBackground'; +import { AdminBrandPicker } from './components/admin/AdminBrandPicker'; +import type { AdminBrand } from './types/admin'; import PulsatingDots from './components/ui/PulsatingDots'; // Phase C Round 2 — PDF 묶음 + Dashboard 묶음 추출 (정적 import 유지) @@ -782,6 +784,10 @@ function SimulatorDashboard({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [brand?.industry_medium]); const [businessTypeOpen, setBusinessTypeOpen] = useState(false); + // [superadmin] brand picker — role==='superadmin' 만 보이고, 선택 시 brand_name + businessType 오버라이드. + const [adminBrand, setAdminBrand] = useState(null); + const [adminPickerOpen, setAdminPickerOpen] = useState(false); + const isSuperadmin = user?.role === 'superadmin'; const [storeArea, setStoreArea] = useState(initParams?.store_area ?? 15); // 평 const [targetPrice, setTargetPrice] = useState(initParams?.target_price_range ?? '5to10k'); const [operatingHours, setOperatingHours] = useState( @@ -983,9 +989,14 @@ function SimulatorDashboard({ // business_type: UI 한글 라벨 → _SALES_CODE_MAP 키로 변환. // brand_name: 브랜드 자동매핑 결과 우선, 없으면 company_name 폴백. // lat/lon: 출점 후보지 좌표 (학교환경위생정화구역 거리 룰 트리거). 미입력 시 backend caution. + // [superadmin] adminBrand 선택 시 brand_name 오버라이드. business_type 은 사용자가 dropdown 으로 명시 변경한 값 우선 사용 (admin 도 자유 변경 가능). + const effectiveBrandName = + isSuperadmin && adminBrand?.brand_name + ? adminBrand.brand_name + : brand?.brand_name || user?.company_name || ''; const payload = { business_type: BUSINESS_TYPE_BACKEND_KEY[businessType] || businessType, - brand_name: brand?.brand_name || user?.company_name || '', + brand_name: effectiveBrandName, target_district: selectedDongs[0] || '서교동', target_districts: selectedDongs.length > 0 ? selectedDongs : ['서교동'], existing_stores: [], @@ -1207,6 +1218,43 @@ function SimulatorDashboard({ + {/* [superadmin] brand picker — role === 'superadmin' 만 노출 */} + {isSuperadmin && ( + +
+ + {adminBrand && ( + + )} +
+ {adminBrand && ( +
+ {adminBrand.corp_name ?? '—'} + {typeof adminBrand.franchise_count === 'number' && + ` · 가맹점 ${adminBrand.franchise_count.toLocaleString()}개`} +
+ )} +
+ )} + {/* 유동인구 가중치 */} + + {/* [superadmin] brand picker 모달 */} + {isSuperadmin && ( + setAdminPickerOpen(false)} + onSelect={(b) => { + setAdminBrand(b); + // 선택된 브랜드의 cs_code 에 매칭되는 frontend 라벨로 businessType 자동 설정 + const frontendLabel = FRONTEND_LABEL_FROM_BACKEND_KEY[b.business_type]; + if (frontendLabel) setBusinessType(frontendLabel); + }} + initialIndustry={ + adminBrand?.business_type ?? BUSINESS_TYPE_BACKEND_KEY[businessType] ?? undefined + } + /> + )} ); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 19bb3062..fb9a74b3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -34,6 +34,11 @@ import type { SimulationHistoryItem, } from '../types/simulationHistory'; import type { TokenUsageResponse } from '../types/tokenUsage'; +import type { + AdminBrandsQuery, + AdminBrandsResponse, + AdminIndustriesResponse, +} from '../types/admin'; /** * [v11.5 멀티테넌시 사전 준비] @@ -517,4 +522,19 @@ export async function getTokenUsage(params: { return response.data; } +// ───────────────────────────────────────────────────────── +// admin/brands (슈퍼어드민 전용 brand picker) +// 백엔드: backend/src/api/admin_brands.py — role != 'superadmin' 시 403. +// ───────────────────────────────────────────────────────── + +export async function listAdminBrands(query: AdminBrandsQuery = {}): Promise { + const response = await apiClient.get('/admin/brands', { params: query }); + return response.data; +} + +export async function listAdminIndustries(): Promise { + const response = await apiClient.get('/admin/brands/industries'); + return response.data; +} + export default apiClient; diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 7dfd3708..f42d1d8c 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -19,7 +19,7 @@ interface User { position: string; store_count: string; plan: string; - role?: 'master' | 'manager'; + role?: 'master' | 'manager' | 'superadmin'; } interface Brand { diff --git a/frontend/src/components/admin/AdminBrandPicker.tsx b/frontend/src/components/admin/AdminBrandPicker.tsx new file mode 100644 index 00000000..7512016b --- /dev/null +++ b/frontend/src/components/admin/AdminBrandPicker.tsx @@ -0,0 +1,249 @@ +/** + * 슈퍼어드민 brand picker 모달. + * + * 사용 흐름: + * 1. 시뮬 입력폼에서 슈퍼어드민이 [브랜드 선택] 버튼 클릭 → 모달 열림 + * 2. 검색어 입력 + 업종 필터 → /admin/brands 조회 + * 3. 클릭 → onSelect 콜백으로 AdminBrand 전달, 모달 닫힘 + * + * 호스트 (App.tsx) 가 onSelect 받아 brand_name 오버라이드 + businessType 자동 설정. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { Search, Building2, X, Store, AlertCircle } from 'lucide-react'; +import { useAdminBrands } from '../../hooks/useAdminBrands'; +import type { AdminBrand } from '../../types/admin'; + +export interface AdminBrandPickerProps { + open: boolean; + onClose: () => void; + onSelect: (brand: AdminBrand) => void; + /** 초기 업종 필터 (canonical key). 비우면 전체. */ + initialIndustry?: string; +} + +const PAGE_SIZE = 50; + +export function AdminBrandPicker({ + open, + onClose, + onSelect, + initialIndustry, +}: AdminBrandPickerProps) { + const [query, setQuery] = useState(''); + const [industry, setIndustry] = useState(initialIndustry ?? ''); + const [page, setPage] = useState(1); + + // 모달 열릴 때 초기화 + useEffect(() => { + if (open) { + setQuery(''); + setIndustry(initialIndustry ?? ''); + setPage(1); + } + }, [open, initialIndustry]); + + // ESC → close + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [open, onClose]); + + const { items, total, supportedIndustries, loading, error, forbidden } = useAdminBrands({ + q: query, + industry: industry || undefined, + page, + size: PAGE_SIZE, + enabled: open, + }); + + const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

+ 브랜드 선택 슈퍼어드민 +

+
+ +
+ + {/* Filters */} +
+
+ + { + setQuery(e.target.value); + setPage(1); + }} + placeholder="브랜드명 또는 기업명 검색 (예: 스타벅스)" + className="w-full pl-9 pr-3 h-9 rounded-lg border border-border bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary" + autoFocus + /> +
+ +
+ + {supportedIndustries.map((ind) => ( + + ))} +
+
+ + {/* List */} +
+ {forbidden && ( +
+ + 슈퍼어드민 권한이 없습니다. 일반 master / manager 는 picker 사용 불가. +
+ )} + {error && !forbidden && ( +
{error}
+ )} + {!forbidden && !error && loading && ( +
조회 중…
+ )} + {!forbidden && !error && !loading && items.length === 0 && ( +
+ 검색 결과 없음. 다른 키워드 / 업종 필터를 시도하세요. +
+ )} + {!forbidden && !error && items.length > 0 && ( +
    + {items.map((b, i) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Footer — pagination */} + {!forbidden && !error && items.length > 0 && ( +
+ + 총 {total.toLocaleString()}개 · {page} / {totalPages} 페이지 + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/hooks/useAdminBrands.ts b/frontend/src/hooks/useAdminBrands.ts new file mode 100644 index 00000000..e82df7d4 --- /dev/null +++ b/frontend/src/hooks/useAdminBrands.ts @@ -0,0 +1,98 @@ +/** + * 슈퍼어드민 brand 검색 hook. + * - debounce 300ms + * - AbortController 로 stale 요청 취소 (race condition 방지) + * - 403 (role != superadmin) 시 forbidden=true 반환, error 비움 + */ + +import axios from 'axios'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { listAdminBrands } from '../api/client'; +import type { AdminBrand, SupportedIndustry } from '../types/admin'; + +export interface UseAdminBrandsOptions { + q?: string; + industry?: string; + page?: number; + size?: number; + enabled?: boolean; + debounceMs?: number; +} + +export interface UseAdminBrandsResult { + items: AdminBrand[]; + total: number; + supportedIndustries: SupportedIndustry[]; + loading: boolean; + error: string | null; + forbidden: boolean; + refetch: () => void; +} + +export function useAdminBrands({ + q, + industry, + page = 1, + size = 50, + enabled = true, + debounceMs = 300, +}: UseAdminBrandsOptions): UseAdminBrandsResult { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [supportedIndustries, setSupportedIndustries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [forbidden, setForbidden] = useState(false); + const [refetchToken, setRefetchToken] = useState(0); + + const abortRef = useRef(null); + const refetch = useCallback(() => setRefetchToken((n) => n + 1), []); + + useEffect(() => { + if (!enabled) { + setItems([]); + setTotal(0); + return; + } + + const handle = window.setTimeout(() => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + setError(null); + setForbidden(false); + + listAdminBrands({ q: q?.trim() || undefined, industry, page, size }) + .then((res) => { + if (controller.signal.aborted) return; + setItems(res.items); + setTotal(res.total); + if (res.supported_industries?.length > 0) { + setSupportedIndustries(res.supported_industries); + } + }) + .catch((err: unknown) => { + if (controller.signal.aborted || axios.isCancel(err)) return; + if (axios.isAxiosError(err) && err.response?.status === 403) { + setForbidden(true); + setItems([]); + setTotal(0); + return; + } + setError(err instanceof Error ? err.message : '브랜드 조회 실패'); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + }, debounceMs); + + return () => { + window.clearTimeout(handle); + abortRef.current?.abort(); + }; + }, [q, industry, page, size, enabled, debounceMs, refetchToken]); + + return { items, total, supportedIndustries, loading, error, forbidden, refetch }; +} diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts new file mode 100644 index 00000000..05010b03 --- /dev/null +++ b/frontend/src/types/admin.ts @@ -0,0 +1,48 @@ +/** + * 슈퍼어드민 brand picker 관련 타입. + * 백엔드 응답 (`backend/src/api/admin_brands.py`) 와 1:1. + */ + +export interface AdminBrand { + brand_name: string; + corp_name: string | null; + biz_number: string | null; + /** canonical 업종 key — '한식', '커피' 등. App.tsx 의 BUSINESS_TYPE_BACKEND_KEY 와 매칭. */ + business_type: string; + /** CS100001 ~ CS100010 */ + cs_code: string; + industry_medium: string | null; + franchise_count: number | null; + avg_sales: number | null; + source: 'ftc' | 'biz_brand_mapping'; +} + +export interface SupportedIndustry { + /** canonical key — '한식', '커피' 등 */ + key: string; + /** UI 표시명 — '한식음식점', '커피-음료' */ + label: string; + /** CS100001 ~ CS100010 */ + cs_code: string; + kakao_category?: string; +} + +export interface AdminBrandsResponse { + total: number; + page: number; + size: number; + supported_industries: SupportedIndustry[]; + items: AdminBrand[]; +} + +export interface AdminIndustriesResponse { + industries: SupportedIndustry[]; +} + +export interface AdminBrandsQuery { + q?: string; + /** canonical key */ + industry?: string; + page?: number; + size?: number; +}