2026-05-10 21:34:17 +09:00
|
|
|
import { useState } from 'react';
|
2026-05-10 22:09:17 +09:00
|
|
|
import { Layout, Menu, Dropdown, Badge, Space, Avatar, Button, DatePicker, theme } from 'antd';
|
2026-05-10 21:34:17 +09:00
|
|
|
import { useNavigate, Outlet, useLocation } from 'react-router-dom';
|
2026-05-09 22:00:53 +09:00
|
|
|
import { useQuery } from '@tanstack/react-query';
|
2026-05-10 21:34:17 +09:00
|
|
|
import {
|
2026-05-10 22:09:17 +09:00
|
|
|
IconMenu2, IconBell, IconUser, IconLogout, IconChevronDown,
|
2026-05-10 21:34:17 +09:00
|
|
|
} from '@tabler/icons-react';
|
|
|
|
|
import dayjs from 'dayjs';
|
2026-05-09 22:00:53 +09:00
|
|
|
import { menuApi, MenuResp } from '@/api/menu';
|
|
|
|
|
import { authApi } from '@/api/auth';
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-05-10 22:09:17 +09:00
|
|
|
import { getMenuIcon } from '@/components/common/MenuIcon';
|
2026-05-09 22:00:53 +09:00
|
|
|
|
2026-05-10 22:09:17 +09:00
|
|
|
const { Header, Sider, Content, Footer } = Layout;
|
2026-05-09 22:00:53 +09:00
|
|
|
|
|
|
|
|
function toMenuItems(menus: MenuResp[]): any[] {
|
|
|
|
|
return menus.map((m) => ({
|
|
|
|
|
key: m.menuPath ?? m.menuCode,
|
|
|
|
|
label: m.menuName,
|
2026-05-10 22:09:17 +09:00
|
|
|
icon: getMenuIcon(m.menuCode, m.menuPath),
|
2026-05-09 22:00:53 +09:00
|
|
|
children: m.children?.length ? toMenuItems(m.children) : undefined,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function MainLayout() {
|
|
|
|
|
const navigate = useNavigate();
|
2026-05-10 21:34:17 +09:00
|
|
|
const location = useLocation();
|
2026-05-09 22:00:53 +09:00
|
|
|
const auth = useAuthStore();
|
2026-05-10 22:09:17 +09:00
|
|
|
const { token } = theme.useToken();
|
2026-05-10 21:34:17 +09:00
|
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
|
|
|
const [settleMonth, setSettleMonth] = useState(dayjs());
|
2026-05-09 22:00:53 +09:00
|
|
|
|
|
|
|
|
const { data: menus = [] } = useQuery({
|
|
|
|
|
queryKey: ['menu', 'my'],
|
|
|
|
|
queryFn: menuApi.myMenus,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
try { await authApi.logout(); } catch { /* ignore */ }
|
|
|
|
|
localStorage.removeItem('accessToken');
|
|
|
|
|
localStorage.removeItem('refreshToken');
|
|
|
|
|
auth.clear();
|
|
|
|
|
navigate('/login');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Layout style={{ minHeight: '100vh' }}>
|
2026-05-10 22:09:17 +09:00
|
|
|
<Sider
|
|
|
|
|
width={220}
|
|
|
|
|
collapsedWidth={64}
|
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
theme="light"
|
2026-05-10 21:34:17 +09:00
|
|
|
style={{
|
2026-05-10 22:09:17 +09:00
|
|
|
borderRight: '1px solid #f0f0f0',
|
2026-05-10 21:34:17 +09:00
|
|
|
background: '#ffffff',
|
2026-05-10 22:09:17 +09:00
|
|
|
boxShadow: '2px 0 8px rgba(0,0,0,0.04)',
|
|
|
|
|
overflow: 'auto',
|
|
|
|
|
height: '100vh',
|
|
|
|
|
position: 'sticky',
|
|
|
|
|
top: 0,
|
|
|
|
|
left: 0,
|
2026-05-10 21:34:17 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-05-10 22:09:17 +09:00
|
|
|
{/* 로고 영역 */}
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
height: 56,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
|
|
|
padding: collapsed ? 0 : '0 16px',
|
|
|
|
|
background: 'linear-gradient(135deg, #1677FF 0%, #0C447C 100%)',
|
|
|
|
|
color: '#fff',
|
|
|
|
|
gap: 10,
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
letterSpacing: 0.3,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
width: 32, height: 32, borderRadius: 8,
|
|
|
|
|
background: 'rgba(255,255,255,0.18)',
|
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
fontSize: 16, fontWeight: 800,
|
|
|
|
|
}}
|
|
|
|
|
>GA</div>
|
|
|
|
|
{!collapsed && <span style={{ fontSize: 14 }}>정산 시스템</span>}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Menu
|
|
|
|
|
mode="inline"
|
|
|
|
|
items={toMenuItems(menus)}
|
|
|
|
|
selectedKeys={[location.pathname]}
|
|
|
|
|
onClick={({ key }) => navigate(String(key))}
|
|
|
|
|
style={{ borderRight: 0, paddingTop: 8, paddingBottom: 8 }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{!collapsed && (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
|
|
|
|
padding: '12px 16px',
|
|
|
|
|
borderTop: '1px solid #f0f0f0',
|
|
|
|
|
background: '#fafafa',
|
|
|
|
|
fontSize: 11, color: '#999',
|
2026-05-10 21:34:17 +09:00
|
|
|
}}
|
|
|
|
|
>
|
2026-05-10 22:09:17 +09:00
|
|
|
v1.0.0-alpha · trading_ai
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Sider>
|
|
|
|
|
|
2026-05-09 22:00:53 +09:00
|
|
|
<Layout>
|
2026-05-10 22:09:17 +09:00
|
|
|
<Header
|
|
|
|
|
style={{
|
|
|
|
|
height: 48,
|
|
|
|
|
lineHeight: '48px',
|
|
|
|
|
background: '#ffffff',
|
|
|
|
|
borderBottom: '1px solid #f0f0f0',
|
|
|
|
|
padding: '0 16px',
|
|
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
|
|
|
|
|
position: 'sticky',
|
|
|
|
|
top: 0,
|
|
|
|
|
zIndex: 10,
|
|
|
|
|
}}
|
2026-05-10 21:34:17 +09:00
|
|
|
>
|
2026-05-10 22:09:17 +09:00
|
|
|
<Space size={8}>
|
|
|
|
|
<Button
|
|
|
|
|
type="text"
|
|
|
|
|
icon={<IconMenu2 size={18} />}
|
|
|
|
|
onClick={() => setCollapsed((c) => !c)}
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
fontSize: 13, fontWeight: 500, color: '#666',
|
|
|
|
|
paddingLeft: 8, borderLeft: '1px solid #f0f0f0',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{breadcrumbOf(location.pathname)}
|
|
|
|
|
</span>
|
|
|
|
|
</Space>
|
|
|
|
|
<Space size={12}>
|
|
|
|
|
<span style={{ fontSize: 12, color: '#999' }}>정산월</span>
|
|
|
|
|
<DatePicker
|
|
|
|
|
picker="month"
|
|
|
|
|
value={settleMonth}
|
|
|
|
|
onChange={(d) => d && setSettleMonth(d)}
|
|
|
|
|
allowClear={false}
|
|
|
|
|
format="YYYY-MM"
|
|
|
|
|
size="small"
|
|
|
|
|
style={{ width: 110 }}
|
|
|
|
|
/>
|
|
|
|
|
<Badge count={3} size="small" offset={[-2, 4]}>
|
|
|
|
|
<Button type="text" icon={<IconBell size={18} />} />
|
|
|
|
|
</Badge>
|
|
|
|
|
<Dropdown
|
|
|
|
|
menu={{
|
|
|
|
|
items: [
|
|
|
|
|
{
|
|
|
|
|
key: 'logout',
|
|
|
|
|
label: '로그아웃',
|
|
|
|
|
icon: <IconLogout size={14} />,
|
|
|
|
|
onClick: handleLogout,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Space
|
|
|
|
|
style={{
|
|
|
|
|
cursor: 'pointer', padding: '4px 8px', borderRadius: 6,
|
|
|
|
|
background: token.colorBgLayout,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Avatar
|
|
|
|
|
size={26}
|
|
|
|
|
style={{ background: 'linear-gradient(135deg, #1677FF 0%, #185FA5 100%)' }}
|
|
|
|
|
icon={<IconUser size={14} />}
|
|
|
|
|
/>
|
|
|
|
|
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
|
|
|
|
{auth.userName ?? auth.loginId ?? '관리자'}
|
|
|
|
|
</span>
|
|
|
|
|
<IconChevronDown size={12} color="#999" />
|
|
|
|
|
</Space>
|
|
|
|
|
</Dropdown>
|
|
|
|
|
</Space>
|
|
|
|
|
</Header>
|
2026-05-10 21:34:17 +09:00
|
|
|
<Content style={{ padding: 'var(--ga-content-padding)', overflow: 'auto' }}>
|
|
|
|
|
<div className="ga-page-container">
|
|
|
|
|
<Outlet />
|
|
|
|
|
</div>
|
2026-05-09 22:00:53 +09:00
|
|
|
</Content>
|
2026-05-10 22:09:17 +09:00
|
|
|
<Footer
|
|
|
|
|
style={{
|
|
|
|
|
textAlign: 'center', fontSize: 11, color: '#bbb',
|
|
|
|
|
padding: '8px 0', background: 'transparent',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
© GA Commission System · trading_ai · admin@local
|
|
|
|
|
</Footer>
|
2026-05-09 22:00:53 +09:00
|
|
|
</Layout>
|
|
|
|
|
</Layout>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-10 22:09:17 +09:00
|
|
|
|
|
|
|
|
/** 경로 → 한글 breadcrumb 라벨 */
|
|
|
|
|
function breadcrumbOf(pathname: string): string {
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
'/': '대시보드',
|
|
|
|
|
'/org/agents': '조직 / 설계사',
|
|
|
|
|
'/contracts': '계약 관리',
|
|
|
|
|
'/ledger/recruit': '원장 / 모집수수료',
|
|
|
|
|
'/ledger/exception': '원장 / 예외금액',
|
|
|
|
|
'/settle': '정산 / 결과',
|
|
|
|
|
'/settle/batch': '정산 / 배치 실행',
|
|
|
|
|
'/payments': '지급 관리',
|
|
|
|
|
'/system/users': '시스템 / 사용자',
|
|
|
|
|
'/system/roles': '시스템 / 역할 권한',
|
|
|
|
|
'/system/codes': '시스템 / 공통 코드',
|
|
|
|
|
};
|
|
|
|
|
return map[pathname] ?? '';
|
|
|
|
|
}
|