동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
+137 -40
View File
@@ -1,38 +1,58 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ProLayout, type MenuDataItem } from '@ant-design/pro-components';
import { Badge, Button, DatePicker, Dropdown, Space } from 'antd';
import { Avatar, Button, DatePicker, Dropdown, Space, Typography } from 'antd';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { IconBell, IconLogout, IconUser } from '@tabler/icons-react';
import { IconChevronDown, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
import dayjs from 'dayjs';
import { menuApi, type MenuResp } from '@/api/menu';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/auth';
import { getMenuIcon } from '@/components/common/MenuIcon';
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';
/** API 메뉴 트리 → ProLayout MenuDataItem */
function toProMenu(menus: MenuResp[]): MenuDataItem[] {
return menus.map((m) => {
const item: MenuDataItem = {
name: m.menuName,
path: m.menuPath ?? `/_grp/${m.menuCode}`,
key: m.menuCode,
icon: getMenuIcon(m.menuCode, m.menuPath),
name: m.menuName,
path: m.menuPath ?? `/_grp/${m.menuCode}`,
key: m.menuCode,
icon: getMenuIcon(m.menuCode, m.menuPath),
};
if (m.children?.length) item.children = toProMenu(m.children);
return item;
});
}
/** 로컬스토리지에서 collapse 상태 읽기 */
function readCollapsed(): boolean {
try { return localStorage.getItem(COLLAPSE_KEY) === 'true'; } catch { return false; }
}
export default function MainLayout() {
const navigate = useNavigate();
const location = useLocation();
const auth = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const auth = useAuthStore();
const [settleMonth, setSettleMonth] = useState(dayjs());
const [collapsed, setCollapsed] = useState(readCollapsed);
// collapse 상태 localStorage 저장
useEffect(() => {
try { localStorage.setItem(COLLAPSE_KEY, String(collapsed)); } catch { /* ignore */ }
}, [collapsed]);
const { data: menus = [] } = useQuery({
queryKey: ['menu', 'my'],
queryFn: menuApi.myMenus,
queryFn: menuApi.myMenus,
});
const handleLogout = async () => {
@@ -43,21 +63,62 @@ export default function MainLayout() {
navigate('/login');
};
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,
},
];
return (
<ProLayout
title="GA 정산시스템"
logo="/vite.svg"
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>
}
layout="mix"
navTheme="light"
contentWidth="Fluid"
fixedHeader
fixSiderbar
siderWidth={220}
siderWidth={LAYOUT.siderWidth}
collapsed={collapsed}
onCollapse={setCollapsed}
menu={{ locale: false }}
menuDataRender={() => toProMenu(menus)}
location={{ pathname: location.pathname }}
menuItemRender={(item, dom) => (
<div onClick={() => item.path && !item.path.startsWith('/_grp/') && navigate(item.path)}>
<div
onClick={() =>
item.path && !item.path.startsWith('/_grp/') && navigate(item.path)
}
>
{dom}
</div>
)}
@@ -65,21 +126,27 @@ export default function MainLayout() {
onMenuHeaderClick={() => navigate('/')}
token={{
sider: {
colorMenuBackground: '#ffffff',
colorTextMenuSelected: '#185FA5',
colorBgMenuItemSelected: '#E6F1FB',
colorTextMenuItemHover: '#1677FF',
colorMenuBackground: '#ffffff',
colorTextMenuSelected: COLOR_PRIMARY,
colorBgMenuItemSelected: COLOR_PRIMARY_LIGHT,
colorTextMenuItemHover: GRAY[800],
colorTextMenu: GRAY[600],
colorTextMenuSecondary: GRAY[500],
colorTextSubMenuSelected: COLOR_PRIMARY,
},
header: {
colorBgHeader: '#ffffff',
colorHeaderTitle: '#185FA5',
heightLayoutHeader: 56,
colorBgHeader: '#ffffff',
colorHeaderTitle: GRAY[800],
heightLayoutHeader: LAYOUT.headerHeight,
colorBgRightActionsItemHover: GRAY[100],
},
bgLayout: '#f5f7fa',
bgLayout: GRAY[50],
colorTextAppListIconHover: COLOR_PRIMARY,
}}
actionsRender={() => [
<Space key="month" size={4} align="center">
<span style={{ fontSize: 12, color: '#666' }}></span>
// 정산월 선택
<Space key="month" size={6} align="center" style={{ marginRight: 4 }}>
<Text style={{ fontSize: 12, color: GRAY[500], whiteSpace: 'nowrap' }}></Text>
<DatePicker
picker="month"
value={settleMonth}
@@ -87,36 +154,66 @@ export default function MainLayout() {
allowClear={false}
format="YYYY-MM"
size="small"
style={{ width: 100 }}
style={{ width: 96 }}
/>
</Space>,
<Badge key="bell" count={3} size="small" offset={[-2, 4]}>
<Button type="text" icon={<IconBell size={18} />} />
</Badge>,
// 알림 벨 (NotificationBell — unread-count API 연동)
<NotificationBell key="bell" />,
]}
avatarProps={{
size: 'small',
title: auth.userName ?? auth.loginId ?? '관리자',
icon: <IconUser size={14} />,
size: 'small',
style: {
background: COLOR_PRIMARY,
color: '#fff',
fontWeight: 600,
fontSize: 12,
},
src: undefined,
children: displayName.charAt(0),
render: (_props, dom) => (
<Dropdown
menu={{
items: [
{ key: 'logout', label: '로그아웃', icon: <IconLogout size={14} />, onClick: handleLogout },
],
}}
menu={{ items: userMenuItems }}
placement="bottomRight"
trigger={['click']}
>
{dom}
<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>
</Dropdown>
),
}}
footerRender={() => (
<div style={{ textAlign: 'center', padding: '8px 0', fontSize: 11, color: '#bbb' }}>
© GA Commission System v1.0.0-alpha · trading_ai
<div style={{
textAlign: 'center', padding: '8px 0',
fontSize: 11, color: GRAY[400],
borderTop: `1px solid ${GRAY[200]}`,
}}>
GA Commission System v1.0.0-alpha
</div>
)}
>
<Outlet />
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</ProLayout>
);
}