Files
BizCare/frontend/src/components/AgentShell.tsx
T
2026-07-12 19:31:20 +09:00

263 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import {
AppstoreOutlined, CalendarOutlined, ShoppingCartOutlined,
} from '@ant-design/icons'
import SchedulePanel from './SchedulePanel'
import BizCareLogo from './BizCareLogo'
import AccountPopover from './AccountPopover'
import { careMenuSections, filterMenuSections, resolveSelectedKey, sectionContainsPath, shopMenuSections } from '../menu'
import { fetchMyMenus } from '../api/auth'
import { THEMES } from '../theme'
import { useTheme } from '../themeContext'
const PANEL_MIN = 170
const PANEL_MAX = 420
type Props = {
shop?: boolean
}
export default function AgentShell({ shop = false }: Props) {
const navigate = useNavigate()
const location = useLocation()
const { theme, changeTheme } = useTheme()
const [scheduleOpen, setScheduleOpen] = useState(false)
const userName = localStorage.getItem('userName') || (shop ? '판매점' : '관리자')
const [allowedMenus, setAllowedMenus] = useState<string[] | null>(() => {
if (shop) return null
try {
const cached = localStorage.getItem('allowedMenus')
return cached ? (JSON.parse(cached) as string[]) : []
} catch {
return []
}
})
const [panelWidth, setPanelWidth] = useState(() => {
const v = Number(localStorage.getItem('lp.width'))
return v >= PANEL_MIN && v <= PANEL_MAX ? v : 220
})
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('lp.collapsed') === '1')
const draggingRef = useRef(false)
const [dragging, setDragging] = useState(false)
useEffect(() => {
if (shop) {
setAllowedMenus(null)
return
}
let cancelled = false
fetchMyMenus()
.then((res) => {
if (cancelled) return
if (res.staffPermission) localStorage.setItem('staffPermission', res.staffPermission)
setAllowedMenus(res.menus || [])
try {
localStorage.setItem('allowedMenus', JSON.stringify(res.menus || []))
} catch { /* ignore */ }
})
.catch(() => {
if (cancelled) return
try {
const cached = localStorage.getItem('allowedMenus')
if (cached) setAllowedMenus(JSON.parse(cached) as string[])
else setAllowedMenus(['home'])
} catch {
setAllowedMenus(['home'])
}
})
return () => { cancelled = true }
}, [shop])
const allSections = useMemo(() => (shop ? shopMenuSections() : careMenuSections()), [shop])
const sections = useMemo(
() => (shop ? allSections : filterMenuSections(allSections, allowedMenus)),
[shop, allSections, allowedMenus],
)
const selectedKey = resolveSelectedKey(location.pathname, location.search)
const [openCards, setOpenCards] = useState<Record<string, boolean>>({})
useEffect(() => {
setOpenCards((prev) => {
const next = { ...prev }
sections.forEach((sec) => {
// 기본은 접힘. 현재 경로가 속한 섹션만 펼침
if (next[sec.id] === undefined) {
next[sec.id] = sectionContainsPath(sec, location.pathname)
}
if (sectionContainsPath(sec, location.pathname)) next[sec.id] = true
})
return next
})
}, [location.pathname, location.search, sections])
useEffect(() => {
localStorage.setItem('lp.width', String(panelWidth))
}, [panelWidth])
useEffect(() => {
localStorage.setItem('lp.collapsed', collapsed ? '1' : '0')
}, [collapsed])
const startResize = useCallback((e: React.MouseEvent) => {
e.preventDefault()
draggingRef.current = true
setDragging(true)
document.body.style.userSelect = 'none'
document.body.style.cursor = 'col-resize'
}, [])
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!draggingRef.current) return
setPanelWidth(Math.min(PANEL_MAX, Math.max(PANEL_MIN, e.clientX)))
}
const onUp = () => {
if (!draggingRef.current) return
draggingRef.current = false
setDragging(false)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
return () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
}, [])
const logout = () => {
const theme = localStorage.getItem('bizcare_ui_theme')
localStorage.clear()
if (theme) localStorage.setItem('bizcare_ui_theme', theme)
navigate('/login')
}
const toggleCard = (id: string) => {
setOpenCards((prev) => ({ ...prev, [id]: !prev[id] }))
}
return (
<div className="app-shell">
<header className="topbar">
<div className={`topbar-brand${collapsed ? ' is-collapsed' : ''}`} style={collapsed ? undefined : { width: panelWidth }}>
{!collapsed && (
<div className="brand-inline">
<BizCareLogo title="BizCare" size="header" />
</div>
)}
</div>
<div className="topbar-main">
<div className="topbar-left-tools">
{shop ? (
<span className="topbar-user muted">에이전트 데모 / {userName}</span>
) : (
<div className="agent-header-tools">
<ShoppingCartOutlined />
<span>문자발송(장바구니)</span>
<span className="sms-remain">잔여SMS <b>996</b>건</span>
</div>
)}
</div>
<div className="topbar-right">
{!shop && (
<>
<button
type="button"
className={`header-icon-btn${location.pathname.includes('/sitemap') ? ' active' : ''}`}
aria-label="전체보기"
title="전체보기"
onClick={() => navigate('/care/main/sitemap')}
>
<AppstoreOutlined />
</button>
<AccountPopover onLogout={logout} />
<button
type="button"
className={`header-icon-btn${scheduleOpen ? ' active' : ''}`}
aria-label="일정표"
onClick={() => setScheduleOpen((v) => !v)}
>
<CalendarOutlined />
</button>
</>
)}
<select
className="theme-select"
value={theme}
onChange={(e) => changeTheme(e.target.value as typeof theme)}
title="테마 선택"
>
{THEMES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
</div>
</header>
<div className="app-body">
{!collapsed && (
<aside className="leftpanel" style={{ width: panelWidth }}>
{sections.map((sec) => {
const open = openCards[sec.id] === true
const activeSec = sectionContainsPath(sec, location.pathname)
return (
<div className={`lp-card${activeSec ? ' is-active' : ''}`} key={sec.id}>
<button
type="button"
className="lp-card-title"
onClick={() => toggleCard(sec.id)}
aria-expanded={open}
>
<span>{sec.title}</span>
<span className="lp-card-chevron">{open ? '▾' : '▸'}</span>
</button>
{open && (
<div className="lp-card-body">
{sec.items.map((it) => (
<NavLink
key={it.key}
to={it.key}
title={it.label}
className={() => (selectedKey === it.key ? 'active' : undefined)}
>
{it.label}
</NavLink>
))}
</div>
)}
</div>
)
})}
</aside>
)}
{!collapsed && (
<div className={`panel-resizer${dragging ? ' dragging' : ''}`} onMouseDown={startResize} />
)}
<button
type="button"
className="panel-toggle"
style={{ left: collapsed ? 0 : panelWidth }}
title={collapsed ? '패널 펼치기' : '패널 접기'}
onClick={() => setCollapsed((c) => !c)}
>
{collapsed ? '›' : '‹'}
</button>
<div className="main-area">
<div className="content">
<Outlet />
</div>
</div>
</div>
{!shop && <SchedulePanel open={scheduleOpen} onClose={() => setScheduleOpen(false)} />}
</div>
)
}