BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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 BillCareLogo from './BillCareLogo'
|
||||
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)
|
||||
|
||||
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) || sec.items.length <= 1
|
||||
}
|
||||
if (sectionContainsPath(sec, location.pathname)) next[sec.id] = true
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [location.pathname, 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 = () => {
|
||||
localStorage.clear()
|
||||
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">
|
||||
<BillCareLogo title="BillCare" 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">
|
||||
<strong>[ BillCare ]</strong>
|
||||
<span className="header-divider" />
|
||||
<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] !== false
|
||||
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={({ isActive }) => (isActive || 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user