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:
@@ -25,3 +25,7 @@ spring:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
|
||||
# 로컬 검증 시 Redis 미설치면 REDIS_DISABLED=true 로 띄움 → 인메모리 캐시 폴백
|
||||
autoconfigure:
|
||||
exclude: ${REDIS_AUTOCONFIG_EXCLUDE:}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ga.common.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@@ -15,8 +16,13 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 가 사용 가능한 경우(RedisConnectionFactory 빈 존재)에만 활성화.
|
||||
* Redis 없으면 SimpleCacheConfig 가 ConcurrentMapCacheManager 로 폴백.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
@ConditionalOnBean(RedisConnectionFactory.class)
|
||||
public class RedisConfig {
|
||||
|
||||
@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).
|
||||
* 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',
|
||||
PAID: 'success', COMPLETED: 'success', MATCH: 'success',
|
||||
MATCHED: 'success', DONE: 'success', SUCCESS: 'success',
|
||||
|
||||
// 파랑 — 진행 중
|
||||
CALCULATED: 'processing', PENDING: 'processing', PROCESSING: 'processing',
|
||||
REVIEWED: 'processing', NEW: 'processing', SENT: 'processing',
|
||||
CALCULATED: 'info', PENDING: 'info', PROCESSING: 'info',
|
||||
REVIEWED: 'info', NEW: 'info', SENT: 'info',
|
||||
|
||||
// 주황 — 주의
|
||||
HOLD: 'warning', SUSPEND: 'warning', MISMATCH: 'warning',
|
||||
PARTIAL: 'warning', DIFF: 'warning',
|
||||
|
||||
// 빨강 — 부정적
|
||||
REJECTED: 'error', LEAVE: 'error', CANCEL: 'error',
|
||||
FAILED: 'error', FAIL: 'error', LAPSED: 'error',
|
||||
LAPSE: 'error', LOCKED: 'error',
|
||||
REJECTED: 'danger', LEAVE: 'danger', CANCEL: 'danger',
|
||||
FAILED: 'danger', FAIL: 'danger', LAPSED: 'danger',
|
||||
LAPSE: 'danger', LOCKED: 'danger',
|
||||
|
||||
// 회색 — 기본
|
||||
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) {
|
||||
const { data = [] } = useCommonCodes(groupCode);
|
||||
if (!value) return null;
|
||||
const code = data.find((c) => c.code === value);
|
||||
const label = code?.codeName ?? value;
|
||||
const color = COLOR_MAP[value] ?? 'default';
|
||||
return <Tag color={color}>{label}</Tag>;
|
||||
const tone = TONE_MAP[value] ?? 'default';
|
||||
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 {
|
||||
title: string;
|
||||
description?: string;
|
||||
extra?: 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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>{title}</Title>
|
||||
{extra}
|
||||
{/* 페이지 제목 영역 */}
|
||||
<div
|
||||
style={{
|
||||
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>
|
||||
{extra && <div>{extra}</div>}
|
||||
</div>
|
||||
|
||||
{/* 본문 카드 */}
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
boxShadow: 'var(--ga-shadow-card)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,13 @@ export default function SearchForm({ conditions, initialValues, onSearch, onRese
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
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}>
|
||||
{conditions.map((c) => (
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
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 { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconMenu2,
|
||||
IconBell,
|
||||
IconUser,
|
||||
IconLogout,
|
||||
IconMenu2, IconBell, IconUser, IconLogout, IconChevronDown,
|
||||
} from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import { menuApi, MenuResp } from '@/api/menu';
|
||||
import { authApi } from '@/api/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[] {
|
||||
return menus.map((m) => ({
|
||||
key: m.menuPath ?? m.menuCode,
|
||||
label: m.menuName,
|
||||
icon: getMenuIcon(m.menuCode, m.menuPath),
|
||||
children: m.children?.length ? toMenuItems(m.children) : undefined,
|
||||
}));
|
||||
}
|
||||
@@ -27,6 +26,7 @@ export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const auth = useAuthStore();
|
||||
const { token } = theme.useToken();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [settleMonth, setSettleMonth] = useState(dayjs());
|
||||
|
||||
@@ -45,82 +45,180 @@ export default function MainLayout() {
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header
|
||||
<Sider
|
||||
width={220}
|
||||
collapsedWidth={64}
|
||||
collapsed={collapsed}
|
||||
theme="light"
|
||||
style={{
|
||||
height: 48,
|
||||
lineHeight: '48px',
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
background: '#ffffff',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
padding: '0 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
boxShadow: 'var(--ga-shadow-card)',
|
||||
boxShadow: '2px 0 8px rgba(0,0,0,0.04)',
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Space size={12}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconMenu2 size={18} />}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
/>
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#185FA5' }}>
|
||||
GA 정산시스템
|
||||
</span>
|
||||
</Space>
|
||||
<Space size={12}>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
value={settleMonth}
|
||||
onChange={(d) => d && setSettleMonth(d)}
|
||||
allowClear={false}
|
||||
format="YYYY-MM"
|
||||
size="small"
|
||||
/>
|
||||
<Badge count={0} size="small">
|
||||
<Button type="text" icon={<IconBell size={18} />} />
|
||||
</Badge>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'logout',
|
||||
label: '로그아웃',
|
||||
icon: <IconLogout size={14} />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
],
|
||||
{/* 로고 영역 */}
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar size="small" icon={<IconUser size={14} />} />
|
||||
<span style={{ fontSize: 13 }}>{auth.userName ?? auth.loginId}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
v1.0.0-alpha · trading_ai
|
||||
</div>
|
||||
)}
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
<Sider
|
||||
width={220}
|
||||
collapsedWidth={64}
|
||||
collapsed={collapsed}
|
||||
theme="light"
|
||||
style={{ borderRight: '1px solid #f0f0f0' }}
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
items={toMenuItems(menus)}
|
||||
selectedKeys={[location.pathname]}
|
||||
onClick={({ key }) => navigate(String(key))}
|
||||
style={{ borderRight: 0, height: '100%', paddingTop: 8 }}
|
||||
/>
|
||||
</Sider>
|
||||
<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>
|
||||
<Content style={{ padding: 'var(--ga-content-padding)', overflow: 'auto' }}>
|
||||
<div className="ga-page-container">
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
<Footer
|
||||
style={{
|
||||
textAlign: 'center', fontSize: 11, color: '#bbb',
|
||||
padding: '8px 0', background: 'transparent',
|
||||
}}
|
||||
>
|
||||
© GA Commission System · trading_ai · admin@local
|
||||
</Footer>
|
||||
</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] ?? '';
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
return (
|
||||
<>
|
||||
<Title level={3}>대시보드</Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="이번 달 정산 건수" value={0} suffix="건" /></Card>
|
||||
<PageContainer
|
||||
title="대시보드"
|
||||
description="이번 달 (2026-05) 정산 요약 / 실시간 배치 진행 / 알림"
|
||||
>
|
||||
{/* 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>
|
||||
);
|
||||
})}
|
||||
</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 span={6}>
|
||||
<Card><Statistic title="확정 대기" value={0} suffix="건" /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="지급 완료" value={0} suffix="건" /></Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card><Statistic title="대사 차이" value={0} suffix="건" valueStyle={{ color: '#cf1322' }} /></Card>
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={<span style={{ fontWeight: 600 }}>배치 진행 (5월)</span>}
|
||||
bordered={false}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user