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
@@ -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) => (