feat: UI 시각 강화 + 메뉴 트리 구조 + Redis 폴백

UI 시각 강화 (사용자 피드백 — flat 미니멀이 빈약하게 보임):
- MainLayout: 사이드바 로고 영역(그라디언트 #1677FF→#0C447C),
  메뉴 아이콘(Tabler), 푸터, 헤더 breadcrumb + 정산월 라벨,
  사용자 아바타 그라디언트
- PageContainer: 흰 카드 + 좌측 4px 컬러 바 + 그림자, 본문 카드 분리
- Dashboard: KPI 카드 4종(아이콘+컬러+델타), 월별 추이 BarChart(recharts),
  배치 8 Step 진행 상태(pulse), 알림 3종 (대사/파싱/승인)
- CodeBadge: antd preset → 직접 색상 (#0F6E56/#185FA5/#BA7517/#E24B4A
  + 옅은 배경 + 진한 보더, 진한 톤)
- SearchForm: 그라디언트 배경 + 보더 + 패딩 조정
- MenuIcon 신규: 메뉴 코드/경로 → Tabler 아이콘 매핑

메뉴 트리 구조:
- V18 마이그레이션: 25개 평면 PAGE → 9 roots (1 PAGE + 8 DIRECTORY)
  · 조직/인사, 상품/계약, 수수료규정, 데이터수신,
    원장관리, 정산/지급, 리포트, 시스템관리
- 자식 메뉴 sort_order 1~6 정리, DASHBOARD sort_order 10
- 모든 역할에 GRP_* READ 권한 자동 부여

Redis 폴백 (로컬 검증용):
- RedisConfig @ConditionalOnBean(RedisConnectionFactory.class)
- SimpleCacheConfig 신규: ConcurrentMapCacheManager 폴백
  (RedisAutoConfiguration 제외 시 활성)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-10 22:09:17 +09:00
parent 24fc187e2e
commit e70589fda8
10 changed files with 596 additions and 100 deletions
@@ -25,3 +25,7 @@ spring:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
# 로컬 검증 시 Redis 미설치면 REDIS_DISABLED=true 로 띄움 → 인메모리 캐시 폴백
autoconfigure:
exclude: ${REDIS_AUTOCONFIG_EXCLUDE:}
@@ -1,6 +1,7 @@
package com.ga.common.config; package com.ga.common.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -15,8 +16,13 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration; import java.time.Duration;
/**
* Redis 가 사용 가능한 경우(RedisConnectionFactory 빈 존재)에만 활성화.
* Redis 없으면 SimpleCacheConfig 가 ConcurrentMapCacheManager 로 폴백.
*/
@Configuration @Configuration
@EnableCaching @EnableCaching
@ConditionalOnBean(RedisConnectionFactory.class)
public class RedisConfig { public class RedisConfig {
@Bean @Bean
@@ -0,0 +1,24 @@
package com.ga.common.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
/**
* Redis 미연결 환경(개발/검증)용 인메모리 캐시 폴백.
* RedisConnectionFactory 빈이 없을 때만 활성화 (RedisAutoConfiguration 제외 시).
*/
@Configuration
@EnableCaching
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public class SimpleCacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("commonCode", "userPerm");
}
}
@@ -0,0 +1,65 @@
-- V18: 메뉴 트리 구조 (대메뉴 8개 신설 + 25개 PAGE 를 자식으로 매핑)
-- 1) 대메뉴 (DIRECTORY 타입) 추가 -- 기존 menu_code 충돌 회피
INSERT INTO menu (menu_code, menu_name, menu_type, menu_icon, menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
VALUES
('GRP_ORG', '조직 / 인사', 'DIRECTORY', 'users-group', 1, 20, 'Y', 'Y', NULL, '조직 트리 / 설계사 관리'),
('GRP_PRD', '상품 / 계약', 'DIRECTORY', 'briefcase', 1, 30, 'Y', 'Y', NULL, '보험사 / 상품 / 계약 관리'),
('GRP_RULE', '수수료 규정', 'DIRECTORY', 'receipt', 1, 40, 'Y', 'Y', NULL, '수수료율 / 지급율 / 오버라이드 / 환수'),
('GRP_RECEIVE', '데이터 수신', 'DIRECTORY', 'database', 1, 50, 'Y', 'Y', NULL, '보험사 데이터 수신 / 매핑'),
('GRP_LEDGER', '원장 관리', 'DIRECTORY', 'file-invoice', 1, 60, 'Y', 'Y', NULL, '모집 / 유지 / 예외 원장'),
('GRP_SETTLE', '정산 / 지급', 'DIRECTORY', 'calculator', 1, 70, 'Y', 'Y', NULL, '정산 결과 / 배치 / 지급'),
('GRP_REPORT', '리포트', 'DIRECTORY', 'chart-bar', 1, 80, 'Y', 'Y', NULL, '실적 / 조직 / 환수 위험'),
('GRP_SYSTEM', '시스템 관리', 'DIRECTORY', 'settings', 1, 99, 'Y', 'Y', NULL, '사용자 / 권한 / 코드 / 로그')
ON CONFLICT (menu_code) DO NOTHING;
-- 2) 자식 PAGE 메뉴들의 parent_menu_id + menu_level 갱신
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_ORG'), menu_level = 2 WHERE menu_code IN ('ORG_TREE', 'ORG_AGENT');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_PRD'), menu_level = 2 WHERE menu_code IN ('COMPANY', 'PRODUCT', 'CONTRACT_LIST');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_RULE'), menu_level = 2 WHERE menu_code IN ('RULE_COMMISSION', 'RULE_PAYOUT', 'RULE_OVERRIDE', 'RULE_CHARGEBACK', 'RULE_EXCEPTION');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_RECEIVE'), menu_level = 2 WHERE menu_code IN ('RECEIVE_MAPPING', 'RECEIVE_DATA');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_LEDGER'), menu_level = 2 WHERE menu_code IN ('LEDGER_RECRUIT', 'LEDGER_MAINTAIN', 'LEDGER_EXCEPTION');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_SETTLE'), menu_level = 2 WHERE menu_code IN ('SETTLE_LIST', 'BATCH_RUN', 'PAYMENT');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'), menu_level = 2 WHERE menu_code IN ('SYSTEM_USER', 'SYSTEM_ROLE', 'SYSTEM_MENU', 'SYSTEM_CODE', 'SYSTEM_CONFIG', 'SYSTEM_LOG');
-- 3) 대시보드는 root, sort_order 10 으로 최상단
UPDATE menu SET sort_order = 10 WHERE menu_code = 'DASHBOARD';
-- 4) 자식 PAGE 의 그룹 내 sort_order 정렬
UPDATE menu SET sort_order = 1 WHERE menu_code = 'ORG_TREE';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'ORG_AGENT';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'COMPANY';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'PRODUCT';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'CONTRACT_LIST';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'RULE_COMMISSION';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'RULE_PAYOUT';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'RULE_OVERRIDE';
UPDATE menu SET sort_order = 4 WHERE menu_code = 'RULE_CHARGEBACK';
UPDATE menu SET sort_order = 5 WHERE menu_code = 'RULE_EXCEPTION';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'RECEIVE_DATA';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'RECEIVE_MAPPING';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'LEDGER_RECRUIT';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'LEDGER_MAINTAIN';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'LEDGER_EXCEPTION';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'SETTLE_LIST';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'BATCH_RUN';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'PAYMENT';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'SYSTEM_USER';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'SYSTEM_ROLE';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'SYSTEM_MENU';
UPDATE menu SET sort_order = 4 WHERE menu_code = 'SYSTEM_CODE';
UPDATE menu SET sort_order = 5 WHERE menu_code = 'SYSTEM_CONFIG';
UPDATE menu SET sort_order = 6 WHERE menu_code = 'SYSTEM_LOG';
-- 5) 대메뉴(DIRECTORY) 에 READ 권한 정의
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code LIKE 'GRP_%'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 6) 모든 역할에 대메뉴 READ 권한 부여 (자식 메뉴 권한이 있어야 자식이 보이지만, 부모 expand 가능)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, 'READ', 'Y'
FROM role r CROSS JOIN menu m
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN', 'MANAGER', 'AGENT')
AND m.menu_code LIKE 'GRP_%'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -8,36 +8,64 @@ interface Props {
/** /**
* 09_UI_DESIGN.md §5 CodeBadge — 4단계 색상 (success/info/warning/danger/default). * 09_UI_DESIGN.md §5 CodeBadge — 4단계 색상 (success/info/warning/danger/default).
* antd Tag 의 preset 컬러 사용: success(초록) / processing(파랑) / warning(주황) / error(빨강) / default(회색). * antd preset 대신 직접 컬러 + 옅은 배경으로 명확한 시각 구분.
*/ */
const COLOR_MAP: Record<string, string> = { type Tone = 'success' | 'info' | 'warning' | 'danger' | 'default';
const TONE_MAP: Record<string, Tone> = {
// 초록 — 긍정적 완료 // 초록 — 긍정적 완료
CONFIRMED: 'success', APPROVED: 'success', ACTIVE: 'success', CONFIRMED: 'success', APPROVED: 'success', ACTIVE: 'success',
PAID: 'success', COMPLETED: 'success', MATCH: 'success', PAID: 'success', COMPLETED: 'success', MATCH: 'success',
MATCHED: 'success', DONE: 'success', SUCCESS: 'success', MATCHED: 'success', DONE: 'success', SUCCESS: 'success',
// 파랑 — 진행 중 // 파랑 — 진행 중
CALCULATED: 'processing', PENDING: 'processing', PROCESSING: 'processing', CALCULATED: 'info', PENDING: 'info', PROCESSING: 'info',
REVIEWED: 'processing', NEW: 'processing', SENT: 'processing', REVIEWED: 'info', NEW: 'info', SENT: 'info',
// 주황 — 주의 // 주황 — 주의
HOLD: 'warning', SUSPEND: 'warning', MISMATCH: 'warning', HOLD: 'warning', SUSPEND: 'warning', MISMATCH: 'warning',
PARTIAL: 'warning', DIFF: 'warning', PARTIAL: 'warning', DIFF: 'warning',
// 빨강 — 부정적 // 빨강 — 부정적
REJECTED: 'error', LEAVE: 'error', CANCEL: 'error', REJECTED: 'danger', LEAVE: 'danger', CANCEL: 'danger',
FAILED: 'error', FAIL: 'error', LAPSED: 'error', FAILED: 'danger', FAIL: 'danger', LAPSED: 'danger',
LAPSE: 'error', LOCKED: 'error', LAPSE: 'danger', LOCKED: 'danger',
// 회색 — 기본 // 회색 — 기본
DRAFT: 'default', READY: 'default', RETIRED: 'default', DRAFT: 'default', READY: 'default', RETIRED: 'default',
}; };
const STYLE_MAP: Record<Tone, { color: string; bg: string; border: string }> = {
success: { color: '#0F6E56', bg: '#E1F5EE', border: '#B9E5D5' },
info: { color: '#185FA5', bg: '#E6F1FB', border: '#BFDDF6' },
warning: { color: '#BA7517', bg: '#FAEEDA', border: '#F1D9A8' },
danger: { color: '#E24B4A', bg: '#FCEBEB', border: '#F4C8C8' },
default: { color: '#666666', bg: '#F5F5F5', border: '#DDDDDD' },
};
export default function CodeBadge({ groupCode, value }: Props) { export default function CodeBadge({ groupCode, value }: Props) {
const { data = [] } = useCommonCodes(groupCode); const { data = [] } = useCommonCodes(groupCode);
if (!value) return null; if (!value) return null;
const code = data.find((c) => c.code === value); const code = data.find((c) => c.code === value);
const label = code?.codeName ?? value; const label = code?.codeName ?? value;
const color = COLOR_MAP[value] ?? 'default'; const tone = TONE_MAP[value] ?? 'default';
return <Tag color={color}>{label}</Tag>; const s = STYLE_MAP[tone];
return (
<Tag
style={{
color: s.color,
background: s.bg,
border: `1px solid ${s.border}`,
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
{label}
</Tag>
);
} }
@@ -0,0 +1,63 @@
import {
IconLayoutDashboard, IconUsersGroup, IconUser, IconBriefcase,
IconReceipt, IconFileInvoice, IconCalculator, IconCash,
IconReportMoney, IconSettings, IconUsers, IconShieldLock,
IconCategory, IconKey, IconClipboardList, IconFolder,
IconHome, IconChartBar, IconDatabase, IconBuildingBank,
IconFileSpreadsheet, IconTransform, IconAlertTriangle,
} from '@tabler/icons-react';
import type { ReactNode } from 'react';
/**
* 메뉴 코드 / 경로 → Tabler 아이콘 매핑.
* 09_UI_DESIGN.md 의 "사이드 메뉴 좌측 아이콘 16px" 규칙.
*/
const ICON_MAP: Record<string, ReactNode> = {
// 최상위 카테고리
DASHBOARD: <IconLayoutDashboard size={16} />,
ORG: <IconUsersGroup size={16} />,
ORG_AGENT: <IconUser size={16} />,
CONTRACT: <IconBriefcase size={16} />,
RULE: <IconReceipt size={16} />,
RECEIVE: <IconDatabase size={16} />,
LEDGER: <IconFileInvoice size={16} />,
LEDGER_RECRUIT: <IconFileInvoice size={16} />,
LEDGER_MAINTAIN: <IconFileInvoice size={16} />,
LEDGER_EXCEPTION: <IconAlertTriangle size={16} />,
SETTLE: <IconCalculator size={16} />,
SETTLE_LIST: <IconCalculator size={16} />,
SETTLE_BATCH: <IconTransform size={16} />,
PAYMENT: <IconCash size={16} />,
REPORT: <IconChartBar size={16} />,
SYSTEM: <IconSettings size={16} />,
SYSTEM_USER: <IconUsers size={16} />,
SYSTEM_ROLE: <IconShieldLock size={16} />,
SYSTEM_MENU: <IconCategory size={16} />,
SYSTEM_CODE: <IconKey size={16} />,
SYSTEM_LOG: <IconClipboardList size={16} />,
SYSTEM_FILE: <IconFolder size={16} />,
SYSTEM_CONFIG: <IconSettings size={16} />,
COMPANY: <IconBuildingBank size={16} />,
PRODUCT: <IconFileSpreadsheet size={16} />,
RECON: <IconReportMoney size={16} />,
};
const PATH_MAP: Record<string, ReactNode> = {
'/': <IconHome size={16} />,
'/org/agents': <IconUser size={16} />,
'/contracts': <IconBriefcase size={16} />,
'/ledger/recruit': <IconFileInvoice size={16} />,
'/ledger/exception': <IconAlertTriangle size={16} />,
'/settle': <IconCalculator size={16} />,
'/settle/batch': <IconTransform size={16} />,
'/payments': <IconCash size={16} />,
'/system/users': <IconUsers size={16} />,
'/system/roles': <IconShieldLock size={16} />,
'/system/codes': <IconKey size={16} />,
};
export function getMenuIcon(code?: string, path?: string): ReactNode {
if (path && PATH_MAP[path]) return PATH_MAP[path];
if (code && ICON_MAP[code]) return ICON_MAP[code];
return <IconCategory size={16} />;
}
@@ -5,19 +5,58 @@ const { Title } = Typography;
interface Props { interface Props {
title: string; title: string;
description?: string;
extra?: ReactNode; extra?: ReactNode;
children: ReactNode; children: ReactNode;
} }
export default function PageContainer({ title, extra, children }: Props) { /**
* 페이지 표준 컨테이너.
* - 상단: 제목 + 좌측 컬러 바 + 우측 액션
* - 본문: 흰 카드 + 그림자
* 09_UI_DESIGN.md §4 타입 A 의 시각적 보강 버전.
*/
export default function PageContainer({ title, description, extra, children }: Props) {
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', {/* 페이지 제목 영역 */}
marginBottom: 16 }}> <div
<Title level={4} style={{ margin: 0 }}>{title}</Title> style={{
{extra} display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
background: '#ffffff',
borderRadius: 8,
marginBottom: 16,
boxShadow: 'var(--ga-shadow-card)',
borderLeft: '4px solid var(--ga-primary)',
}}
>
<div>
<Title level={4} style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
{title}
</Title>
{description && (
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
{description}
</div> </div>
)}
</div>
{extra && <div>{extra}</div>}
</div>
{/* 본문 카드 */}
<div
style={{
background: '#ffffff',
borderRadius: 8,
padding: 20,
boxShadow: 'var(--ga-shadow-card)',
}}
>
{children} {children}
</div> </div>
</div>
); );
} }
@@ -55,7 +55,13 @@ export default function SearchForm({ conditions, initialValues, onSearch, onRese
layout="vertical" layout="vertical"
initialValues={initialValues} initialValues={initialValues}
onFinish={handleSearch} onFinish={handleSearch}
style={{ background: '#fafafa', padding: '12px 16px', marginBottom: 16, borderRadius: 8 }} style={{
background: 'linear-gradient(180deg, #FAFBFC 0%, #F5F7FA 100%)',
padding: '14px 16px 6px',
marginBottom: 16,
borderRadius: 8,
border: '1px solid #EEF1F5',
}}
> >
<Row gutter={16}> <Row gutter={16}>
{conditions.map((c) => ( {conditions.map((c) => (
+128 -30
View File
@@ -1,24 +1,23 @@
import { useState } from 'react'; import { useState } from 'react';
import { Layout, Menu, Dropdown, Badge, Space, Avatar, Button, DatePicker } from 'antd'; import { Layout, Menu, Dropdown, Badge, Space, Avatar, Button, DatePicker, theme } from 'antd';
import { useNavigate, Outlet, useLocation } from 'react-router-dom'; import { useNavigate, Outlet, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
IconMenu2, IconMenu2, IconBell, IconUser, IconLogout, IconChevronDown,
IconBell,
IconUser,
IconLogout,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { menuApi, MenuResp } from '@/api/menu'; import { menuApi, MenuResp } from '@/api/menu';
import { authApi } from '@/api/auth'; import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { getMenuIcon } from '@/components/common/MenuIcon';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content, Footer } = Layout;
function toMenuItems(menus: MenuResp[]): any[] { function toMenuItems(menus: MenuResp[]): any[] {
return menus.map((m) => ({ return menus.map((m) => ({
key: m.menuPath ?? m.menuCode, key: m.menuPath ?? m.menuCode,
label: m.menuName, label: m.menuName,
icon: getMenuIcon(m.menuCode, m.menuPath),
children: m.children?.length ? toMenuItems(m.children) : undefined, children: m.children?.length ? toMenuItems(m.children) : undefined,
})); }));
} }
@@ -27,6 +26,7 @@ export default function MainLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const auth = useAuthStore(); const auth = useAuthStore();
const { token } = theme.useToken();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [settleMonth, setSettleMonth] = useState(dayjs()); const [settleMonth, setSettleMonth] = useState(dayjs());
@@ -45,6 +45,72 @@ export default function MainLayout() {
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Sider
width={220}
collapsedWidth={64}
collapsed={collapsed}
theme="light"
style={{
borderRight: '1px solid #f0f0f0',
background: '#ffffff',
boxShadow: '2px 0 8px rgba(0,0,0,0.04)',
overflow: 'auto',
height: '100vh',
position: 'sticky',
top: 0,
left: 0,
}}
>
{/* 로고 영역 */}
<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',
}}
>
v1.0.0-alpha · trading_ai
</div>
)}
</Sider>
<Layout>
<Header <Header
style={{ style={{
height: 48, height: 48,
@@ -55,20 +121,29 @@ export default function MainLayout() {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
boxShadow: 'var(--ga-shadow-card)', boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
position: 'sticky',
top: 0,
zIndex: 10,
}} }}
> >
<Space size={12}> <Space size={8}>
<Button <Button
type="text" type="text"
icon={<IconMenu2 size={18} />} icon={<IconMenu2 size={18} />}
onClick={() => setCollapsed((c) => !c)} onClick={() => setCollapsed((c) => !c)}
/> />
<span style={{ fontSize: 15, fontWeight: 600, color: '#185FA5' }}> <span
GA style={{
fontSize: 13, fontWeight: 500, color: '#666',
paddingLeft: 8, borderLeft: '1px solid #f0f0f0',
}}
>
{breadcrumbOf(location.pathname)}
</span> </span>
</Space> </Space>
<Space size={12}> <Space size={12}>
<span style={{ fontSize: 12, color: '#999' }}></span>
<DatePicker <DatePicker
picker="month" picker="month"
value={settleMonth} value={settleMonth}
@@ -76,8 +151,9 @@ export default function MainLayout() {
allowClear={false} allowClear={false}
format="YYYY-MM" format="YYYY-MM"
size="small" size="small"
style={{ width: 110 }}
/> />
<Badge count={0} size="small"> <Badge count={3} size="small" offset={[-2, 4]}>
<Button type="text" icon={<IconBell size={18} />} /> <Button type="text" icon={<IconBell size={18} />} />
</Badge> </Badge>
<Dropdown <Dropdown
@@ -92,35 +168,57 @@ export default function MainLayout() {
], ],
}} }}
> >
<Space style={{ cursor: 'pointer' }}> <Space
<Avatar size="small" icon={<IconUser size={14} />} /> style={{
<span style={{ fontSize: 13 }}>{auth.userName ?? auth.loginId}</span> 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> </Space>
</Dropdown> </Dropdown>
</Space> </Space>
</Header> </Header>
<Layout>
<Sider
width={220}
collapsedWidth={64}
collapsed={collapsed}
theme="light"
style={{ borderRight: '1px solid #f0f0f0' }}
>
<Menu
mode="inline"
items={toMenuItems(menus)}
selectedKeys={[location.pathname]}
onClick={({ key }) => navigate(String(key))}
style={{ borderRight: 0, height: '100%', paddingTop: 8 }}
/>
</Sider>
<Content style={{ padding: 'var(--ga-content-padding)', overflow: 'auto' }}> <Content style={{ padding: 'var(--ga-content-padding)', overflow: 'auto' }}>
<div className="ga-page-container"> <div className="ga-page-container">
<Outlet /> <Outlet />
</div> </div>
</Content> </Content>
<Footer
style={{
textAlign: 'center', fontSize: 11, color: '#bbb',
padding: '8px 0', background: 'transparent',
}}
>
© GA Commission System · trading_ai · admin@local
</Footer>
</Layout> </Layout>
</Layout> </Layout>
); );
} }
/** 경로 → 한글 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] ?? '';
}
+178 -15
View File
@@ -1,25 +1,188 @@
import { Card, Col, Row, Statistic, Typography } from 'antd'; import { Card, Col, Row, Typography } from 'antd';
import {
IconCash, IconCheck, IconClock, IconAlertTriangle,
IconTrendingUp, IconTrendingDown,
} from '@tabler/icons-react';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Legend,
} from 'recharts';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
const { Title } = Typography; const { Text } = Typography;
const KPI_CARDS = [
{ key: 'recruit', label: '모집수수료', value: 124_500_000, delta: +8.2,
icon: <IconCash size={20} />, color: '#1677FF', bg: '#E6F1FB' },
{ key: 'maintain', label: '유지수수료', value: 82_300_000, delta: +3.1,
icon: <IconCheck size={20} />, color: '#0F6E56', bg: '#E1F5EE' },
{ key: 'pending', label: '확정 대기', value: 12, delta: -2.0, suffix: '건',
icon: <IconClock size={20} />, color: '#BA7517', bg: '#FAEEDA' },
{ key: 'alert', label: '대사 차이', value: 3, delta: +50.0, suffix: '건',
icon: <IconAlertTriangle size={20} />, color: '#E24B4A', bg: '#FCEBEB' },
];
const MONTHLY_TREND = [
{ month: '12월', recruit: 95, maintain: 70, chargeback: 5 },
{ month: '1월', recruit: 105, maintain: 72, chargeback: 8 },
{ month: '2월', recruit: 110, maintain: 75, chargeback: 6 },
{ month: '3월', recruit: 118, maintain: 78, chargeback: 7 },
{ month: '4월', recruit: 122, maintain: 80, chargeback: 9 },
{ month: '5월', recruit: 125, maintain: 82, chargeback: 4 },
];
const BATCH_STEPS = [
{ name: '데이터 수신', status: 'done', time: '02:14' },
{ name: '데이터 변환', status: 'done', time: '00:52' },
{ name: '대사 검증', status: 'done', time: '00:21' },
{ name: '모집수수료', status: 'done', time: '01:08' },
{ name: '유지수수료', status: 'done', time: '00:46' },
{ name: '환수/예외', status: 'progress', time: '00:33' },
{ name: '오버라이드', status: 'pending' },
{ name: '집계', status: 'pending' },
];
export default function Dashboard() { export default function Dashboard() {
return ( return (
<> <PageContainer
<Title level={3}></Title> title="대시보드"
<Row gutter={16}> description="이번 달 (2026-05) 정산 요약 / 실시간 배치 진행 / 알림"
<Col span={6}> >
<Card><Statistic title="이번 달 정산 건수" value={0} suffix="건" /></Card> {/* KPI 카드 4개 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
{KPI_CARDS.map((c) => {
const isUp = c.delta >= 0;
return (
<Col span={6} key={c.key}>
<Card
bordered={false}
style={{
boxShadow: 'var(--ga-shadow-card)',
borderTop: `3px solid ${c.color}`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 44, height: 44, borderRadius: 10,
background: c.bg, color: c.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{c.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: '#888' }}>{c.label}</div>
<div style={{ fontSize: 20, fontWeight: 600, color: '#222', marginTop: 2 }}>
{c.suffix === '건' ? c.value.toLocaleString() : (
<MoneyText value={c.value} />
)}
<span style={{ fontSize: 12, color: '#888', marginLeft: 4, fontWeight: 400 }}>
{c.suffix ?? '원'}
</span>
</div>
<div
style={{
fontSize: 11, marginTop: 4,
color: isUp ? '#0F6E56' : '#E24B4A',
display: 'flex', alignItems: 'center', gap: 2,
}}
>
{isUp ? <IconTrendingUp size={12} /> : <IconTrendingDown size={12} />}
{Math.abs(c.delta).toFixed(1)}%
</div>
</div>
</div>
</Card>
</Col> </Col>
<Col span={6}> );
<Card><Statistic title="확정 대기" value={0} suffix="건" /></Card> })}
</Row>
{/* 차트 + 배치 현황 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={16}>
<Card
title={<span style={{ fontWeight: 600 }}> ( 6)</span>}
bordered={false}
style={{ boxShadow: 'var(--ga-shadow-card)' }}
styles={{ body: { padding: 16, height: 280 } }}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={MONTHLY_TREND}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: '#666' }} />
<YAxis tick={{ fontSize: 12, fill: '#666' }}
label={{ value: '백만원', angle: 0, position: 'top', fontSize: 11, fill: '#999' }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="recruit" name="모집" fill="#1677FF" radius={[4, 4, 0, 0]} />
<Bar dataKey="maintain" name="유지" fill="#0F6E56" radius={[4, 4, 0, 0]} />
<Bar dataKey="chargeback" name="환수" fill="#E24B4A" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</Card>
</Col> </Col>
<Col span={6}> <Col span={8}>
<Card><Statistic title="지급 완료" value={0} suffix="건" /></Card> <Card
</Col> title={<span style={{ fontWeight: 600 }}> (5)</span>}
<Col span={6}> bordered={false}
<Card><Statistic title="대사 차이" value={0} suffix="건" valueStyle={{ color: '#cf1322' }} /></Card> style={{ boxShadow: 'var(--ga-shadow-card)' }}
styles={{ body: { padding: 16 } }}
>
{BATCH_STEPS.map((s, i) => {
const dotColor = s.status === 'done' ? '#0F6E56' :
s.status === 'progress' ? '#1677FF' : '#ddd';
return (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 0', borderBottom: i < BATCH_STEPS.length - 1 ? '1px dashed #f5f5f5' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 8, height: 8, borderRadius: '50%', background: dotColor,
boxShadow: s.status === 'progress' ? '0 0 0 4px rgba(22,119,255,0.15)' : 'none',
}} />
<span style={{ fontSize: 13, color: s.status === 'pending' ? '#bbb' : '#333' }}>
Step {i + 1}. {s.name}
</span>
</div>
<span style={{ fontSize: 11, color: '#999' }}>{s.time ?? '-'}</span>
</div>
);
})}
</Card>
</Col> </Col>
</Row> </Row>
</>
{/* 알림 */}
<Card
title={<span style={{ fontWeight: 600 }}> </span>}
bordered={false}
style={{ boxShadow: 'var(--ga-shadow-card)' }}
styles={{ body: { padding: 0 } }}
>
{[
{ tag: '대사불일치', tagColor: '#E24B4A', tagBg: '#FCEBEB',
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이', time: '10분 전' },
{ tag: '파싱에러', tagColor: '#BA7517', tagBg: '#FAEEDA',
text: '한화생명 raw 파일 12행 날짜 형식 오류 (parse_error_log #4521)', time: '1시간 전' },
{ tag: '승인대기', tagColor: '#1677FF', tagBg: '#E6F1FB',
text: '예외금액 원장 승인 대기 5건 (정산월 202605)', time: '3시간 전' },
].map((n, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
borderBottom: i < 2 ? '1px solid #f5f5f5' : 'none',
}}>
<span style={{
fontSize: 11, fontWeight: 500, padding: '2px 8px', borderRadius: 4,
color: n.tagColor, background: n.tagBg,
}}>{n.tag}</span>
<Text style={{ flex: 1, fontSize: 13 }}>{n.text}</Text>
<span style={{ fontSize: 11, color: '#999' }}>{n.time}</span>
</div>
))}
</Card>
</PageContainer>
); );
} }