diff --git a/ga-api/src/main/resources/application-trading_ai.yml b/ga-api/src/main/resources/application-trading_ai.yml index 7ce0cca..91e7bf5 100644 --- a/ga-api/src/main/resources/application-trading_ai.yml +++ b/ga-api/src/main/resources/application-trading_ai.yml @@ -25,3 +25,7 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} + + # 로컬 검증 시 Redis 미설치면 REDIS_DISABLED=true 로 띄움 → 인메모리 캐시 폴백 + autoconfigure: + exclude: ${REDIS_AUTOCONFIG_EXCLUDE:} diff --git a/ga-common/src/main/java/com/ga/common/config/RedisConfig.java b/ga-common/src/main/java/com/ga/common/config/RedisConfig.java index 54f9e1b..ddb4b06 100644 --- a/ga-common/src/main/java/com/ga/common/config/RedisConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/RedisConfig.java @@ -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 diff --git a/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java b/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java new file mode 100644 index 0000000..7d78120 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java @@ -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"); + } +} diff --git a/ga-common/src/main/resources/db/migration/V18__메뉴_트리구조.sql b/ga-common/src/main/resources/db/migration/V18__메뉴_트리구조.sql new file mode 100644 index 0000000..fc427f0 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V18__메뉴_트리구조.sql @@ -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; diff --git a/ga-frontend/src/components/common/CodeBadge.tsx b/ga-frontend/src/components/common/CodeBadge.tsx index 23aecfe..97943ed 100644 --- a/ga-frontend/src/components/common/CodeBadge.tsx +++ b/ga-frontend/src/components/common/CodeBadge.tsx @@ -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 = { +type Tone = 'success' | 'info' | 'warning' | 'danger' | 'default'; + +const TONE_MAP: Record = { // 초록 — 긍정적 완료 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 = { + 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 {label}; + const tone = TONE_MAP[value] ?? 'default'; + const s = STYLE_MAP[tone]; + return ( + + {label} + + ); } diff --git a/ga-frontend/src/components/common/MenuIcon.tsx b/ga-frontend/src/components/common/MenuIcon.tsx new file mode 100644 index 0000000..b957a7a --- /dev/null +++ b/ga-frontend/src/components/common/MenuIcon.tsx @@ -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 = { + // 최상위 카테고리 + DASHBOARD: , + ORG: , + ORG_AGENT: , + CONTRACT: , + RULE: , + RECEIVE: , + LEDGER: , + LEDGER_RECRUIT: , + LEDGER_MAINTAIN: , + LEDGER_EXCEPTION: , + SETTLE: , + SETTLE_LIST: , + SETTLE_BATCH: , + PAYMENT: , + REPORT: , + SYSTEM: , + SYSTEM_USER: , + SYSTEM_ROLE: , + SYSTEM_MENU: , + SYSTEM_CODE: , + SYSTEM_LOG: , + SYSTEM_FILE: , + SYSTEM_CONFIG: , + COMPANY: , + PRODUCT: , + RECON: , +}; + +const PATH_MAP: Record = { + '/': , + '/org/agents': , + '/contracts': , + '/ledger/recruit': , + '/ledger/exception': , + '/settle': , + '/settle/batch': , + '/payments': , + '/system/users': , + '/system/roles': , + '/system/codes': , +}; + +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 ; +} diff --git a/ga-frontend/src/components/common/PageContainer.tsx b/ga-frontend/src/components/common/PageContainer.tsx index 29cfa9f..d4dbea6 100644 --- a/ga-frontend/src/components/common/PageContainer.tsx +++ b/ga-frontend/src/components/common/PageContainer.tsx @@ -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 (
-
- {title} - {extra} + {/* 페이지 제목 영역 */} +
+
+ + {title} + + {description && ( +
+ {description} +
+ )} +
+ {extra &&
{extra}
} +
+ + {/* 본문 카드 */} +
+ {children}
- {children}
); } diff --git a/ga-frontend/src/components/common/SearchForm.tsx b/ga-frontend/src/components/common/SearchForm.tsx index 15c2cf4..30ac9ff 100644 --- a/ga-frontend/src/components/common/SearchForm.tsx +++ b/ga-frontend/src/components/common/SearchForm.tsx @@ -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', + }} > {conditions.map((c) => ( diff --git a/ga-frontend/src/layouts/MainLayout.tsx b/ga-frontend/src/layouts/MainLayout.tsx index 34669c0..1574f9f 100644 --- a/ga-frontend/src/layouts/MainLayout.tsx +++ b/ga-frontend/src/layouts/MainLayout.tsx @@ -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 ( -
- -
+ v1.0.0-alpha · trading_ai +
+ )} + + - - navigate(String(key))} - style={{ borderRight: 0, height: '100%', paddingTop: 8 }} - /> - + +