BillCare 초기 소스 등록

비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-12 05:05:17 +09:00
commit 03cd93e1f2
160 changed files with 47915 additions and 0 deletions
+261
View File
@@ -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>
)
}