2026-05-14 01:21:27 +09:00
|
|
|
import { useEffect, useState } from 'react';
|
2026-05-10 22:20:33 +09:00
|
|
|
import { ProLayout, type MenuDataItem } from '@ant-design/pro-components';
|
2026-05-14 01:21:27 +09:00
|
|
|
import { Avatar, Button, DatePicker, Dropdown, Space, Typography } from 'antd';
|
2026-05-10 22:20:33 +09:00
|
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
2026-05-09 22:00:53 +09:00
|
|
|
import { useQuery } from '@tanstack/react-query';
|
2026-05-14 01:21:27 +09:00
|
|
|
import { IconChevronDown, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
|
2026-05-10 21:34:17 +09:00
|
|
|
import dayjs from 'dayjs';
|
2026-05-10 22:20:33 +09:00
|
|
|
import { menuApi, type MenuResp } from '@/api/menu';
|
2026-05-09 22:00:53 +09:00
|
|
|
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-14 01:21:27 +09:00
|
|
|
import ErrorBoundary from '@/components/common/ErrorBoundary';
|
|
|
|
|
import NotificationBell from '@/components/biz/NotificationBell';
|
|
|
|
|
import {
|
|
|
|
|
COLOR_PRIMARY, COLOR_PRIMARY_LIGHT, GRAY, LAYOUT,
|
|
|
|
|
} from '@/theme/tokens';
|
|
|
|
|
|
|
|
|
|
const { Text } = Typography;
|
|
|
|
|
|
|
|
|
|
const COLLAPSE_KEY = 'ga-sider-collapsed';
|
2026-05-09 22:00:53 +09:00
|
|
|
|
2026-05-10 22:20:33 +09:00
|
|
|
/** API 메뉴 트리 → ProLayout MenuDataItem */
|
|
|
|
|
function toProMenu(menus: MenuResp[]): MenuDataItem[] {
|
|
|
|
|
return menus.map((m) => {
|
|
|
|
|
const item: MenuDataItem = {
|
2026-05-14 01:21:27 +09:00
|
|
|
name: m.menuName,
|
|
|
|
|
path: m.menuPath ?? `/_grp/${m.menuCode}`,
|
|
|
|
|
key: m.menuCode,
|
|
|
|
|
icon: getMenuIcon(m.menuCode, m.menuPath),
|
2026-05-10 22:20:33 +09:00
|
|
|
};
|
|
|
|
|
if (m.children?.length) item.children = toProMenu(m.children);
|
|
|
|
|
return item;
|
|
|
|
|
});
|
2026-05-09 22:00:53 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 01:21:27 +09:00
|
|
|
/** 로컬스토리지에서 collapse 상태 읽기 */
|
|
|
|
|
function readCollapsed(): boolean {
|
|
|
|
|
try { return localStorage.getItem(COLLAPSE_KEY) === 'true'; } catch { return false; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 22:00:53 +09:00
|
|
|
export default function MainLayout() {
|
2026-05-14 01:21:27 +09:00
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const location = useLocation();
|
|
|
|
|
const auth = useAuthStore();
|
2026-05-10 21:34:17 +09:00
|
|
|
const [settleMonth, setSettleMonth] = useState(dayjs());
|
2026-05-14 01:21:27 +09:00
|
|
|
const [collapsed, setCollapsed] = useState(readCollapsed);
|
|
|
|
|
|
|
|
|
|
// collapse 상태 localStorage 저장
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
try { localStorage.setItem(COLLAPSE_KEY, String(collapsed)); } catch { /* ignore */ }
|
|
|
|
|
}, [collapsed]);
|
2026-05-09 22:00:53 +09:00
|
|
|
|
|
|
|
|
const { data: menus = [] } = useQuery({
|
|
|
|
|
queryKey: ['menu', 'my'],
|
2026-05-14 01:21:27 +09:00
|
|
|
queryFn: menuApi.myMenus,
|
2026-05-09 22:00:53 +09:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleLogout = async () => {
|
|
|
|
|
try { await authApi.logout(); } catch { /* ignore */ }
|
|
|
|
|
localStorage.removeItem('accessToken');
|
|
|
|
|
localStorage.removeItem('refreshToken');
|
|
|
|
|
auth.clear();
|
|
|
|
|
navigate('/login');
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 01:21:27 +09:00
|
|
|
const displayName = auth.userName ?? auth.loginId ?? '관리자';
|
|
|
|
|
const roleLabel = auth.roles?.[0] ?? 'USER';
|
|
|
|
|
|
|
|
|
|
const userMenuItems = [
|
|
|
|
|
{
|
|
|
|
|
key: 'profile',
|
|
|
|
|
label: '내 프로필',
|
|
|
|
|
icon: <IconUser size={14} />,
|
|
|
|
|
onClick: () => navigate('/system/users'),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'config',
|
|
|
|
|
label: '시스템 설정',
|
|
|
|
|
icon: <IconSettings size={14} />,
|
|
|
|
|
onClick: () => navigate('/system/config'),
|
|
|
|
|
},
|
|
|
|
|
{ type: 'divider' as const },
|
|
|
|
|
{
|
|
|
|
|
key: 'logout',
|
|
|
|
|
label: '로그아웃',
|
|
|
|
|
icon: <IconLogout size={14} />,
|
|
|
|
|
danger: true,
|
|
|
|
|
onClick: handleLogout,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-09 22:00:53 +09:00
|
|
|
return (
|
2026-05-10 22:20:33 +09:00
|
|
|
<ProLayout
|
|
|
|
|
title="GA 정산시스템"
|
2026-05-14 01:21:27 +09:00
|
|
|
logo={
|
|
|
|
|
<div style={{
|
|
|
|
|
width: 32, height: 32, borderRadius: 8,
|
|
|
|
|
background: COLOR_PRIMARY,
|
|
|
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
color: '#fff', fontWeight: 700, fontSize: 14, letterSpacing: -0.5,
|
|
|
|
|
}}>
|
|
|
|
|
GA
|
|
|
|
|
</div>
|
|
|
|
|
}
|
2026-05-10 22:20:33 +09:00
|
|
|
layout="mix"
|
|
|
|
|
navTheme="light"
|
|
|
|
|
contentWidth="Fluid"
|
|
|
|
|
fixedHeader
|
|
|
|
|
fixSiderbar
|
2026-05-14 01:21:27 +09:00
|
|
|
siderWidth={LAYOUT.siderWidth}
|
|
|
|
|
collapsed={collapsed}
|
|
|
|
|
onCollapse={setCollapsed}
|
2026-05-10 22:20:33 +09:00
|
|
|
menu={{ locale: false }}
|
|
|
|
|
menuDataRender={() => toProMenu(menus)}
|
|
|
|
|
location={{ pathname: location.pathname }}
|
|
|
|
|
menuItemRender={(item, dom) => (
|
2026-05-14 01:21:27 +09:00
|
|
|
<div
|
|
|
|
|
onClick={() =>
|
|
|
|
|
item.path && !item.path.startsWith('/_grp/') && navigate(item.path)
|
|
|
|
|
}
|
|
|
|
|
>
|
2026-05-10 22:20:33 +09:00
|
|
|
{dom}
|
2026-05-10 22:09:17 +09:00
|
|
|
</div>
|
2026-05-10 22:20:33 +09:00
|
|
|
)}
|
|
|
|
|
breadcrumbRender={(routers = []) => routers}
|
|
|
|
|
onMenuHeaderClick={() => navigate('/')}
|
|
|
|
|
token={{
|
|
|
|
|
sider: {
|
2026-05-14 01:21:27 +09:00
|
|
|
colorMenuBackground: '#ffffff',
|
|
|
|
|
colorTextMenuSelected: COLOR_PRIMARY,
|
|
|
|
|
colorBgMenuItemSelected: COLOR_PRIMARY_LIGHT,
|
|
|
|
|
colorTextMenuItemHover: GRAY[800],
|
|
|
|
|
colorTextMenu: GRAY[600],
|
|
|
|
|
colorTextMenuSecondary: GRAY[500],
|
|
|
|
|
colorTextSubMenuSelected: COLOR_PRIMARY,
|
2026-05-10 22:20:33 +09:00
|
|
|
},
|
|
|
|
|
header: {
|
2026-05-14 01:21:27 +09:00
|
|
|
colorBgHeader: '#ffffff',
|
|
|
|
|
colorHeaderTitle: GRAY[800],
|
|
|
|
|
heightLayoutHeader: LAYOUT.headerHeight,
|
|
|
|
|
colorBgRightActionsItemHover: GRAY[100],
|
2026-05-10 22:20:33 +09:00
|
|
|
},
|
2026-05-14 01:21:27 +09:00
|
|
|
bgLayout: GRAY[50],
|
|
|
|
|
colorTextAppListIconHover: COLOR_PRIMARY,
|
2026-05-10 22:20:33 +09:00
|
|
|
}}
|
|
|
|
|
actionsRender={() => [
|
2026-05-14 01:21:27 +09:00
|
|
|
// 정산월 선택
|
|
|
|
|
<Space key="month" size={6} align="center" style={{ marginRight: 4 }}>
|
|
|
|
|
<Text style={{ fontSize: 12, color: GRAY[500], whiteSpace: 'nowrap' }}>정산월</Text>
|
2026-05-10 22:20:33 +09:00
|
|
|
<DatePicker
|
|
|
|
|
picker="month"
|
|
|
|
|
value={settleMonth}
|
|
|
|
|
onChange={(d) => d && setSettleMonth(d)}
|
|
|
|
|
allowClear={false}
|
|
|
|
|
format="YYYY-MM"
|
|
|
|
|
size="small"
|
2026-05-14 01:21:27 +09:00
|
|
|
style={{ width: 96 }}
|
2026-05-10 22:20:33 +09:00
|
|
|
/>
|
|
|
|
|
</Space>,
|
2026-05-14 01:21:27 +09:00
|
|
|
|
|
|
|
|
// 알림 벨 (NotificationBell — unread-count API 연동)
|
|
|
|
|
<NotificationBell key="bell" />,
|
2026-05-10 22:20:33 +09:00
|
|
|
]}
|
|
|
|
|
avatarProps={{
|
2026-05-14 01:21:27 +09:00
|
|
|
size: 'small',
|
|
|
|
|
style: {
|
|
|
|
|
background: COLOR_PRIMARY,
|
|
|
|
|
color: '#fff',
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
},
|
|
|
|
|
src: undefined,
|
|
|
|
|
children: displayName.charAt(0),
|
2026-05-10 22:20:33 +09:00
|
|
|
render: (_props, dom) => (
|
|
|
|
|
<Dropdown
|
2026-05-14 01:21:27 +09:00
|
|
|
menu={{ items: userMenuItems }}
|
|
|
|
|
placement="bottomRight"
|
|
|
|
|
trigger={['click']}
|
2026-05-10 21:34:17 +09:00
|
|
|
>
|
2026-05-14 01:21:27 +09:00
|
|
|
<Space
|
|
|
|
|
style={{
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
borderRadius: 6,
|
|
|
|
|
transition: 'background 0.2s',
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
(e.currentTarget as HTMLElement).style.background = GRAY[100];
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
(e.currentTarget as HTMLElement).style.background = 'transparent';
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{dom}
|
|
|
|
|
<div style={{ lineHeight: 1.2 }}>
|
|
|
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: GRAY[900] }}>{displayName}</div>
|
|
|
|
|
<div style={{ fontSize: 11, color: GRAY[500] }}>{roleLabel}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<IconChevronDown size={12} style={{ color: GRAY[400] }} />
|
|
|
|
|
</Space>
|
2026-05-10 22:20:33 +09:00
|
|
|
</Dropdown>
|
|
|
|
|
),
|
|
|
|
|
}}
|
|
|
|
|
footerRender={() => (
|
2026-05-14 01:21:27 +09:00
|
|
|
<div style={{
|
|
|
|
|
textAlign: 'center', padding: '8px 0',
|
|
|
|
|
fontSize: 11, color: GRAY[400],
|
|
|
|
|
borderTop: `1px solid ${GRAY[200]}`,
|
|
|
|
|
}}>
|
|
|
|
|
GA Commission System v1.0.0-alpha
|
2026-05-10 22:20:33 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-05-14 01:21:27 +09:00
|
|
|
<ErrorBoundary>
|
|
|
|
|
<Outlet />
|
|
|
|
|
</ErrorBoundary>
|
2026-05-10 22:20:33 +09:00
|
|
|
</ProLayout>
|
2026-05-09 22:00:53 +09:00
|
|
|
);
|
|
|
|
|
}
|