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:
|
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>
|
||||||
|
{extra && <div>{extra}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 카드 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: '#ffffff',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 20,
|
||||||
|
boxShadow: 'var(--ga-shadow-card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</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) => (
|
||||||
|
|||||||
@@ -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,82 +45,180 @@ export default function MainLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
<Header
|
<Sider
|
||||||
|
width={220}
|
||||||
|
collapsedWidth={64}
|
||||||
|
collapsed={collapsed}
|
||||||
|
theme="light"
|
||||||
style={{
|
style={{
|
||||||
height: 48,
|
borderRight: '1px solid #f0f0f0',
|
||||||
lineHeight: '48px',
|
|
||||||
background: '#ffffff',
|
background: '#ffffff',
|
||||||
borderBottom: '1px solid #f0f0f0',
|
boxShadow: '2px 0 8px rgba(0,0,0,0.04)',
|
||||||
padding: '0 16px',
|
overflow: 'auto',
|
||||||
display: 'flex',
|
height: '100vh',
|
||||||
alignItems: 'center',
|
position: 'sticky',
|
||||||
justifyContent: 'space-between',
|
top: 0,
|
||||||
boxShadow: 'var(--ga-shadow-card)',
|
left: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space size={12}>
|
{/* 로고 영역 */}
|
||||||
<Button
|
<div
|
||||||
type="text"
|
style={{
|
||||||
icon={<IconMenu2 size={18} />}
|
height: 56,
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
display: 'flex',
|
||||||
/>
|
alignItems: 'center',
|
||||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#185FA5' }}>
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
GA 정산시스템
|
padding: collapsed ? 0 : '0 16px',
|
||||||
</span>
|
background: 'linear-gradient(135deg, #1677FF 0%, #0C447C 100%)',
|
||||||
</Space>
|
color: '#fff',
|
||||||
<Space size={12}>
|
gap: 10,
|
||||||
<DatePicker
|
fontWeight: 700,
|
||||||
picker="month"
|
letterSpacing: 0.3,
|
||||||
value={settleMonth}
|
}}
|
||||||
onChange={(d) => d && setSettleMonth(d)}
|
>
|
||||||
allowClear={false}
|
<div
|
||||||
format="YYYY-MM"
|
style={{
|
||||||
size="small"
|
width: 32, height: 32, borderRadius: 8,
|
||||||
/>
|
background: 'rgba(255,255,255,0.18)',
|
||||||
<Badge count={0} size="small">
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
<Button type="text" icon={<IconBell size={18} />} />
|
fontSize: 16, fontWeight: 800,
|
||||||
</Badge>
|
}}
|
||||||
<Dropdown
|
>GA</div>
|
||||||
menu={{
|
{!collapsed && <span style={{ fontSize: 14 }}>정산 시스템</span>}
|
||||||
items: [
|
</div>
|
||||||
{
|
|
||||||
key: 'logout',
|
<Menu
|
||||||
label: '로그아웃',
|
mode="inline"
|
||||||
icon: <IconLogout size={14} />,
|
items={toMenuItems(menus)}
|
||||||
onClick: handleLogout,
|
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' }}>
|
v1.0.0-alpha · trading_ai
|
||||||
<Avatar size="small" icon={<IconUser size={14} />} />
|
</div>
|
||||||
<span style={{ fontSize: 13 }}>{auth.userName ?? auth.loginId}</span>
|
)}
|
||||||
</Space>
|
</Sider>
|
||||||
</Dropdown>
|
|
||||||
</Space>
|
|
||||||
</Header>
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Sider
|
<Header
|
||||||
width={220}
|
style={{
|
||||||
collapsedWidth={64}
|
height: 48,
|
||||||
collapsed={collapsed}
|
lineHeight: '48px',
|
||||||
theme="light"
|
background: '#ffffff',
|
||||||
style={{ borderRight: '1px solid #f0f0f0' }}
|
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
|
<Space size={8}>
|
||||||
mode="inline"
|
<Button
|
||||||
items={toMenuItems(menus)}
|
type="text"
|
||||||
selectedKeys={[location.pathname]}
|
icon={<IconMenu2 size={18} />}
|
||||||
onClick={({ key }) => navigate(String(key))}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
style={{ borderRight: 0, height: '100%', paddingTop: 8 }}
|
/>
|
||||||
/>
|
<span
|
||||||
</Sider>
|
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' }}>
|
<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] ?? '';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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="건" /></Card>
|
style={{ boxShadow: 'var(--ga-shadow-card)' }}
|
||||||
</Col>
|
styles={{ body: { padding: 16 } }}
|
||||||
<Col span={6}>
|
>
|
||||||
<Card><Statistic title="대사 차이" value={0} suffix="건" valueStyle={{ color: '#cf1322' }} /></Card>
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user