263 lines
8.8 KiB
TypeScript
263 lines
8.8 KiB
TypeScript
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>
|
||
)
|
||
}
|