Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 유지)

Expand Down Expand Up @@ -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<AdminBrand | null>(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<string[]>(
Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -1207,6 +1218,43 @@ function SimulatorDashboard({
</div>
</FormField>

{/* [superadmin] brand picker — role === 'superadmin' 만 노출 */}
{isSuperadmin && (
<FormField label="브랜드 (슈퍼어드민)" icon={Store}>
<div className="flex gap-2">
<button
type="button"
onClick={() => setAdminPickerOpen(true)}
className="flex-1 flex items-center justify-between px-3 h-10 rounded-lg border border-primary/40 bg-primary/5 text-sm text-foreground hover:border-primary transition-colors"
>
<span className="truncate">
{adminBrand
? `${adminBrand.brand_name}${adminBrand.industry_medium ? ` · ${adminBrand.industry_medium}` : ''}`
: '브랜드 선택…'}
</span>
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
</button>
{adminBrand && (
<button
type="button"
onClick={() => setAdminBrand(null)}
className="px-2 h-10 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="브랜드 선택 해제"
>
해제
</button>
)}
</div>
{adminBrand && (
<div className="mt-1 text-[11px] text-muted-foreground">
{adminBrand.corp_name ?? '—'}
{typeof adminBrand.franchise_count === 'number' &&
` · 가맹점 ${adminBrand.franchise_count.toLocaleString()}개`}
</div>
)}
</FormField>
)}

{/* 유동인구 가중치 */}
<FormField
label="유동인구 가중치"
Expand Down Expand Up @@ -1510,6 +1558,23 @@ function SimulatorDashboard({
selectedLegalType={selectedLegalType}
/>
</Suspense>

{/* [superadmin] brand picker 모달 */}
{isSuperadmin && (
<AdminBrandPicker
open={adminPickerOpen}
onClose={() => 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
}
/>
)}
</div>
);
}
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 멀티테넌시 사전 준비]
Expand Down Expand Up @@ -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<AdminBrandsResponse> {
const response = await apiClient.get<AdminBrandsResponse>('/admin/brands', { params: query });
return response.data;
}

export async function listAdminIndustries(): Promise<AdminIndustriesResponse> {
const response = await apiClient.get<AdminIndustriesResponse>('/admin/brands/industries');
return response.data;
}

export default apiClient;
2 changes: 1 addition & 1 deletion frontend/src/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface User {
position: string;
store_count: string;
plan: string;
role?: 'master' | 'manager';
role?: 'master' | 'manager' | 'superadmin';
}

interface Brand {
Expand Down
249 changes: 249 additions & 0 deletions frontend/src/components/admin/AdminBrandPicker.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(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 (
<div
className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="브랜드 선택"
>
<div
className="w-full max-w-3xl max-h-[85vh] bg-card border border-border rounded-xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
<h2 className="text-base font-semibold text-foreground">
브랜드 선택 <span className="text-xs text-muted-foreground ml-2">슈퍼어드민</span>
</h2>
</div>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="닫기"
>
<X size={18} />
</button>
</div>

{/* Filters */}
<div className="px-5 py-3 border-b border-border space-y-2">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
value={query}
onChange={(e) => {
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
/>
</div>

<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => {
setIndustry('');
setPage(1);
}}
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
industry === ''
? 'bg-primary text-primary-foreground border-primary'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
>
전체
</button>
{supportedIndustries.map((ind) => (
<button
key={ind.key}
type="button"
onClick={() => {
setIndustry(ind.key);
setPage(1);
}}
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
industry === ind.key
? 'bg-primary text-primary-foreground border-primary'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
>
{ind.label}
</button>
))}
</div>
</div>

{/* List */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{forbidden && (
<div className="p-6 text-center text-sm text-destructive flex flex-col items-center gap-2">
<AlertCircle size={20} />
슈퍼어드민 권한이 없습니다. 일반 master / manager 는 picker 사용 불가.
</div>
)}
{error && !forbidden && (
<div className="p-6 text-center text-sm text-destructive">{error}</div>
)}
{!forbidden && !error && loading && (
<div className="p-6 text-center text-sm text-muted-foreground">조회 중…</div>
)}
{!forbidden && !error && !loading && items.length === 0 && (
<div className="p-6 text-center text-sm text-muted-foreground">
검색 결과 없음. 다른 키워드 / 업종 필터를 시도하세요.
</div>
)}
{!forbidden && !error && items.length > 0 && (
<ul className="divide-y divide-border">
{items.map((b, i) => (
<li key={`${b.brand_name}-${b.corp_name ?? ''}-${i}`}>
<button
type="button"
onClick={() => {
onSelect(b);
onClose();
}}
className="w-full text-left px-5 py-3 hover:bg-muted transition-colors flex items-start gap-3"
>
<div className="mt-0.5 shrink-0">
<Store size={16} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-foreground truncate">
{b.brand_name}
</span>
<span className="text-xs text-muted-foreground truncate">
{b.industry_medium ?? b.business_type}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5 truncate">
{b.corp_name ?? '—'}
</div>
<div className="text-xs text-muted-foreground mt-0.5 flex gap-3">
{typeof b.franchise_count === 'number' && (
<span>가맹점 {b.franchise_count.toLocaleString()}개</span>
)}
{typeof b.avg_sales === 'number' && b.avg_sales > 0 && (
<span>
평균매출 {Math.round(b.avg_sales / 1000).toLocaleString()}백만원
</span>
)}
<span className="ml-auto text-[10px] text-muted-foreground/70">
{b.source === 'biz_brand_mapping' ? '회원사' : 'FTC'}
</span>
</div>
</div>
</button>
</li>
))}
</ul>
)}
</div>

{/* Footer — pagination */}
{!forbidden && !error && items.length > 0 && (
<div className="px-5 py-3 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
<span>
총 {total.toLocaleString()}개 · {page} / {totalPages} 페이지
</span>
<div className="flex gap-2">
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="px-2 py-1 border border-border rounded disabled:opacity-30 hover:text-foreground"
>
이전
</button>
<button
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
className="px-2 py-1 border border-border rounded disabled:opacity-30 hover:text-foreground"
>
다음
</button>
</div>
</div>
)}
</div>
</div>
);
}
Loading
Loading