동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
+414 -120
View File
@@ -1,24 +1,92 @@
import { ProCard, StatisticCard } from '@ant-design/pro-components';
import { Tag } from 'antd';
import { Skeleton, Tag, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Legend,
Area, AreaChart,
} from 'recharts';
import {
IconCash, IconCheck, IconClock, IconAlertTriangle, IconTrendingUp,
IconCash, IconUsers, IconFileCheck, IconAlertTriangle, IconTrendingUp,
IconTrendingDown, IconMinus, IconClock, IconArrowUpRight,
} from '@tabler/icons-react';
import PageContainer from '@/components/common/PageContainer';
import {
COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING, COLOR_ERROR,
COLOR_PRIMARY_LIGHT, GRAY, SHADOW,
} from '@/theme/tokens';
const { Statistic } = StatisticCard;
const { Text } = Typography;
// ────────────────────────────────────────────────
// Mock 데이터
// ────────────────────────────────────────────────
const KPI = [
{ title: '모집수수료', value: 124_500_000, suffix: '원', precision: 0,
color: '#1677FF', bg: '#E6F1FB', icon: <IconCash size={20} />, delta: '+8.2%' },
{ title: '유지수수료', value: 82_300_000, suffix: '원', precision: 0,
color: '#0F6E56', bg: '#E1F5EE', icon: <IconCheck size={20} />, delta: '+3.1%' },
{ title: '확정 대기', value: 12, suffix: '건', precision: 0,
color: '#BA7517', bg: '#FAEEDA', icon: <IconClock size={20} />, delta: '-2건' },
{ title: '대사 차이', value: 3, suffix: '건', precision: 0,
color: '#E24B4A', bg: '#FCEBEB', icon: <IconAlertTriangle size={20} />, delta: '+1건' },
{
title: '모집수수료',
value: 124_500_000,
suffix: '원',
delta: 8.2,
prevText: '전월比',
color: COLOR_PRIMARY,
bg: COLOR_PRIMARY_LIGHT,
icon: <IconCash size={20} />,
format: (v: number) => `${(v / 1_000_000).toFixed(1)}백만`,
},
{
title: '활성 설계사',
value: 342,
suffix: '명',
delta: 12,
prevText: '전월比',
color: COLOR_SUCCESS,
bg: '#E8FAF3',
icon: <IconUsers size={20} />,
format: (v: number) => v.toLocaleString(),
},
{
title: '신계약',
value: 87,
suffix: '건',
delta: -3.1,
prevText: '전월比',
color: COLOR_WARNING,
bg: '#FFF6E6',
icon: <IconFileCheck size={20} />,
format: (v: number) => v.toLocaleString(),
},
{
title: '대사 차이',
value: 3,
suffix: '건',
delta: 1,
prevText: '전월比',
color: COLOR_ERROR,
bg: '#FEF0F1',
icon: <IconAlertTriangle size={20} />,
format: (v: number) => v.toLocaleString(),
},
{
title: '확정 대기',
value: 12,
suffix: '건',
delta: -2,
prevText: '전월比',
color: '#7C3AED',
bg: '#F3EEFF',
icon: <IconClock size={20} />,
format: (v: number) => v.toLocaleString(),
},
{
title: '유지수수료',
value: 82_300_000,
suffix: '원',
delta: 3.1,
prevText: '전월比',
color: '#0891B2',
bg: '#E0F7FA',
icon: <IconArrowUpRight size={20} />,
format: (v: number) => `${(v / 1_000_000).toFixed(1)}백만`,
},
];
const MONTHLY = [
@@ -30,144 +98,370 @@ const MONTHLY = [
{ month: '5월', recruit: 125, maintain: 82, chargeback: 4 },
];
const TREND = [
{ month: '12월', net: 160 },
{ month: '1월', net: 169 },
{ month: '2월', net: 179 },
{ month: '3월', net: 189 },
{ month: '4월', net: 193 },
{ month: '5월', net: 203 },
];
const 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: '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: '진행 중' },
{ name: '오버라이드', status: 'pending' },
{ name: '집계', status: 'pending' },
];
const ALERTS = [
{
tag: '대사불일치', color: 'error' as const,
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이',
time: '10분 전',
},
{
tag: '파싱에러', color: 'warning' as const,
text: '한화생명 raw 파일 12행 날짜 형식 오류 (log #4521)',
time: '1시간 전',
},
{
tag: '승인대기', color: 'processing' as const,
text: '예외금액 원장 승인 대기 5건 (정산월 202605)',
time: '3시간 전',
},
];
// ────────────────────────────────────────────────
// 서브 컴포넌트
// ────────────────────────────────────────────────
function TrendIndicator({ delta }: { delta: number }) {
if (delta > 0) {
return (
<span className="ga-kpi-trend-up" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600 }}>
<IconTrendingUp size={12} />
+{Math.abs(delta).toFixed(1)}%
</span>
);
}
if (delta < 0) {
return (
<span className="ga-kpi-trend-down" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600 }}>
<IconTrendingDown size={12} />
{delta.toFixed(1)}%
</span>
);
}
return (
<span className="ga-kpi-trend-flat" style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12 }}>
<IconMinus size={12} />
</span>
);
}
function KpiCard({ k }: { k: typeof KPI[0] }) {
return (
<div
style={{
background: '#ffffff',
borderRadius: 16,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
padding: '24px 28px',
display: 'flex',
flexDirection: 'column',
gap: 16,
height: '100%',
transition: 'box-shadow 0.2s ease',
}}
>
{/* 상단: 아이콘 + 라벨 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>
{k.title}
</Text>
<div style={{
width: 36, height: 36,
borderRadius: 10,
background: k.bg,
color: k.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
{k.icon}
</div>
</div>
{/* 중간: 큰 숫자 */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{
fontSize: 34,
fontWeight: 700,
color: GRAY[800],
lineHeight: 1,
fontFeatureSettings: "'tnum'",
fontVariantNumeric: 'tabular-nums',
}}>
{k.format(k.value)}
</span>
<Text style={{ fontSize: 14, color: GRAY[500], fontWeight: 400 }}>
{k.suffix}
</Text>
</div>
{/* 하단: 트렌드 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<TrendIndicator delta={k.delta} />
<Text style={{ fontSize: 12, color: GRAY[400] }}>{k.prevText}</Text>
</div>
</div>
);
}
// ────────────────────────────────────────────────
// 메인 컴포넌트
// ────────────────────────────────────────────────
export default function Dashboard() {
return (
<PageContainer
title="대시보드"
description="2026-05 정산 요약 / 실시간 배치 진행 / 알림"
description="2026-05 정산 요약 · 실시간 배치 진행 · 알림"
>
{/* KPI */}
<ProCard
gutter={16}
ghost
style={{ marginBottom: 16 }}
wrap
{/* ── KPI 카드 6개 ── */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 20,
marginBottom: 24,
}}
>
{KPI.map((k) => (
<ProCard
key={k.title}
colSpan={6}
bordered={false}
style={{ borderTop: `3px solid ${k.color}` }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div
style={{
width: 44, height: 44, borderRadius: 10,
background: k.bg, color: k.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{k.icon}
</div>
<div style={{ flex: 1 }}>
<Statistic
title={k.title}
value={k.value}
suffix={k.suffix}
precision={k.precision}
description={
<Tag color={k.delta.startsWith('+') ? 'success' : 'error'} style={{ marginTop: 4 }}>
<IconTrendingUp size={11} style={{ verticalAlign: -2 }} /> {k.delta}
</Tag>
}
/>
</div>
</div>
</ProCard>
<KpiCard key={k.title} k={k} />
))}
</ProCard>
</div>
{/* 차트 + 배치 진행 */}
<ProCard gutter={16} ghost wrap style={{ marginBottom: 16 }}>
{/* ── 차트 2개 + 배치 진행 ── */}
<ProCard gutter={[20, 20]} ghost wrap style={{ marginBottom: 20 }}>
{/* 월별 수수료 바 차트 */}
<ProCard
colSpan={16}
title="월별 추이 (최근 6개월)"
colSpan={{ xs: 24, md: 16 }}
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> </span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}> 6 ()</span>}
headerBordered
bordered={false}
style={{ minHeight: 320 }}
style={{
background: '#ffffff',
borderRadius: 16,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
}}
>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={MONTHLY}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: '#666' }} />
<YAxis tick={{ fontSize: 12, fill: '#666' }} />
<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]} />
<ResponsiveContainer width="100%" height={240}>
<BarChart data={MONTHLY} margin={{ top: 4, right: 8, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{
borderRadius: 12, border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.md, fontSize: 13,
}}
/>
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
<Bar dataKey="recruit" name="모집" fill={COLOR_PRIMARY} radius={[6, 6, 0, 0]} />
<Bar dataKey="maintain" name="유지" fill={COLOR_SUCCESS} radius={[6, 6, 0, 0]} />
<Bar dataKey="chargeback" name="환수" fill={COLOR_ERROR} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ProCard>
{/* 배치 진행 */}
<ProCard
colSpan={8}
title="배치 진행 (5월)"
colSpan={{ xs: 24, md: 8 }}
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> </span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>2026-05</span>}
headerBordered
bordered={false}
style={{
background: '#ffffff',
borderRadius: 16,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
}}
>
{STEPS.map((s, i) => {
const dot = 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 < STEPS.length - 1 ? '1px dashed #f5f5f5' : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 8, height: 8, borderRadius: '50%', background: dot,
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 style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{STEPS.map((s, i) => {
const isDone = s.status === 'done';
const isProgress = s.status === 'progress';
const dotColor = isDone ? COLOR_SUCCESS : isProgress ? COLOR_PRIMARY : GRAY[300];
return (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: i < STEPS.length - 1 ? `1px solid ${GRAY[100]}` : 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 8, height: 8,
borderRadius: '50%',
background: dotColor,
flexShrink: 0,
boxShadow: isProgress
? `0 0 0 3px ${COLOR_PRIMARY}22`
: isDone ? `0 0 0 3px ${COLOR_SUCCESS}22` : 'none',
}} />
<Text style={{
fontSize: 13,
color: s.status === 'pending' ? GRAY[400] : GRAY[700],
fontWeight: isProgress ? 700 : 400,
}}>
{s.name}
</Text>
{isProgress && (
<Tag color="blue" style={{ fontSize: 10, padding: '0 6px', margin: 0 }}></Tag>
)}
</div>
<Text style={{ fontSize: 11, color: GRAY[400] }}>{s.time ?? '-'}</Text>
</div>
<span style={{ fontSize: 11, color: '#999' }}>{s.time ?? '-'}</span>
</div>
);
})}
);
})}
</div>
{/* 진행률 바 */}
<div style={{ marginTop: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text style={{ fontSize: 13, color: GRAY[500] }}> </Text>
<Text style={{ fontSize: 13, fontWeight: 700, color: COLOR_PRIMARY }}>62.5%</Text>
</div>
<div style={{
height: 6, background: GRAY[100], borderRadius: 999, overflow: 'hidden',
}}>
<div style={{
height: '100%', width: '62.5%',
background: COLOR_PRIMARY,
borderRadius: 999,
transition: 'width 0.5s ease',
}} />
</div>
</div>
</ProCard>
</ProCard>
{/* 알림 */}
<ProCard title="최근 알림" headerBordered bordered={false}>
{[
{ tag: '대사불일치', color: 'error',
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이', time: '10분 전' },
{ tag: '파싱에러', color: 'warning',
text: '한화생명 raw 파일 12행 날짜 형식 오류 (parse_error_log #4521)', time: '1시간 전' },
{ tag: '승인대기', color: 'processing',
text: '예외금액 원장 승인 대기 5건 (정산월 202605)', time: '3시간 전' },
].map((n, i, arr) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
borderBottom: i < arr.length - 1 ? '1px solid #f5f5f5' : 'none',
}}>
<Tag color={n.color}>{n.tag}</Tag>
<span style={{ flex: 1, fontSize: 13 }}>{n.text}</span>
<span style={{ fontSize: 11, color: '#999' }}>{n.time}</span>
{/* ── 실지급 추이 + 알림 ── */}
<ProCard gutter={[20, 20]} ghost wrap>
{/* 실지급 라인 차트 */}
<ProCard
colSpan={{ xs: 24, md: 12 }}
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> </span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}> 6 ()</span>}
headerBordered
style={{
background: '#ffffff',
borderRadius: 16,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
}}
>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={TREND} margin={{ top: 8, right: 8, left: -8, bottom: 0 }}>
<defs>
<linearGradient id="netGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={COLOR_PRIMARY} stopOpacity={0.12} />
<stop offset="95%" stopColor={COLOR_PRIMARY} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{
borderRadius: 12, border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.md, fontSize: 13,
}}
/>
<Area
type="monotone"
dataKey="net"
name="실지급"
stroke={COLOR_PRIMARY}
strokeWidth={2}
fill="url(#netGrad)"
dot={{ fill: COLOR_PRIMARY, r: 3 }}
activeDot={{ r: 5 }}
/>
</AreaChart>
</ResponsiveContainer>
</ProCard>
{/* 최근 알림 */}
<ProCard
colSpan={{ xs: 24, md: 12 }}
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> </span>}
headerBordered
style={{
background: '#ffffff',
borderRadius: 16,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
}}
extra={
<Text style={{ fontSize: 13, color: COLOR_PRIMARY, cursor: 'pointer', fontWeight: 500 }}> </Text>
}
>
{ALERTS.map((n, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '14px 0',
borderBottom: i < ALERTS.length - 1 ? `1px solid ${GRAY[100]}` : 'none',
}}
>
<Tag
color={n.color}
style={{ flexShrink: 0, marginTop: 1 }}
>
{n.tag}
</Tag>
<Text style={{ flex: 1, fontSize: 13, color: GRAY[700], lineHeight: 1.5 }}>
{n.text}
</Text>
<Text style={{ fontSize: 12, color: GRAY[400], whiteSpace: 'nowrap' }}>
{n.time}
</Text>
</div>
))}
<div style={{ marginTop: 12, display: 'none' }} className="ga-empty-state">
<Text style={{ fontSize: 13, color: GRAY[400] }}> </Text>
</div>
))}
</ProCard>
</ProCard>
{/* ── 스켈레톤 로딩 데모 (hidden) ── */}
<div style={{ display: 'none' }}>
<div className="ga-skeleton-card">
<Skeleton active paragraph={{ rows: 3 }} />
</div>
</div>
</PageContainer>
);
}
+118 -19
View File
@@ -1,22 +1,27 @@
import { Button, Card, Form, Input, message } from 'antd';
import { Button, Divider, Form, Input, Typography } from 'antd';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { authApi } from '@/api/auth';
import { useAuthStore } from '@/stores/auth';
import { COLOR_PRIMARY, GRAY, SHADOW } from '@/theme/tokens';
const { Title, Text } = Typography;
export default function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const setAuth = useAuthStore((s) => s.setAuth);
const onFinish = async (values: { loginId: string; password: string }) => {
try {
const resp = await authApi.login(values);
localStorage.setItem('accessToken', resp.accessToken);
localStorage.setItem('accessToken', resp.accessToken);
if (resp.refreshToken) localStorage.setItem('refreshToken', resp.refreshToken);
setAuth({
userId: resp.userId, loginId: resp.loginId,
userName: resp.userName, roles: resp.roles,
userId: resp.userId,
loginId: resp.loginId,
userName: resp.userName,
roles: resp.roles,
});
message.success(`${resp.userName}님 환영합니다`);
navigate('/');
} catch {
// 인터셉터에서 메시지 처리
@@ -24,19 +29,113 @@ export default function LoginPage() {
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center',
minHeight: '100vh' }}>
<Card title="GA 수수료 정산 솔루션" style={{ width: 400 }}>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item label="아이디" name="loginId" rules={[{ required: true }]}>
<Input autoFocus />
</Form.Item>
<Form.Item label="비밀번호" name="password" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Button type="primary" htmlType="submit" block></Button>
</Form>
</Card>
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: GRAY[50],
padding: 24,
}}
>
<div
style={{
width: 440,
background: '#ffffff',
borderRadius: 20,
boxShadow: SHADOW.lg,
overflow: 'hidden',
}}
>
{/* 상단 브랜드 헤더 */}
<div
style={{
background: COLOR_PRIMARY,
padding: '36px 40px 32px',
textAlign: 'center',
}}
>
<div
style={{
width: 52, height: 52,
borderRadius: 14,
background: 'rgba(255,255,255,0.18)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
color: '#fff',
fontWeight: 800,
fontSize: 20,
letterSpacing: -0.5,
}}
>
GA
</div>
<Title level={3} style={{ color: '#ffffff', margin: 0, fontWeight: 700, fontSize: 22 }}>
GA
</Title>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 6, display: 'block' }}>
GA Commission Management System
</Text>
</div>
{/* 로그인 폼 */}
<div style={{ padding: '36px 40px 40px' }}>
<Form layout="vertical" onFinish={onFinish} autoComplete="off">
<Form.Item
label={<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[600] }}></Text>}
name="loginId"
rules={[{ required: true, message: '아이디를 입력하세요' }]}
style={{ marginBottom: 16 }}
>
<Input
prefix={<UserOutlined style={{ color: GRAY[400] }} />}
placeholder="아이디 입력"
size="large"
autoFocus
style={{ height: 52, borderRadius: 12, fontSize: 15 }}
/>
</Form.Item>
<Form.Item
label={<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[600] }}></Text>}
name="password"
rules={[{ required: true, message: '비밀번호를 입력하세요' }]}
style={{ marginBottom: 28 }}
>
<Input.Password
prefix={<LockOutlined style={{ color: GRAY[400] }} />}
placeholder="비밀번호 입력"
size="large"
style={{ height: 52, borderRadius: 12, fontSize: 15 }}
/>
</Form.Item>
<Button
type="primary"
htmlType="submit"
block
style={{
fontWeight: 700,
fontSize: 16,
height: 56,
borderRadius: 12,
letterSpacing: 0.3,
}}
>
</Button>
</Form>
<Divider style={{ margin: '28px 0 20px', borderColor: GRAY[100] }} />
<Text style={{ fontSize: 13, color: GRAY[400], display: 'block', textAlign: 'center' }}>
문의: 시스템 · 1234
</Text>
</div>
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import {
agentContractApi,
AgentContractRow,
AgentContractSaveReq,
AgentContractSearchParam,
} from '@/api/agent-contract';
import { GRAY, RADIUS, SHADOW, COLOR_WARNING_BG, COLOR_WARNING_BORDER } from '@/theme/tokens';
const COLUMNS: ColDef<AgentContractRow>[] = [
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
{ field: 'carrierName', headerName: '보험사', width: 140 },
{ field: 'contractType', headerName: '계약유형', width: 120 },
{ field: 'effectiveFrom', headerName: '효력시작', width: 120 },
{ field: 'effectiveTo', headerName: '효력종료', width: 120 },
{ field: 'commissionRate', headerName: '위촉수수료율(%)', width: 140, type: 'numericColumn' },
{
field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="AGENT_CONTRACT_STATUS" value={p.value} />,
},
{ field: 'terminatedAt', headerName: '해촉일', width: 120 },
];
export default function AgentContracts() {
const qc = useQueryClient();
const [params, setParams] = useState<AgentContractSearchParam>({ pageSize: 100 });
const [createOpen, setCreateOpen] = useState(false);
const [terminateId, setTerminateId] = useState<number | null>(null);
const [terminateReason, setTerminateReason] = useState('');
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['agent-contracts', params],
queryFn: () => agentContractApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const handleCreate = async () => {
const values = await form.validateFields() as AgentContractSaveReq;
try {
await agentContractApi.create(values);
message.success('위촉계약 등록 완료');
qc.invalidateQueries({ queryKey: ['agent-contracts'] });
setCreateOpen(false);
form.resetFields();
} catch { /* handled */ }
};
const handleTerminate = async () => {
if (!terminateId) return;
try {
await agentContractApi.terminate(terminateId, terminateReason);
message.success('해촉 처리 완료');
qc.invalidateQueries({ queryKey: ['agent-contracts'] });
setTerminateId(null);
setTerminateReason('');
} catch { /* handled */ }
};
return (
<PageContainer
title="위촉계약 관리"
description="보험사-설계사 위촉계약 목록 및 해촉 처리"
extra={
<PermissionButton menuCode="AGENT_CONTRACTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/agent-contracts 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_CONTRACT_STATUS', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AgentContractSearchParam, pageSize: 100 })}
onReset={() => setParams({ pageSize: 100 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<AgentContractRow>
rows={rows}
columns={[
...COLUMNS,
{
headerName: '액션', width: 180, pinned: 'right',
cellRenderer: (p: { data: AgentContractRow }) => (
<Space>
<PermissionButton
menuCode="AGENT_CONTRACTS" permCode="APPROVE" size="small" danger
disabled={p.data.status === 'TERMINATED'}
onClick={() => { setTerminateId(p.data.contractId); setTerminateReason(''); }}
>
</PermissionButton>
</Space>
),
},
]}
loading={isLoading}
height={600}
rowKey="contractId"
getRowStyle={(params) => {
if (params.data?.status === 'TERMINATED') {
return { background: COLOR_WARNING_BG, borderLeft: `3px solid ${COLOR_WARNING_BORDER}` };
}
return undefined;
}}
/>
</ProCard>
{/* 등록 모달 */}
<Modal title="위촉계약 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
<Form form={form} layout="vertical">
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="carrierId" label="보험사 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="contractType" label="계약유형" rules={[{ required: true }]}>
<Input placeholder="EXCLUSIVE / GENERAL" />
</Form.Item>
<Form.Item name="effectiveFrom" label="효력시작일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="효력종료일">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="commissionRate" label="위촉수수료율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
</Form.Item>
</Form>
</Modal>
{/* 해촉 모달 */}
<Modal
title="해촉 처리"
open={terminateId !== null}
onOk={handleTerminate}
onCancel={() => setTerminateId(null)}
okText="해촉 확정"
okButtonProps={{ danger: true }}
cancelText="취소"
>
<Form layout="vertical">
<Form.Item label="해촉 사유" required>
<Input.TextArea
rows={3}
value={terminateReason}
onChange={(e) => setTerminateReason(e.target.value)}
placeholder="해촉 사유를 입력하세요"
/>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { Alert, Form, Input, Modal, Space, Tag, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef, RowClassParams } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { licenseApi, AgentLicenseRow, AgentLicenseSaveReq, AgentLicenseSearchParam } from '@/api/license';
import { GRAY, RADIUS, SHADOW, COLOR_WARNING, COLOR_WARNING_BG, COLOR_WARNING_BORDER, COLOR_ERROR, COLOR_ERROR_BG } from '@/theme/tokens';
const { Text } = Typography;
const COLUMNS: ColDef<AgentLicenseRow>[] = [
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
{ field: 'licenseTypeName', headerName: '자격 유형', width: 120 },
{ field: 'licenseNo', headerName: '자격증번호', width: 160 },
{ field: 'issuedAt', headerName: '발급일', width: 120 },
{ field: 'expiresAt', headerName: '만료일', width: 120 },
{ field: 'renewedAt', headerName: '갱신일', width: 120 },
{
field: 'daysUntilExpiry', headerName: '만료까지', width: 110,
cellRenderer: (p: { value?: number }) => {
const d = p.value;
if (d == null) return '-';
const color = d <= 0 ? COLOR_ERROR : d <= 30 ? COLOR_WARNING : GRAY[600];
return <span style={{ color, fontWeight: d != null && d <= 30 ? 700 : 400 }}>{d <= 0 ? '만료' : `D-${d}`}</span>;
},
},
{ field: 'status', headerName: '상태', width: 90 },
];
export default function AgentLicenses() {
const qc = useQueryClient();
const [params, setParams] = useState<AgentLicenseSearchParam>({ pageSize: 100 });
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['agent-licenses', params],
queryFn: () => licenseApi.list(params),
retry: false,
});
const { data: expiring = [] } = useQuery({
queryKey: ['agent-licenses', 'expiring'],
queryFn: () => licenseApi.expiring(30),
retry: false,
});
const rows = data?.list ?? [];
const handleCreate = async () => {
const values = await form.validateFields() as AgentLicenseSaveReq;
try {
await licenseApi.create(values);
message.success('자격증 등록 완료');
qc.invalidateQueries({ queryKey: ['agent-licenses'] });
setCreateOpen(false);
form.resetFields();
} catch { /* handled */ }
};
const getRowStyle = (p: RowClassParams<AgentLicenseRow>): Record<string, string> | undefined => {
const d = p.data?.daysUntilExpiry;
if (d != null && d <= 0) return { background: COLOR_ERROR_BG };
if (d != null && d <= 30) return { background: COLOR_WARNING_BG, borderLeft: `3px solid ${COLOR_WARNING_BORDER}` };
return undefined;
};
return (
<PageContainer
title="자격증 관리"
description="설계사 생보/손보/변액 자격증 관리"
extra={
<PermissionButton menuCode="AGENT_LICENSES" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/agent-licenses 를 확인하세요." style={{ marginBottom: 16 }} />
)}
{/* 만료 임박 헤더 카드 */}
{expiring.length > 0 && (
<div style={{
padding: '12px 20px',
background: COLOR_WARNING_BG,
border: `1px solid ${COLOR_WARNING_BORDER}`,
borderRadius: RADIUS.md,
marginBottom: 16,
display: 'flex', alignItems: 'center', gap: 12,
}}>
<Tag color="warning" style={{ fontWeight: 700, fontSize: 13 }}>
30 {expiring.length}
</Tag>
<Text style={{ fontSize: 13, color: GRAY[700] }}>
{expiring.slice(0, 3).map((e) => `${e.agentName} (${e.licenseTypeName})`).join(', ')}
{expiring.length > 3 ? `${expiring.length - 3}` : ''}
</Text>
</div>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'code', name: 'licenseType', label: '자격 유형', groupCode: 'LICENSE_TYPE', span: 6 },
{ type: 'code', name: 'status', label: '상태', groupCode: 'LICENSE_STATUS', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AgentLicenseSearchParam, pageSize: 100 })}
onReset={() => setParams({ pageSize: 100 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<AgentLicenseRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={600}
rowKey="licenseId"
getRowStyle={getRowStyle}
/>
</ProCard>
<Modal title="자격증 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
<Form form={form} layout="vertical">
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="licenseType" label="자격 유형" rules={[{ required: true }]}>
<Input placeholder="LIFE / NONLIFE / VARIABLE" />
</Form.Item>
<Form.Item name="licenseNo" label="자격증 번호" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="issuedAt" label="발급일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="expiresAt" label="만료일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,205 @@
import { useState } from 'react';
import { Alert, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { trainingApi, AgentTrainingRow, AgentTrainingSaveReq, AgentTrainingSearchParam } from '@/api/training';
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_SUCCESS, COLOR_ERROR } from '@/theme/tokens';
const { Text } = Typography;
const COLUMNS: ColDef<AgentTrainingRow>[] = [
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
{ field: 'trainingName', headerName: '교육명', flex: 1 },
{ field: 'trainingType', headerName: '교육유형', width: 120 },
{ field: 'completedHours', headerName: '이수시간(H)', width: 120, type: 'numericColumn' },
{ field: 'trainingDate', headerName: '교육일', width: 120 },
{ field: 'provider', headerName: '교육기관', width: 140 },
{ field: 'year', headerName: '연도', width: 90 },
{ field: 'status', headerName: '상태', width: 90 },
];
const REQUIRED_HOURS = 12; // 의무 12시간/년
export default function AgentTrainings() {
const qc = useQueryClient();
const currentYear = dayjs().year();
const [params, setParams] = useState<AgentTrainingSearchParam>({ year: currentYear, pageSize: 200 });
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
const [selectedAgentName, setSelectedAgentName] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['agent-trainings', params],
queryFn: () => trainingApi.list(params),
retry: false,
});
const { data: annualHours } = useQuery({
queryKey: ['agent-trainings', 'annual', selectedAgentId, currentYear],
queryFn: () => trainingApi.annualHours(selectedAgentId!, currentYear),
enabled: !!selectedAgentId,
retry: false,
});
const rows = data?.list ?? [];
// 설계사별 연간 합계 계산 (클라이언트사이드)
const agentHoursMap: Record<number, number> = {};
rows.forEach((r) => {
agentHoursMap[r.agentId] = (agentHoursMap[r.agentId] ?? 0) + r.completedHours;
});
const chartData = selectedAgentId && annualHours ? [
{ label: '이수', hours: annualHours.completedHours },
{ label: '의무', hours: annualHours.requiredHours },
] : [];
const handleCreate = async () => {
const values = await form.validateFields() as AgentTrainingSaveReq;
try {
await trainingApi.create(values);
message.success('교육 이수 등록 완료');
qc.invalidateQueries({ queryKey: ['agent-trainings'] });
setCreateOpen(false);
form.resetFields();
} catch { /* handled */ }
};
const satisfactionRate = annualHours
? Math.min(100, Math.round((annualHours.completedHours / REQUIRED_HOURS) * 100))
: 0;
return (
<PageContainer
title="보수교육 관리"
description="설계사 의무교육 이수 시간 관리 (의무: 12시간/년)"
extra={
<PermissionButton menuCode="AGENT_TRAININGS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/agent-trainings 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'text', name: 'year', label: '연도', span: 6 },
{ type: 'code', name: 'trainingType', label: '교육유형', groupCode: 'TRAINING_TYPE', span: 6 },
]}
onSearch={(v) => setParams({ ...v as AgentTrainingSearchParam, pageSize: 200 })}
onReset={() => setParams({ year: currentYear, pageSize: 200 })}
/>
<Row gutter={20} style={{ marginBottom: 16 }}>
{/* 설계사 선택 → 연간 차트 */}
<Col span={8}>
<ProCard
title={<Text style={{ fontSize: 14, fontWeight: 700 }}> </Text>}
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, height: 300 }}
>
<Select
showSearch
placeholder="설계사 선택"
style={{ width: '100%', marginBottom: 12 }}
options={Array.from(new Set(rows.map((r) => r.agentId))).map((id) => {
const r = rows.find((x) => x.agentId === id)!;
return { value: id, label: r.agentName };
})}
onChange={(val, opt) => {
setSelectedAgentId(val);
const single = Array.isArray(opt) ? opt[0] : opt;
setSelectedAgentName(single?.label ?? '');
}}
/>
{annualHours ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Progress
percent={satisfactionRate}
strokeColor={satisfactionRate >= 100 ? COLOR_SUCCESS : COLOR_ERROR}
format={() => `${annualHours.completedHours}H / ${REQUIRED_HOURS}H`}
/>
<Text style={{ fontSize: 12, color: GRAY[500] }}>
{annualHours.satisfied ? '의무시간 충족' : `부족: ${REQUIRED_HOURS - annualHours.completedHours}시간`}
</Text>
</Space>
) : (
<Text style={{ fontSize: 13, color: GRAY[400] }}> </Text>
)}
</ProCard>
</Col>
{/* 미충족 설계사 현황 */}
<Col span={16}>
<ProCard
title={<Text style={{ fontSize: 14, fontWeight: 700 }}> ({currentYear})</Text>}
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, height: 300 }}
>
<ResponsiveContainer width="100%" height={220}>
<BarChart
data={Object.entries(agentHoursMap).slice(0, 10).map(([id, hours]) => {
const agent = rows.find((r) => r.agentId === Number(id));
return { name: agent?.agentName ?? id, hours };
})}
margin={{ top: 8, right: 16, left: -8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<Tooltip formatter={(v) => [`${v}시간`, '이수시간']} />
<ReferenceLine y={REQUIRED_HOURS} stroke={COLOR_ERROR} strokeDasharray="4 2" label={{ value: '의무 12H', fontSize: 11, fill: COLOR_ERROR }} />
<Bar dataKey="hours" fill={COLOR_PRIMARY} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ProCard>
</Col>
</Row>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<AgentTrainingRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={500}
rowKey="trainingId"
/>
</ProCard>
<Modal title="교육 이수 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
<Form form={form} layout="vertical">
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="trainingName" label="교육명" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="trainingType" label="교육유형" rules={[{ required: true }]}>
<Input placeholder="MANDATORY / OPTIONAL" />
</Form.Item>
<Form.Item name="completedHours" label="이수시간(H)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} step={0.5} />
</Form.Item>
<Form.Item name="trainingDate" label="교육일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="provider" label="교육기관">
<Input />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,199 @@
import { useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Tabs, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
commissionApi,
CollectionRuleRow, CollectionLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const RULE_COLS: ColDef<CollectionRuleRow>[] = [
{ field: 'carrierName', headerName: '보험사', width: 140 },
{ field: 'productType', headerName: '상품유형', width: 120 },
{ field: 'paymentCycle', headerName: '납입주기', width: 100 },
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
{ field: 'status', headerName: '상태', width: 90 },
];
const LEDGER_COLS: ColDef<CollectionLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'collectionAmount', headerName: '수납금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'ratePercent', headerName: '수수료율(%)', width: 110, type: 'numericColumn' },
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 90 },
];
export default function CollectionCommission() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<CollectionRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['collection', 'rules'],
queryFn: () => commissionApi.collectionRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['collection', 'ledgers', ledgerParams],
queryFn: () => commissionApi.collectionLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: CollectionRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.collectionRuleUpdate(editingRule.ruleId, values);
message.success('수정 완료');
} else {
await commissionApi.collectionRuleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['collection', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.collectionRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['collection', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
collectionAmount: ledgers.reduce((s, r) => s + (r.collectionAmount ?? 0), 0),
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
} as Partial<CollectionLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="수금수수료" description="수금수수료 룰 관리 및 원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/collection-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '수수료 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="COLLECT_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<CollectionRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: CollectionRuleRow }) => (
<Space>
<PermissionButton menuCode="COLLECT_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="COLLECT_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '수금 원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<CollectionLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="paymentCycle" label="납입주기">
<Input placeholder="MONTHLY / ANNUAL" />
</Form.Item>
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,80 @@
import { useState } from 'react';
import { Alert } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { commissionApi, CommissionMasterRow, CommissionSearchParam } from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<CommissionMasterRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
{ field: 'commissionTypeName', headerName: '수수료 종류', width: 140 },
{ field: 'grossAmount', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'netAmount', headerName: '실지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function CommissionMaster() {
const [params, setParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'),
pageSize: 200,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['commission', 'master', params],
queryFn: () => commissionApi.masterList(params),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
commissionTypeName: '',
grossAmount: rows.reduce((s, r) => s + (r.grossAmount ?? 0), 0),
netAmount: rows.reduce((s, r) => s + (r.netAmount ?? 0), 0),
} as Partial<CommissionMasterRow> : undefined;
return (
<PageContainer
title="수수료 통합 마스터"
description="설계사+정산월 기준 9종 수수료 통합 조회"
>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{ type: 'code', name: 'commissionType', label: '수수료 종류', groupCode: 'COMMISSION_TYPE', span: 6 },
]}
onSearch={(v) => setParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
{isError && (
<Alert type="warning" showIcon
message="API 미응답"
description="/api/commissions 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<CommissionMasterRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={600}
rowKey="masterId"
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Alert, Radio, Space, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { commissionApi, OverrideLedgerRow, OverrideLedgerSearchParam } from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const { Text } = Typography;
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const fmtPct = (v: unknown) => `${((v as number) ?? 0).toFixed(2)}%`;
const COLUMNS: ColDef<OverrideLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'fromAgentName', headerName: '원천 설계사(From)', width: 150 },
{ field: 'toAgentName', headerName: '수령 설계사(To)', width: 150 },
{ field: 'orgName', headerName: '조직', width: 130 },
{ field: 'baseCommission', headerName: '기준 수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'overrideRate', headerName: '오버라이드율', width: 130, type: 'numericColumn', valueFormatter: (p) => fmtPct(p.value) },
{ field: 'overrideAmount', headerName: '오버라이드액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 90 },
];
type FilterMode = 'both' | 'from' | 'to';
export default function OverrideLedger() {
const [mode, setMode] = useState<FilterMode>('both');
const [params, setParams] = useState<OverrideLedgerSearchParam>({
settleMonth: dayjs().format('YYYYMM'),
pageSize: 200,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['override', 'ledgers', params, mode],
queryFn: () => commissionApi.overrideLedgers(params),
retry: false,
});
const allRows = data?.list ?? [];
// 클라이언트사이드 방향 필터
const rows = mode === 'from'
? allRows.filter((r) => r.fromAgentId === params.fromAgentId)
: mode === 'to'
? allRows.filter((r) => r.toAgentId === params.toAgentId)
: allRows;
const pinnedRow = rows.length > 0 ? {
fromAgentName: '합계',
baseCommission: rows.reduce((s, r) => s + (r.baseCommission ?? 0), 0),
overrideAmount: rows.reduce((s, r) => s + (r.overrideAmount ?? 0), 0),
} as Partial<OverrideLedgerRow> : undefined;
return (
<PageContainer
title="오버라이드 원장"
description="From-Agent → To-Agent 오버라이드 발생 원장 (조회 전용)"
extra={
<Space>
<Text style={{ fontSize: 13, color: GRAY[500] }}> :</Text>
<Radio.Group
value={mode}
onChange={(e) => setMode(e.target.value)}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="both"></Radio.Button>
<Radio.Button value="from">From </Radio.Button>
<Radio.Button value="to">To </Radio.Button>
</Radio.Group>
</Space>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/override-ledgers 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setParams({ ...v as OverrideLedgerSearchParam, pageSize: 200 })}
onReset={() => setParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<OverrideLedgerRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={600}
rowKey="ledgerId"
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,241 @@
import { useState } from 'react';
import { Alert, Col, Form, Input, InputNumber, Modal, Row, Space, Tabs, Tag, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
commissionApi,
PersistencyRuleRow, PersistencyBonusRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING } from '@/theme/tokens';
const { Text } = Typography;
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const fmtPct = (v: unknown) => `${((v as number) ?? 0).toFixed(1)}%`;
// 유지보수수당 등급별 색상
const BONUS_TYPE_COLOR: Record<string, string> = {
'13M': COLOR_PRIMARY,
'25M': COLOR_SUCCESS,
'37M': COLOR_WARNING,
'49M': '#7C3AED',
};
const RULE_COLS: ColDef<PersistencyRuleRow>[] = [
{
field: 'bonusType', headerName: '유지 기준', width: 100,
cellRenderer: (p: { value: string }) => (
<Tag style={{ background: BONUS_TYPE_COLOR[p.value] ?? GRAY[200], color: '#fff', border: 'none', borderRadius: 4 }}>
{p.value}
</Tag>
),
},
{ field: 'minRate', headerName: '최저유지율(%)', width: 130, type: 'numericColumn', valueFormatter: (p) => fmtPct(p.value) },
{ field: 'bonusAmount', headerName: '정액지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'bonusRate', headerName: '정률지급(%)', flex: 1, type: 'numericColumn', valueFormatter: (p) => p.value != null ? fmtPct(p.value) : '-' },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
{ field: 'status', headerName: '상태', width: 90 },
];
const BONUS_COLS: ColDef<PersistencyBonusRow>[] = [
{ field: 'evaluationMonth', headerName: '평가월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{
field: 'bonusType', headerName: '기준', width: 90,
cellRenderer: (p: { value: string }) => (
<Tag style={{ background: BONUS_TYPE_COLOR[p.value] ?? GRAY[200], color: '#fff', border: 'none', borderRadius: 4 }}>
{p.value}
</Tag>
),
},
{ field: 'retentionRate', headerName: '유지율(%)', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtPct(p.value) },
{ field: 'bonusAmount', headerName: '지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'paidYn', headerName: '지급여부', width: 90 },
{ field: 'status', headerName: '상태', width: 90 },
];
export default function PersistencyBonus() {
const qc = useQueryClient();
const [bonusParams, setBonusParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<PersistencyRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
queryKey: ['persistency', 'rules'],
queryFn: () => commissionApi.persistencyRules(),
retry: false,
});
const { data: bonusData, isLoading: bonusLoading } = useQuery({
queryKey: ['persistency', 'bonuses', bonusParams],
queryFn: () => commissionApi.persistencyBonuses(bonusParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const bonuses = bonusData?.list ?? [];
// 등급별 통계 카드
const byType = ['13M', '25M', '37M', '49M'].map((t) => ({
type: t,
count: bonuses.filter((b) => b.bonusType === t).length,
total: bonuses.filter((b) => b.bonusType === t).reduce((s, b) => s + (b.bonusAmount ?? 0), 0),
}));
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: PersistencyRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.persistencyRuleUpdate(editingRule.ruleId, values);
} else {
await commissionApi.persistencyRuleCreate(values);
}
message.success('저장 완료');
qc.invalidateQueries({ queryKey: ['persistency', 'rules'] });
setRuleModalOpen(false);
} catch { /* handled */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', onOk: async () => {
await commissionApi.persistencyRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['persistency', 'rules'] });
},
});
const pinnedBonus = bonuses.length > 0 ? {
agentName: '합계',
bonusAmount: bonuses.reduce((s, b) => s + (b.bonusAmount ?? 0), 0),
} as Partial<PersistencyBonusRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="유지보수수당" description="13M/25M/37M/49M 유지율 기반 보너스 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/persistency-bonus 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '지급 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="PERSIST_BONUS" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<PersistencyRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: PersistencyRuleRow }) => (
<Space>
<PermissionButton menuCode="PERSIST_BONUS" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="PERSIST_BONUS" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={420}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'bonuses',
label: '지급 이력',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '평가월', span: 6 },
]}
onSearch={(v) => setBonusParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setBonusParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
{/* 등급별 통계 카드 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
{byType.map((t) => (
<Col span={6} key={t.type}>
<div style={{
...cardStyle, padding: '16px 20px',
borderLeft: `4px solid ${BONUS_TYPE_COLOR[t.type] ?? GRAY[300]}`,
}}>
<Text style={{ fontSize: 13, color: GRAY[500] }}>{t.type} </Text>
<div style={{ fontSize: 20, fontWeight: 700, color: BONUS_TYPE_COLOR[t.type] ?? GRAY[700] }}>
{t.count}
</div>
<Text style={{ fontSize: 12, color: GRAY[400] }}>{t.total.toLocaleString()}</Text>
</div>
</Col>
))}
</Row>
<ProCard style={cardStyle}>
<DataGrid<PersistencyBonusRow>
rows={bonuses}
columns={BONUS_COLS}
loading={bonusLoading}
height={480}
rowKey="bonusId"
pinnedBottomRow={pinnedBonus}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장" cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="bonusType" label="유지 기준" rules={[{ required: true }]}>
<Input placeholder="13M / 25M / 37M / 49M" />
</Form.Item>
<Form.Item name="minRate" label="최저유지율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.1} />
</Form.Item>
<Form.Item name="bonusAmount" label="정액지급(원)">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="bonusRate" label="정률지급(%)">
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}><Input placeholder="YYYY-MM-DD" /></Form.Item>
<Form.Item name="effectiveTo" label="적용종료"><Input placeholder="YYYY-MM-DD" /></Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,184 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, Tabs, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
commissionApi,
ReinstatementRuleRow, ReinstatementLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const RULE_COLS: ColDef<ReinstatementRuleRow>[] = [
{ field: 'carrierName', headerName: '보험사', width: 140 },
{ field: 'productType', headerName: '상품유형', width: 120 },
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
{ field: 'status', headerName: '상태', width: 90 },
];
const LEDGER_COLS: ColDef<ReinstatementLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'reinstatementPremium', headerName: '부활보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 90 },
];
export default function ReinstatementCommission() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<ReinstatementRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
queryKey: ['reinstatement', 'rules'],
queryFn: () => commissionApi.reinstatementRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['reinstatement', 'ledgers', ledgerParams],
queryFn: () => commissionApi.reinstatementLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: ReinstatementRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.reinstatementRuleUpdate(editingRule.ruleId, values);
} else {
await commissionApi.reinstatementRuleCreate(values);
}
message.success('저장 완료');
qc.invalidateQueries({ queryKey: ['reinstatement', 'rules'] });
setRuleModalOpen(false);
} catch { /* handled */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.reinstatementRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['reinstatement', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
reinstatementPremium: ledgers.reduce((s, r) => s + (r.reinstatementPremium ?? 0), 0),
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
} as Partial<ReinstatementLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="부활수수료" description="실효 계약 부활 시 수수료 룰 및 원장">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/reinstatement-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '수수료 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="REINSTATE_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<ReinstatementRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: ReinstatementRuleRow }) => (
<Space>
<PermissionButton menuCode="REINSTATE_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="REINSTATE_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={480}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '부활 원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<ReinstatementLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={480}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장" cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}><Input placeholder="YYYY-MM-DD" /></Form.Item>
<Form.Item name="effectiveTo" label="적용종료"><Input placeholder="YYYY-MM-DD" /></Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,184 @@
import { useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Tabs, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
commissionApi,
RenewalRuleRow, RenewalLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const RULE_COLS: ColDef<RenewalRuleRow>[] = [
{ field: 'carrierName', headerName: '보험사', width: 140 },
{ field: 'productType', headerName: '상품유형', width: 120 },
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
{ field: 'status', headerName: '상태', width: 90 },
];
const LEDGER_COLS: ColDef<RenewalLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'renewalPremium', headerName: '갱신보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 90 },
];
export default function RenewalCommission() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RenewalRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
queryKey: ['renewal', 'rules'],
queryFn: () => commissionApi.renewalRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['renewal', 'ledgers', ledgerParams],
queryFn: () => commissionApi.renewalLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: RenewalRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.renewalRuleUpdate(editingRule.ruleId, values);
} else {
await commissionApi.renewalRuleCreate(values);
}
message.success('저장 완료');
qc.invalidateQueries({ queryKey: ['renewal', 'rules'] });
setRuleModalOpen(false);
} catch { /* handled */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.renewalRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['renewal', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
renewalPremium: ledgers.reduce((s, r) => s + (r.renewalPremium ?? 0), 0),
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
} as Partial<RenewalLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="갱신수수료" description="자동차/실비 갱신 수수료 룰 및 원장">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/renewal-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '수수료 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="RENEWAL_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<RenewalRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: RenewalRuleRow }) => (
<Space>
<PermissionButton menuCode="RENEWAL_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="RENEWAL_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={480}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '갱신 원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<RenewalLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={480}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장" cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}><Input placeholder="YYYY-MM-DD" /></Form.Item>
<Form.Item name="effectiveTo" label="적용종료"><Input placeholder="YYYY-MM-DD" /></Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,197 @@
import { useState } from 'react';
import { Alert, Checkbox, Form, Input, Modal, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import AttachmentUpload from '@/components/biz/AttachmentUpload';
import { complaintApi, ComplaintRow, ComplaintSaveReq, ComplaintSearchParam } from '@/api/complaint';
import { GRAY, RADIUS, SHADOW, COLOR_ERROR_BG, COLOR_ERROR_BORDER } from '@/theme/tokens';
const { Text } = Typography;
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<ComplaintRow>[] = [
{ field: 'contractNo', headerName: '계약번호', width: 150, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'complaintType', headerName: '민원유형', width: 120 },
{ field: 'content', headerName: '민원내용', flex: 1 },
{ field: 'receivedDate', headerName: '접수일', width: 120 },
{
field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="COMPLAINT_STATUS" value={p.value} />,
},
{
field: 'chargebackTriggered', headerName: '환수', width: 80,
cellRenderer: (p: { value: boolean }) =>
p.value ? <Text style={{ color: '#E24B4A', fontWeight: 700 }}></Text> : <Text style={{ color: GRAY[400] }}>-</Text>,
},
{ field: 'chargebackAmount', headerName: '환수금액', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'closedAt', headerName: '처리일', width: 120 },
];
export default function Complaints() {
const qc = useQueryClient();
const [params, setParams] = useState<ComplaintSearchParam>({ pageSize: 100 });
const [createOpen, setCreateOpen] = useState(false);
const [closeTarget, setCloseTarget] = useState<number | null>(null);
const [processNote, setProcessNote] = useState('');
const [createdId, setCreatedId] = useState<number | null>(null);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['complaints', params],
queryFn: () => complaintApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const chargebackCount = rows.filter((r) => r.chargebackTriggered).length;
const handleCreate = async () => {
const values = await form.validateFields() as ComplaintSaveReq;
try {
const created = await complaintApi.create(values);
message.success('민원 등록 완료');
qc.invalidateQueries({ queryKey: ['complaints'] });
setCreatedId(created.complaintId);
setCreateOpen(false);
form.resetFields();
} catch { /* handled */ }
};
const handleClose = async () => {
if (!closeTarget) return;
try {
await complaintApi.close(closeTarget, processNote);
message.success('처리완료');
qc.invalidateQueries({ queryKey: ['complaints'] });
setCloseTarget(null);
setProcessNote('');
} catch { /* handled */ }
};
return (
<PageContainer
title="민원 관리"
description="고객 민원 접수, 처리, 환수 연동"
extra={
<PermissionButton menuCode="COMPLAINTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/complaints 를 확인하세요." style={{ marginBottom: 16 }} />
)}
{chargebackCount > 0 && (
<div style={{
padding: '12px 20px',
background: COLOR_ERROR_BG,
border: `1px solid ${COLOR_ERROR_BORDER}`,
borderRadius: RADIUS.md,
marginBottom: 16,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<Text style={{ fontSize: 13, color: '#E24B4A', fontWeight: 700 }}> {chargebackCount} </Text>
</div>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'code', name: 'complaintType', label: '민원유형', groupCode: 'COMPLAINT_TYPE', span: 6 },
{ type: 'code', name: 'status', label: '상태', groupCode: 'COMPLAINT_STATUS', span: 6 },
{ type: 'dateRange', name: 'receivedDate', label: '접수일', span: 8 },
]}
onSearch={(v) => setParams({ ...v as ComplaintSearchParam, pageSize: 100 })}
onReset={() => setParams({ pageSize: 100 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<ComplaintRow>
rows={rows}
columns={[
...COLUMNS,
{
headerName: '액션', width: 120, pinned: 'right',
cellRenderer: (p: { data: ComplaintRow }) => (
<PermissionButton
menuCode="COMPLAINTS" permCode="APPROVE" size="small" type="primary"
disabled={p.data.status === 'CLOSED'}
onClick={() => { setCloseTarget(p.data.complaintId); setProcessNote(''); }}
>
</PermissionButton>
),
},
]}
loading={isLoading}
height={600}
rowKey="complaintId"
getRowStyle={(params) => {
if (params.data?.chargebackTriggered) {
return { background: '#FEF0F1' };
}
return undefined;
}}
/>
</ProCard>
{/* 등록 모달 */}
<Modal title="민원 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소" width={560}>
<Form form={form} layout="vertical">
<Form.Item name="contractNo" label="계약번호" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="complaintType" label="민원유형" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="content" label="민원내용" rules={[{ required: true }]}>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item name="receivedDate" label="접수일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="chargebackTriggered" label="환수 연동" valuePropName="checked">
<Checkbox> (13M )</Checkbox>
</Form.Item>
</Form>
</Modal>
{/* 처리완료 모달 */}
<Modal
title="처리완료 확인"
open={closeTarget !== null}
onOk={handleClose}
onCancel={() => setCloseTarget(null)}
okText="처리완료"
cancelText="취소"
>
<Form layout="vertical">
<Form.Item label="처리 내용" required>
<Input.TextArea
rows={4}
value={processNote}
onChange={(e) => setProcessNote(e.target.value)}
placeholder="처리 내용을 입력하세요"
/>
</Form.Item>
</Form>
</Modal>
{/* 첨부파일 (마지막 등록 건) */}
{createdId && (
<ProCard
title="첨부파일"
style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm, marginTop: 16 }}
>
<AttachmentUpload refType="COMPLAINT" refId={createdId} />
</ProCard>
)}
</PageContainer>
);
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { Alert, Checkbox, Form, Input, Modal, Space, Tag, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import { endorsementApi, ContractEndorsementRow, ContractEndorsementSaveReq, EndorsementSearchParam } from '@/api/endorsement';
import { GRAY, RADIUS, SHADOW, COLOR_WARNING_BG, COLOR_WARNING_BORDER } from '@/theme/tokens';
const { Text } = Typography;
const COLUMNS: ColDef<ContractEndorsementRow>[] = [
{ field: 'contractNo', headerName: '계약번호', width: 150, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'endorsementTypeName', headerName: '변경유형', width: 130 },
{ field: 'changeDescription', headerName: '변경내용', flex: 1 },
{ field: 'endorsementDate', headerName: '변경일', width: 120 },
{
field: 'recalculateRequired', headerName: '재계산', width: 90,
cellRenderer: (p: { value: boolean }) => p.value
? <Tag color="warning" style={{ margin: 0, fontWeight: 700 }}></Tag>
: <Tag color="default" style={{ margin: 0 }}></Tag>,
},
{
field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="ENDORSEMENT_STATUS" value={p.value} />,
},
{ field: 'processedAt', headerName: '처리일', width: 120 },
];
export default function ContractEndorsements() {
const qc = useQueryClient();
const [params, setParams] = useState<EndorsementSearchParam>({ pageSize: 100 });
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['endorsements', params],
queryFn: () => endorsementApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const recalcCount = rows.filter((r) => r.recalculateRequired && r.status !== 'PROCESSED').length;
const handleCreate = async () => {
const values = await form.validateFields() as ContractEndorsementSaveReq;
try {
await endorsementApi.create(values);
message.success('계약 변경 등록 완료');
qc.invalidateQueries({ queryKey: ['endorsements'] });
setCreateOpen(false);
form.resetFields();
} catch { /* handled */ }
};
const handleProcess = (id: number) => Modal.confirm({
title: '처리 확정',
content: '변경사항을 처리 완료로 변경합니다. 재계산이 필요한 경우 정산 배치를 다시 실행하세요.',
onOk: async () => {
await endorsementApi.process(id);
message.success('처리 완료');
qc.invalidateQueries({ queryKey: ['endorsements'] });
},
});
return (
<PageContainer
title="계약 변경 관리"
description="보험료/수익자/피보험자 변경 이력 및 재계산 트리거"
extra={
<PermissionButton menuCode="CONTRACT_ENDORSEMENTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/contract-endorsements 를 확인하세요." style={{ marginBottom: 16 }} />
)}
{recalcCount > 0 && (
<div style={{
padding: '12px 20px',
background: COLOR_WARNING_BG,
border: `1px solid ${COLOR_WARNING_BORDER}`,
borderRadius: RADIUS.md,
marginBottom: 16,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<Tag color="warning" style={{ fontWeight: 700 }}> {recalcCount}</Tag>
<Text style={{ fontSize: 13, color: GRAY[700] }}> .</Text>
</div>
)}
<SearchForm
conditions={[
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'code', name: 'endorsementType', label: '변경유형', groupCode: 'ENDORSEMENT_TYPE', span: 6 },
{ type: 'code', name: 'status', label: '상태', groupCode: 'ENDORSEMENT_STATUS', span: 6 },
]}
onSearch={(v) => setParams({ ...v as EndorsementSearchParam, pageSize: 100 })}
onReset={() => setParams({ pageSize: 100 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<ContractEndorsementRow>
rows={rows}
columns={[
...COLUMNS,
{
headerName: '액션', width: 120, pinned: 'right',
cellRenderer: (p: { data: ContractEndorsementRow }) => (
<PermissionButton
menuCode="CONTRACT_ENDORSEMENTS" permCode="APPROVE" size="small" type="primary"
disabled={p.data.status === 'PROCESSED'}
onClick={() => handleProcess(p.data.endorsementId)}
>
</PermissionButton>
),
},
]}
loading={isLoading}
height={600}
rowKey="endorsementId"
getRowStyle={(params) => {
if (params.data?.recalculateRequired && params.data?.status !== 'PROCESSED') {
return { background: COLOR_WARNING_BG };
}
return undefined;
}}
/>
</ProCard>
<Modal title="계약 변경 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
<Form form={form} layout="vertical">
<Form.Item name="contractNo" label="계약번호" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="endorsementType" label="변경유형" rules={[{ required: true }]}>
<Input placeholder="PREMIUM / INSURED / BENEFICIARY / ETC" />
</Form.Item>
<Form.Item name="changeDescription" label="변경내용" rules={[{ required: true }]}>
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item name="endorsementDate" label="변경일" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="recalculateRequired" label="재계산 필요" valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
+249
View File
@@ -0,0 +1,249 @@
import { useMemo, useState } from 'react';
import { Alert, Col, Row, Tabs, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { kpiApi, CumulativeRow, KpiSearchParam } from '@/api/kpi';
import {
COLOR_PRIMARY, COLOR_SUCCESS, COLOR_ERROR, GRAY, SHADOW, RADIUS,
} from '@/theme/tokens';
const { Text } = Typography;
const fmt = (v: number) => v.toLocaleString();
const fmtM = (v: number) => `${(v / 1_000_000).toFixed(1)}M`;
// ────────────────────────────────────────────────────────
// 컬럼 (AGENT / ORG 공통 구조)
// ────────────────────────────────────────────────────────
function buildColumns(levelType: 'AGENT' | 'ORG'): ColDef<CumulativeRow>[] {
return [
{
field: 'entityName',
headerName: levelType === 'AGENT' ? '설계사명' : '조직명',
width: 150, pinned: 'left',
},
{
field: 'orgName',
headerName: '소속 조직',
width: 140,
hide: levelType === 'ORG',
},
{ field: 'settleMonthCount', headerName: '정산 월수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'cumGross', headerName: '누적 수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'cumChargeback', headerName: '누적 환수', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'cumIncentive', headerName: '누적 시책', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{
headerName: '순수익',
flex: 1,
type: 'numericColumn',
sortable: true,
valueGetter: (p) => (p.data?.cumGross ?? 0) - (p.data?.cumChargeback ?? 0),
valueFormatter: (p) => fmt(p.value ?? 0),
cellStyle: (p) => ({
color: p.value > 0 ? COLOR_SUCCESS : p.value < 0 ? COLOR_ERROR : GRAY[500],
fontWeight: 600,
}),
},
];
}
// ────────────────────────────────────────────────────────
// 합계행 계산
// ────────────────────────────────────────────────────────
function buildPinned(rows: CumulativeRow[]): Partial<CumulativeRow> | undefined {
if (!rows.length) return undefined;
return {
entityName: '합계',
cumGross: rows.reduce((s, r) => s + r.cumGross, 0),
cumChargeback: rows.reduce((s, r) => s + r.cumChargeback, 0),
cumIncentive: rows.reduce((s, r) => s + r.cumIncentive, 0),
cumNet: rows.reduce((s, r) => s + r.cumNet, 0),
settleMonthCount: rows.reduce((s, r) => s + r.settleMonthCount, 0),
};
}
// ────────────────────────────────────────────────────────
// Top10 차트
// ────────────────────────────────────────────────────────
const PALETTE = [
COLOR_PRIMARY, COLOR_SUCCESS, '#F59E0B', '#8B5CF6', '#06B6D4',
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16',
];
function Top10Chart({ rows }: { rows: CumulativeRow[] }) {
const chartData = useMemo(() =>
[...rows]
.map((r) => ({
name: r.entityName,
순수익: Math.round((r.cumGross - r.cumChargeback) / 1_000_000),
}))
.sort((a, b) => b. - a.)
.slice(0, 10),
[rows]);
return (
<ProCard
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> 10</span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원 ( - )</span>}
headerBordered
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
marginBottom: 20,
}}
>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false}
tickFormatter={(v) => `${v}M`}
/>
<YAxis type="category" dataKey="name" width={90} tick={{ fontSize: 11, fill: GRAY[600] }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
formatter={(v: number) => [`${fmtM(v * 1_000_000)}`, '순수익']}
/>
<Bar dataKey="순수익" radius={[0, 4, 4, 0]}>
{chartData.map((_, i) => (
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ProCard>
);
}
// ────────────────────────────────────────────────────────
// 탭 패널
// ────────────────────────────────────────────────────────
interface PanelProps {
levelType: 'AGENT' | 'ORG';
params: KpiSearchParam;
}
function CumulativePanel({ levelType, params }: PanelProps) {
const { data, isLoading, isError } = useQuery({
queryKey: ['kpi', 'cumulative', levelType, params],
queryFn: () => kpiApi.cumulative({ ...params, levelType }),
retry: false,
});
const rows = data?.list ?? [];
const columns = useMemo(() => buildColumns(levelType), [levelType]);
const pinnedRow = buildPinned(rows);
return (
<>
{isError && (
<Alert type="warning" showIcon
message="API 미응답"
description="/api/kpi/cumulative 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
{/* 요약 카드 3개 */}
<Row gutter={20} style={{ marginBottom: 20 }}>
{[
{ label: '누적 수수료 합계', value: rows.reduce((s, r) => s + r.cumGross, 0), color: COLOR_PRIMARY, bg: '#EBF3FF' },
{ label: '누적 환수 합계', value: rows.reduce((s, r) => s + r.cumChargeback, 0), color: COLOR_ERROR, bg: '#FEF0F1' },
{ label: '누적 순수익', value: rows.reduce((s, r) => s + r.cumGross - r.cumChargeback, 0), color: COLOR_SUCCESS, bg: '#E8FAF3' },
].map((card) => (
<Col span={8} key={card.label}>
<div style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 8,
}}>
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{card.label}</Text>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 30, fontWeight: 700, color: card.color, lineHeight: 1, fontFeatureSettings: "'tnum'" }}>
{fmtM(card.value)}
</span>
<Text style={{ fontSize: 13, color: GRAY[500] }}></Text>
</div>
</div>
</Col>
))}
</Row>
<Top10Chart rows={rows} />
<ProCard
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
}}
>
<DataGrid<CumulativeRow>
rows={rows}
columns={columns}
loading={isLoading}
height={500}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</>
);
}
// ────────────────────────────────────────────────────────
// 메인
// ────────────────────────────────────────────────────────
export default function KpiCumulative() {
const [activeTab, setActiveTab] = useState<'AGENT' | 'ORG'>('AGENT');
const [params, setParams] = useState<KpiSearchParam>({
toMonth: dayjs().format('YYYYMM'),
pageSize: 500,
});
const handleSearch = (values: Record<string, unknown>) => {
setParams({
toMonth: values.toMonth as string | undefined,
orgId: values.orgId as number | undefined,
pageSize: 500,
});
};
return (
<PageContainer
title="누계 현황"
description="설계사 / 조직별 누적 수수료 · 환수 · 순수익"
>
<SearchForm
conditions={[
{ type: 'month', name: 'toMonth', label: '기준 월', span: 6 },
]}
onSearch={handleSearch}
onReset={() => setParams({ toMonth: dayjs().format('YYYYMM'), pageSize: 500 })}
/>
<Tabs
activeKey={activeTab}
onChange={(k) => setActiveTab(k as 'AGENT' | 'ORG')}
items={[
{ key: 'AGENT', label: '설계사별' },
{ key: 'ORG', label: '조직별' },
]}
style={{ marginBottom: 16 }}
/>
<CumulativePanel key={activeTab} levelType={activeTab} params={params} />
</PageContainer>
);
}
@@ -0,0 +1,318 @@
import { useMemo, useState } from 'react';
import { Alert, Button, Col, Row, Space, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import {
AreaChart, Area, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import {
IconTrendingUp, IconTrendingDown, IconMinus, IconDownload,
} from '@tabler/icons-react';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { kpiApi, MonthlySummaryRow, KpiSearchParam } from '@/api/kpi';
import {
COLOR_PRIMARY, COLOR_ERROR, COLOR_SUCCESS,
GRAY, SHADOW, RADIUS,
} from '@/theme/tokens';
const { Text } = Typography;
// ────────────────────────────────────────────────────────
// 유틸
// ────────────────────────────────────────────────────────
const fmt = (v: number) => v.toLocaleString();
const fmtM = (v: number) => `${(v / 1_000_000).toFixed(1)}M`;
function calcPct(curr: number, prev: number): number | null {
if (prev === 0) return null;
return Math.round(((curr - prev) / Math.abs(prev)) * 1000) / 10;
}
// ────────────────────────────────────────────────────────
// 서브 컴포넌트 — KPI 카드
// ────────────────────────────────────────────────────────
interface KpiCardProps {
label: string;
value: number;
unit?: string;
delta?: number | null;
deltaLabel?: string;
color?: string;
bg?: string;
}
function KpiCard({ label, value, unit = '원', delta, deltaLabel, color = COLOR_PRIMARY, bg = '#EBF3FF' }: KpiCardProps) {
const renderDelta = () => {
if (delta === null || delta === undefined) return null;
if (delta > 0) return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600, color: COLOR_SUCCESS }}>
<IconTrendingUp size={12} />+{delta.toFixed(1)}%
</span>
);
if (delta < 0) return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, fontWeight: 600, color: COLOR_ERROR }}>
<IconTrendingDown size={12} />{delta.toFixed(1)}%
</span>
);
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2, fontSize: 12, color: GRAY[400] }}>
<IconMinus size={12} />
</span>
);
};
return (
<div style={{
background: '#ffffff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
padding: '24px 28px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{label}</Text>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: bg, color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconTrendingUp size={18} />
</div>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{
fontSize: 34, fontWeight: 700, color: GRAY[800], lineHeight: 1,
fontFeatureSettings: "'tnum'", fontVariantNumeric: 'tabular-nums',
}}>
{fmtM(value)}
</span>
<Text style={{ fontSize: 14, color: GRAY[500] }}>{unit}</Text>
</div>
{(delta !== null && delta !== undefined) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{renderDelta()}
{deltaLabel && <Text style={{ fontSize: 12, color: GRAY[400] }}>{deltaLabel}</Text>}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────
// AG Grid 컬럼 정의
// ────────────────────────────────────────────────────────
const COLUMNS: ColDef<MonthlySummaryRow>[] = [
{ field: 'yyyymm', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentCount', headerName: '설계사수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalGross', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalTax', headerName: '총공제(세)', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalNet', headerName: '총지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalChargeback', headerName: '환수', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalIncentive', headerName: '시책', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
];
// ────────────────────────────────────────────────────────
// 클라이언트 사이드 엑셀 내보내기 (xlsx 없음 — CSV 대체)
// ────────────────────────────────────────────────────────
function exportCsv(rows: MonthlySummaryRow[]) {
const headers = ['정산월', '설계사수', '총수수료', '총공제', '총지급', '환수', '시책'];
const lines = [
headers.join(','),
...rows.map((r) =>
[r.yyyymm, r.agentCount, r.totalGross, r.totalTax, r.totalNet, r.totalChargeback, r.totalIncentive].join(','),
),
];
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `월별정산요약_${dayjs().format('YYYYMMDD')}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
// ────────────────────────────────────────────────────────
// 메인 컴포넌트
// ────────────────────────────────────────────────────────
export default function KpiMonthlySummary() {
const [params, setParams] = useState<KpiSearchParam>({
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
toMonth: dayjs().format('YYYYMM'),
pageSize: 100,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['kpi', 'monthly', params],
queryFn: () => kpiApi.monthlySummary(params),
retry: false,
});
const rows = data?.list ?? [];
// KPI 카드: 최신월 / 전월 / YTD 합산
const thisMonth = rows[rows.length - 1];
const prevMonth = rows.length >= 2 ? rows[rows.length - 2] : undefined;
const ytdGross = rows.reduce((s, r) => s + (r.totalGross ?? 0), 0);
const grossDelta = thisMonth && prevMonth
? calcPct(thisMonth.totalGross, prevMonth.totalGross)
: null;
// 합계행
const pinnedRow = rows.length > 0 ? {
yyyymm: '합계',
agentCount: rows.reduce((s, r) => s + r.agentCount, 0),
totalGross: rows.reduce((s, r) => s + r.totalGross, 0),
totalTax: rows.reduce((s, r) => s + r.totalTax, 0),
totalNet: rows.reduce((s, r) => s + r.totalNet, 0),
totalChargeback: rows.reduce((s, r) => s + r.totalChargeback, 0),
totalIncentive: rows.reduce((s, r) => s + r.totalIncentive, 0),
} as Partial<MonthlySummaryRow> : undefined;
// 차트 데이터 — 최근 12개월
const chartData = useMemo(() =>
rows.slice(-12).map((r) => ({
month: r.yyyymm.slice(4, 6) + '월',
총수수료: Math.round(r.totalGross / 1_000_000),
총지급: Math.round(r.totalNet / 1_000_000),
환수: Math.round(r.totalChargeback / 1_000_000),
})),
[rows]);
const handleSearch = (values: Record<string, unknown>) => {
setParams({
fromMonth: values.fromMonth as string | undefined,
toMonth: values.toMonth as string | undefined,
pageSize: 100,
});
};
return (
<PageContainer
title="월별 정산 요약"
description="정산월별 수수료 / 공제 / 지급 합계 — Materialized View"
extra={
<Button
icon={<IconDownload size={14} />}
onClick={() => rows.length && exportCsv(rows)}
disabled={!rows.length}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
CSV
</Button>
}
>
{/* 검색 */}
<SearchForm
conditions={[
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
]}
onSearch={handleSearch}
onReset={() => setParams({ fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'), toMonth: dayjs().format('YYYYMM'), pageSize: 100 })}
/>
{isError && (
<Alert type="warning" showIcon
message="API 미응답"
description="/api/kpi/monthly-summary 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
{/* KPI 카드 3개 */}
<Row gutter={20} style={{ marginBottom: 20 }}>
<Col span={8}>
<KpiCard
label="당월 총수수료"
value={thisMonth?.totalGross ?? 0}
unit="원"
delta={grossDelta}
deltaLabel="전월比"
color={COLOR_PRIMARY}
bg="#EBF3FF"
/>
</Col>
<Col span={8}>
<KpiCard
label="당월 총지급"
value={thisMonth?.totalNet ?? 0}
unit="원"
color={COLOR_SUCCESS}
bg="#E8FAF3"
/>
</Col>
<Col span={8}>
<KpiCard
label={`YTD 누계 (${dayjs().format('YYYY')})`}
value={ytdGross}
unit="원"
color="#7C3AED"
bg="#F3EEFF"
/>
</Col>
</Row>
{/* 차트 */}
<ProCard
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> 12 </span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원</span>}
headerBordered
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
marginBottom: 20,
}}
>
<ResponsiveContainer width="100%" height={260}>
{chartData.length > 0 ? (
<BarChart data={chartData} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
formatter={(v: number) => [`${v}백만`, '']}
/>
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
<Bar dataKey="총수수료" fill={COLOR_PRIMARY} radius={[4, 4, 0, 0]} />
<Bar dataKey="총지급" fill={COLOR_SUCCESS} radius={[4, 4, 0, 0]} />
<Bar dataKey="환수" fill={COLOR_ERROR} radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<AreaChart data={[]} margin={{ top: 8, right: 16, left: -8, bottom: 0 }}>
<XAxis /><YAxis />
</AreaChart>
)}
</ResponsiveContainer>
</ProCard>
{/* AG Grid */}
<ProCard
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
}}
>
<DataGrid<MonthlySummaryRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={480}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useMemo, useState } from 'react';
import { Alert, Col, Row, Select, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { kpiApi, OrgSummaryRow, KpiSearchParam } from '@/api/kpi';
import {
COLOR_PRIMARY, COLOR_SUCCESS, GRAY, SHADOW, RADIUS,
} from '@/theme/tokens';
import dayjs from 'dayjs';
const { Text } = Typography;
const fmt = (v: number) => v.toLocaleString();
// AG Grid 컬럼
const COLUMNS: ColDef<OrgSummaryRow>[] = [
{ field: 'orgName', headerName: '조직명', width: 160, pinned: 'left' },
{ field: 'orgType', headerName: '조직유형', width: 110 },
{ field: 'yyyymm', headerName: '정산월', width: 110 },
{ field: 'agentCount', headerName: '설계사수', width: 100, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalGross', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalNet', headerName: '총지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalRecruit', headerName: '모집', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
{ field: 'totalMaintain',headerName: '유지', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value ?? 0) },
];
// 차트 팔레트
const PALETTE = [
COLOR_PRIMARY, COLOR_SUCCESS, '#F59E0B', '#8B5CF6', '#06B6D4',
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16',
];
export default function KpiOrgSummary() {
const [params, setParams] = useState<KpiSearchParam>({
fromMonth: dayjs().format('YYYYMM'),
toMonth: dayjs().format('YYYYMM'),
pageSize: 200,
});
const [selectedOrg, setSelectedOrg] = useState<number | undefined>();
const { data, isLoading, isError } = useQuery({
queryKey: ['kpi', 'org', params],
queryFn: () => kpiApi.orgSummary({ ...params, orgId: selectedOrg }),
retry: false,
});
const rows = data?.list ?? [];
// 합계행
const pinnedRow = rows.length > 0 ? {
orgName: '합계',
agentCount: rows.reduce((s, r) => s + r.agentCount, 0),
totalGross: rows.reduce((s, r) => s + r.totalGross, 0),
totalNet: rows.reduce((s, r) => s + r.totalNet, 0),
totalRecruit: rows.reduce((s, r) => s + (r.totalRecruit ?? 0), 0),
totalMaintain: rows.reduce((s, r) => s + (r.totalMaintain ?? 0), 0),
} as Partial<OrgSummaryRow> : undefined;
// 조직 목록 (드롭다운)
const orgOptions = useMemo(() => {
const seen = new Set<number>();
return rows
.filter((r) => { if (seen.has(r.orgId)) return false; seen.add(r.orgId); return true; })
.map((r) => ({ value: r.orgId, label: r.orgName }));
}, [rows]);
// 차트 — 조직별 총수수료 상위 10개
const chartData = useMemo(() => {
const byOrg: Record<number, { orgName: string; totalGross: number }> = {};
for (const r of rows) {
if (!byOrg[r.orgId]) byOrg[r.orgId] = { orgName: r.orgName, totalGross: 0 };
byOrg[r.orgId].totalGross += r.totalGross;
}
return Object.values(byOrg)
.sort((a, b) => b.totalGross - a.totalGross)
.slice(0, 10)
.map((x) => ({ name: x.orgName, 수수료: Math.round(x.totalGross / 1_000_000) }));
}, [rows]);
const handleSearch = (values: Record<string, unknown>) => {
setParams({
fromMonth: values.fromMonth as string | undefined,
toMonth: values.toMonth as string | undefined,
pageSize: 200,
});
};
return (
<PageContainer
title="조직별 정산 요약"
description="조직 단위 월별 수수료 / 설계사 집계"
>
<SearchForm
conditions={[
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
]}
onSearch={handleSearch}
onReset={() => {
setSelectedOrg(undefined);
setParams({ fromMonth: dayjs().format('YYYYMM'), toMonth: dayjs().format('YYYYMM'), pageSize: 200 });
}}
/>
{isError && (
<Alert type="warning" showIcon
message="API 미응답"
description="/api/kpi/org-summary 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
<Row gutter={20} style={{ marginBottom: 20 }}>
{/* 좌측: 조직 필터 Select */}
<Col span={5}>
<ProCard
title={<Text style={{ fontSize: 14, fontWeight: 600, color: GRAY[700] }}> </Text>}
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
height: '100%',
}}
>
<Select
style={{ width: '100%' }}
placeholder="전체 조직"
allowClear
options={orgOptions}
value={selectedOrg}
onChange={(v) => setSelectedOrg(v)}
showSearch
filterOption={(input, opt) =>
(opt?.label as string ?? '').toLowerCase().includes(input.toLowerCase())
}
/>
<div style={{ marginTop: 12 }}>
<Text style={{ fontSize: 12, color: GRAY[400] }}>
</Text>
</div>
</ProCard>
</Col>
{/* 우측: 차트 */}
<Col span={19}>
<ProCard
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> ( 10)</span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}>단위: 백만원</span>}
headerBordered
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
}}
>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chartData} layout="vertical" margin={{ top: 4, right: 24, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis type="category" dataKey="name" width={80} tick={{ fontSize: 11, fill: GRAY[600] }} axisLine={false} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
formatter={(v: number) => [`${v}백만`, '수수료']}
/>
<Bar dataKey="수수료" radius={[0, 4, 4, 0]}>
{chartData.map((_, i) => (
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ProCard>
</Col>
</Row>
{/* AG Grid */}
<ProCard
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
}}
>
<DataGrid<OrgSummaryRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={500}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
+237
View File
@@ -0,0 +1,237 @@
import { useMemo, useState } from 'react';
import { Alert, Col, Row, Typography } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer, Legend,
} from 'recharts';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import { kpiApi, RetentionRow, KpiSearchParam } from '@/api/kpi';
import {
COLOR_PRIMARY, COLOR_SUCCESS, GRAY, SHADOW, RADIUS,
} from '@/theme/tokens';
const { Text } = Typography;
const fmtPct = (v: number) => `${v.toFixed(1)}%`;
// AG Grid 컬럼
const COLUMNS: ColDef<RetentionRow>[] = [
{ field: 'contractMonth', headerName: '계약 기준월', width: 130, pinned: 'left' },
{ field: 'carrierName', headerName: '보험사', width: 140 },
{ field: 'totalContracts', headerName: '전체 계약', width: 110, type: 'numericColumn', valueFormatter: (p) => (p.value ?? 0).toLocaleString() },
{ field: 'retained13m', headerName: '13M 유지건', flex: 1, type: 'numericColumn', valueFormatter: (p) => (p.value ?? 0).toLocaleString() },
{ field: 'retained25m', headerName: '25M 유지건', flex: 1, type: 'numericColumn', valueFormatter: (p) => (p.value ?? 0).toLocaleString() },
{
field: 'retentionRate13m', headerName: '13M 유지율%', flex: 1, type: 'numericColumn',
valueFormatter: (p) => fmtPct(p.value ?? 0),
cellStyle: (p) => ({ color: (p.value ?? 0) >= 80 ? COLOR_SUCCESS : (p.value ?? 0) >= 60 ? '#F59E0B' : '#F04452' }),
},
{
field: 'retentionRate25m', headerName: '25M 유지율%', flex: 1, type: 'numericColumn',
valueFormatter: (p) => fmtPct(p.value ?? 0),
cellStyle: (p) => ({ color: (p.value ?? 0) >= 70 ? COLOR_SUCCESS : (p.value ?? 0) >= 50 ? '#F59E0B' : '#F04452' }),
},
];
// ────────────────────────────────────────────────────────
// 상단 카드
// ────────────────────────────────────────────────────────
interface AvgCardProps { label: string; value: number; color: string; bg: string }
function AvgCard({ label, value, color, bg }: AvgCardProps) {
return (
<div style={{
background: '#ffffff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
padding: '24px 28px',
display: 'flex', flexDirection: 'column', gap: 12,
}}>
<Text style={{ fontSize: 13, fontWeight: 500, color: GRAY[500] }}>{label}</Text>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<span style={{
fontSize: 34, fontWeight: 700, color: GRAY[800], lineHeight: 1,
fontFeatureSettings: "'tnum'",
}}>
{value.toFixed(1)}
</span>
<Text style={{ fontSize: 14, color: GRAY[500] }}>%</Text>
</div>
<div style={{
height: 4, background: GRAY[100], borderRadius: 999, overflow: 'hidden', marginTop: 4,
}}>
<div style={{
height: '100%', width: `${Math.min(value, 100)}%`,
background: color, backgroundColor: bg,
borderRadius: 999,
backgroundImage: `linear-gradient(90deg, ${color}88, ${color})`,
}} />
</div>
</div>
);
}
// ────────────────────────────────────────────────────────
// 메인
// ────────────────────────────────────────────────────────
export default function KpiRetention() {
const [params, setParams] = useState<KpiSearchParam>({
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
toMonth: dayjs().format('YYYYMM'),
pageSize: 200,
});
const { data, isLoading, isError } = useQuery({
queryKey: ['kpi', 'retention', params],
queryFn: () => kpiApi.retention(params),
retry: false,
});
const rows = data?.list ?? [];
// 평균 유지율
const avg13m = rows.length > 0
? rows.reduce((s, r) => s + r.retentionRate13m, 0) / rows.length
: 0;
const avg25m = rows.length > 0
? rows.reduce((s, r) => s + r.retentionRate25m, 0) / rows.length
: 0;
// 차트 — 월별 평균 유지율 (보험사 합산 후 평균)
const chartData = useMemo(() => {
const byMonth: Record<string, { sum13: number; sum25: number; cnt: number }> = {};
for (const r of rows) {
if (!byMonth[r.contractMonth]) byMonth[r.contractMonth] = { sum13: 0, sum25: 0, cnt: 0 };
byMonth[r.contractMonth].sum13 += r.retentionRate13m;
byMonth[r.contractMonth].sum25 += r.retentionRate25m;
byMonth[r.contractMonth].cnt += 1;
}
return Object.entries(byMonth)
.sort(([a], [b]) => a.localeCompare(b))
.slice(-12)
.map(([month, v]) => ({
month: month.slice(4, 6) + '월',
'13M유지율': Math.round((v.sum13 / v.cnt) * 10) / 10,
'25M유지율': Math.round((v.sum25 / v.cnt) * 10) / 10,
}));
}, [rows]);
// 합계행 (건수 합, 비율은 평균)
const pinnedRow = rows.length > 0 ? {
contractMonth: '집계',
totalContracts: rows.reduce((s, r) => s + r.totalContracts, 0),
retained13m: rows.reduce((s, r) => s + r.retained13m, 0),
retained25m: rows.reduce((s, r) => s + r.retained25m, 0),
retentionRate13m: avg13m,
retentionRate25m: avg25m,
} as Partial<RetentionRow> : undefined;
const handleSearch = (values: Record<string, unknown>) => {
setParams({
fromMonth: values.fromMonth as string | undefined,
toMonth: values.toMonth as string | undefined,
pageSize: 200,
});
};
return (
<PageContainer
title="유지율 현황"
description="13개월 / 25개월 계약 유지율 — Materialized View"
>
<SearchForm
conditions={[
{ type: 'month', name: 'fromMonth', label: '시작 월', span: 6 },
{ type: 'month', name: 'toMonth', label: '종료 월', span: 6 },
]}
onSearch={handleSearch}
onReset={() => setParams({
fromMonth: dayjs().subtract(11, 'month').format('YYYYMM'),
toMonth: dayjs().format('YYYYMM'),
pageSize: 200,
})}
/>
{isError && (
<Alert type="warning" showIcon
message="API 미응답"
description="/api/kpi/retention 를 확인하세요."
style={{ marginBottom: 16 }}
/>
)}
{/* 카드 2개 */}
<Row gutter={20} style={{ marginBottom: 20 }}>
<Col span={12}>
<AvgCard label="평균 13M 유지율" value={avg13m} color={COLOR_PRIMARY} bg={COLOR_PRIMARY} />
</Col>
<Col span={12}>
<AvgCard label="평균 25M 유지율" value={avg25m} color={COLOR_SUCCESS} bg={COLOR_SUCCESS} />
</Col>
</Row>
{/* LineChart */}
<ProCard
title={<span style={{ fontSize: 15, fontWeight: 700, color: GRAY[800] }}> </span>}
subTitle={<span style={{ fontSize: 13, color: GRAY[400] }}> 12 </span>}
headerBordered
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
marginBottom: 20,
}}
>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={chartData} margin={{ top: 8, right: 24, left: -8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={GRAY[100]} vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false} />
<YAxis
tick={{ fontSize: 12, fill: GRAY[500] }} axisLine={false} tickLine={false}
tickFormatter={(v) => `${v}%`} domain={[0, 100]}
/>
<Tooltip
contentStyle={{ borderRadius: 12, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.md, fontSize: 13 }}
formatter={(v: number) => [`${v}%`, '']}
/>
<Legend wrapperStyle={{ fontSize: 12, paddingTop: 8 }} />
<Line
type="monotone" dataKey="13M유지율"
stroke={COLOR_PRIMARY} strokeWidth={2.5}
dot={{ fill: COLOR_PRIMARY, r: 3.5 }} activeDot={{ r: 6 }}
/>
<Line
type="monotone" dataKey="25M유지율"
stroke={COLOR_SUCCESS} strokeWidth={2.5}
dot={{ fill: COLOR_SUCCESS, r: 3.5 }} activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</ProCard>
{/* AG Grid */}
<ProCard
style={{
background: '#ffffff', borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm,
}}
>
<DataGrid<RetentionRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={480}
pinnedBottomRow={pinnedRow}
/>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,142 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, Switch, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import { withdrawApi, WithdrawRequestRow, WithdrawRequestSaveReq, WithdrawSearchParam } from '@/api/withdraw';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const { Text } = Typography;
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const COLUMNS: ColDef<WithdrawRequestRow>[] = [
{ field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' },
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{ field: 'requestAmount', headerName: '신청금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'bankCode', headerName: '은행', width: 100 },
{ field: 'accountNo', headerName: '계좌번호', width: 160 },
{ field: 'accountHolder', headerName: '예금주', width: 110 },
{
field: 'status', headerName: '출금 상태', width: 120,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="WITHDRAW_STATUS" value={p.value} />,
},
{
field: 'approvalStatus', headerName: '결재 상태', width: 110,
cellRenderer: (p: { value?: string }) =>
p.value ? <CodeBadge groupCode="APPROVAL_STATUS" value={p.value} /> : <Text style={{ color: GRAY[400], fontSize: 12 }}>-</Text>,
},
{ field: 'requestedAt', headerName: '신청일', width: 120 },
{ field: 'completedAt', headerName: '완료일', width: 120 },
];
export default function WithdrawRequest() {
const qc = useQueryClient();
const [viewAll, setViewAll] = useState(false);
const [params, setParams] = useState<WithdrawSearchParam>({
settleMonth: dayjs().format('YYYYMM'),
pageSize: 100,
});
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['withdraw-requests', params, viewAll],
queryFn: () => withdrawApi.list({ ...params, viewAll }),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
requestAmount: rows.reduce((s, r) => s + (r.requestAmount ?? 0), 0),
} as Partial<WithdrawRequestRow> : undefined;
const handleCreate = async () => {
const values = await form.validateFields() as WithdrawRequestSaveReq;
try {
await withdrawApi.create(values);
message.success('출금 신청 완료 — 결재 요청이 자동 생성됩니다');
qc.invalidateQueries({ queryKey: ['withdraw-requests'] });
setCreateOpen(false);
form.resetFields();
} catch { /* handled */ }
};
return (
<PageContainer
title="출금 신청"
description="정산 출금 신청, 결재 진행, 펌뱅킹 상태 조회"
extra={
<Space>
<Space align="center">
<Text style={{ fontSize: 13, color: GRAY[500] }}> </Text>
<Switch checked={viewAll} onChange={setViewAll} size="small" />
</Space>
<PermissionButton menuCode="WITHDRAW_REQUESTS" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
</Space>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/withdraw-requests 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{ type: 'code', name: 'status', label: '상태', groupCode: 'WITHDRAW_STATUS', span: 6 },
]}
onSearch={(v) => setParams({ ...v as WithdrawSearchParam, pageSize: 100 })}
onReset={() => setParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 100 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<WithdrawRequestRow>
rows={rows}
columns={COLUMNS}
loading={isLoading}
height={600}
rowKey="withdrawId"
pinnedBottomRow={pinnedRow}
/>
</ProCard>
<Modal
title="출금 신청"
open={createOpen}
onOk={handleCreate}
onCancel={() => setCreateOpen(false)}
okText="신청"
cancelText="취소"
width={480}
>
<Form form={form} layout="vertical">
<Form.Item name="settleMonth" label="정산월" rules={[{ required: true }]} initialValue={dayjs().format('YYYYMM')}>
<Input placeholder="YYYYMM" />
</Form.Item>
<Form.Item name="requestAmount" label="신청금액" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} step={1000} formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} />
</Form.Item>
<Form.Item name="bankCode" label="은행코드" rules={[{ required: true }]}>
<Input placeholder="004 (국민) / 020 (우리) / ..." />
</Form.Item>
<Form.Item name="accountNo" label="계좌번호" rules={[{ required: true }]}>
<Input placeholder="'-' 없이 입력" />
</Form.Item>
<Form.Item name="accountHolder" label="예금주명" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,103 @@
import { Alert } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import api, { unwrap } from '@/api/request';
interface RiskRow {
agentId: number;
agentName: string;
orgName: string;
policyNo: string;
companyName: string;
recruitMonth: string;
monthsElapsed: number;
recruitAmount: number;
chargebackRate: number;
estimatedChargeback: number;
riskLevel: 'HIGH' | 'MEDIUM' | 'LOW';
}
function fetchRisk() {
return unwrap<RiskRow[]>(api.get('/api/report/chargeback-risk'));
}
const riskColor: Record<string, string> = {
HIGH: '#f5222d',
MEDIUM: '#fa8c16',
LOW: '#52c41a',
};
const riskLabel: Record<string, string> = {
HIGH: '고위험',
MEDIUM: '중위험',
LOW: '저위험',
};
const columns: ProColumns<RiskRow>[] = [
{ title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left' },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '증권번호', dataIndex: 'policyNo', width: 130 },
{ title: '보험사', dataIndex: 'companyName', width: 110 },
{ title: '모집월', dataIndex: 'recruitMonth', width: 90 },
{ title: '경과월', dataIndex: 'monthsElapsed', width: 80, align: 'right', render: (_, r) => `${r.monthsElapsed}개월` },
{
title: '모집수수료', dataIndex: 'recruitAmount', width: 120, align: 'right',
render: (_, r) => <MoneyText value={r.recruitAmount} />,
},
{
title: '환수율(%)', dataIndex: 'chargebackRate', width: 90, align: 'right',
render: (_, r) => `${r.chargebackRate}%`,
},
{
title: '예상환수액', dataIndex: 'estimatedChargeback', width: 130, align: 'right',
render: (_, r) => <MoneyText value={r.estimatedChargeback} />,
},
{
title: '위험등급', dataIndex: 'riskLevel', width: 90, align: 'center', fixed: 'right',
render: (_, r) => (
<span style={{ color: riskColor[r.riskLevel], fontWeight: 600 }}>
{riskLabel[r.riskLevel] ?? r.riskLevel}
</span>
),
},
];
export default function ChargebackRiskReport() {
const { data, isLoading, isError } = useQuery({
queryKey: ['report', 'chargeback-risk'],
queryFn: fetchRisk,
retry: false,
});
return (
<PageContainer
title="환수위험 리포트"
description="경과월별 환수 가능성 분석"
extra={<ExcelExportButton url="/api/report/chargeback-risk/export" fileName="환수위험리포트" />}
>
{isError && (
<Alert
type="warning"
showIcon
message="백엔드 미구현 — 곧 추가됩니다"
description="/api/report/chargeback-risk API가 아직 준비되지 않았습니다."
style={{ marginBottom: 16 }}
/>
)}
<ProTable<RiskRow>
rowKey={(r) => `${r.agentId}-${r.policyNo}`}
columns={columns}
dataSource={data ?? []}
loading={isLoading}
search={false}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, reload: false }}
scroll={{ x: 1100 }}
/>
</PageContainer>
);
}
@@ -0,0 +1,98 @@
import { useState } from 'react';
import { Alert, DatePicker } from 'antd';
import { ProCard, ProTable, type ProColumns } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import dayjs, { type Dayjs } from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import api, { unwrap } from '@/api/request';
interface OrgRow {
orgId: number;
orgName: string;
orgType: string;
agentCount: number;
recruitTotal: number;
maintainTotal: number;
netAmount: number;
}
function fetchOrgReport(settleMonth: string) {
return unwrap<OrgRow[]>(api.get('/api/report/org', { params: { settleMonth } }));
}
const columns: ProColumns<OrgRow>[] = [
{ title: '조직명', dataIndex: 'orgName', width: 180, fixed: 'left' },
{ title: '유형', dataIndex: 'orgType', width: 80 },
{ title: '설계사 수', dataIndex: 'agentCount', width: 90, align: 'right' },
{
title: '모집수수료', dataIndex: 'recruitTotal', width: 130, align: 'right',
render: (_, r) => <MoneyText value={r.recruitTotal} />,
},
{
title: '유지수수료', dataIndex: 'maintainTotal', width: 130, align: 'right',
render: (_, r) => <MoneyText value={r.maintainTotal} />,
},
{
title: '실지급 합계', dataIndex: 'netAmount', width: 140, align: 'right',
render: (_, r) => <strong><MoneyText value={r.netAmount} /></strong>,
},
];
export default function OrgReport() {
const [settleMonth, setSettleMonth] = useState<Dayjs>(dayjs());
const monthStr = settleMonth.format('YYYYMM');
const { data, isLoading, isError } = useQuery({
queryKey: ['report', 'org', monthStr],
queryFn: () => fetchOrgReport(monthStr),
retry: false,
});
return (
<PageContainer
title="조직별 리포트"
description="조직 단위 정산 집계"
extra={
<ExcelExportButton
url="/api/report/org/export"
fileName="조직별리포트"
params={{ settleMonth: monthStr }}
/>
}
>
{isError && (
<Alert
type="warning"
showIcon
message="백엔드 미구현 — 곧 추가됩니다"
description="/api/report/org API가 아직 준비되지 않았습니다."
style={{ marginBottom: 16 }}
/>
)}
<ProCard bordered style={{ marginBottom: 16 }}>
<DatePicker
picker="month"
value={settleMonth}
onChange={(d) => d && setSettleMonth(d)}
allowClear={false}
format="YYYY-MM"
/>
</ProCard>
<ProTable<OrgRow>
rowKey="orgId"
columns={columns}
dataSource={data ?? []}
loading={isLoading}
search={false}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, reload: false }}
scroll={{ x: 800 }}
/>
</PageContainer>
);
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { Alert, Col, Empty, Row, Spin } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import api, { unwrap } from '@/api/request';
interface PerfRow {
settleMonth: string;
agentCount: number;
recruitTotal: number;
maintainTotal: number;
netAmount: number;
}
function fetchPerf(year: string) {
return unwrap<PerfRow[]>(api.get('/api/report/performance', { params: { year } }));
}
export default function PerformanceReport() {
const [year] = useState(() => dayjs().format('YYYY'));
const { data, isLoading, isError } = useQuery({
queryKey: ['report', 'performance', year],
queryFn: () => fetchPerf(year),
retry: false,
});
return (
<PageContainer
title="실적 리포트"
description="월별 수수료 집계 — 모집/유지/실지급"
extra={<ExcelExportButton url="/api/report/performance/export" fileName="실적리포트" params={{ year }} />}
>
{isError && (
<Alert
type="warning"
showIcon
message="백엔드 미구현 — 곧 추가됩니다"
description="/api/report/performance API가 아직 준비되지 않았습니다."
style={{ marginBottom: 16 }}
/>
)}
<ProCard bordered style={{ marginBottom: 16 }}>
<Spin spinning={isLoading}>
{data && data.length > 0 ? (
<ResponsiveContainer width="100%" height={340}>
<BarChart data={data} margin={{ top: 16, right: 24, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="settleMonth" />
<YAxis tickFormatter={(v: number | string) => `${(Number(v) / 1_000_000).toFixed(0)}백만`} />
<Tooltip formatter={(v: number | string) => Number(v).toLocaleString()} />
<Legend />
<Bar dataKey="recruitTotal" name="모집수수료" fill="#1677ff" />
<Bar dataKey="maintainTotal" name="유지수수료" fill="#52c41a" />
<Bar dataKey="netAmount" name="실지급" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
) : (
<Empty description={isLoading ? '로딩 중...' : '데이터 없음'} style={{ padding: 40 }} />
)}
</Spin>
</ProCard>
{data && data.length > 0 && (
<ProCard bordered>
<Row gutter={16}>
{data.map((row) => (
<Col key={row.settleMonth} xs={24} sm={12} md={8} lg={6} xl={4}>
<ProCard size="small" bordered style={{ marginBottom: 8 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{row.settleMonth}</div>
<div style={{ fontSize: 12, color: '#666' }}>: {row.agentCount}</div>
<div style={{ fontSize: 12, color: '#1677ff' }}>
: {(row.recruitTotal ?? 0).toLocaleString()}
</div>
<div style={{ fontSize: 12, color: '#52c41a' }}>
: {(row.maintainTotal ?? 0).toLocaleString()}
</div>
<div style={{ fontSize: 12, fontWeight: 600 }}>
: {(row.netAmount ?? 0).toLocaleString()}
</div>
</ProCard>
</Col>
))}
</Row>
</ProCard>
)}
</PageContainer>
);
}
+261
View File
@@ -0,0 +1,261 @@
import { useState } from 'react';
import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Space, Tabs, Tag, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import ApprovalProgress from '@/components/biz/ApprovalProgress';
import {
approvalApi,
ApprovalLineRow, ApprovalRequestRow,
ApprovalRequestSaveReq,
} from '@/api/approval';
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY } from '@/theme/tokens';
const { Text } = Typography;
const { TextArea } = Input;
const LINE_COLS: ColDef<ApprovalLineRow>[] = [
{ field: 'lineName', headerName: '결재선명', flex: 1 },
{ field: 'description', headerName: '설명', flex: 1 },
{ field: 'stepCount', headerName: '단계수', width: 80, type: 'numericColumn' },
{ field: 'status', headerName: '상태', width: 90 },
{ field: 'createdAt', headerName: '생성일', width: 120 },
];
const REQ_COLS: ColDef<ApprovalRequestRow>[] = [
{ field: 'title', headerName: '제목', flex: 1 },
{ field: 'lineName', headerName: '결재선', width: 140 },
{ field: 'requesterName', headerName: '요청자', width: 110 },
{ field: 'currentApproverName', headerName: '현재 결재자', width: 130 },
{
headerName: '진행', width: 120,
cellRenderer: (p: { data: ApprovalRequestRow }) => (
<span style={{ fontSize: 12 }}>{p.data.currentStep} / {p.data.totalSteps} </span>
),
},
{
field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="APPROVAL_STATUS" value={p.value} />,
},
{ field: 'requestedAt', headerName: '요청일', width: 120 },
];
export default function Approvals() {
const qc = useQueryClient();
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<ApprovalRequestRow | null>(null);
const [approveComment, setApproveComment] = useState('');
const [createReqOpen, setCreateReqOpen] = useState(false);
const [form] = Form.useForm();
const { data: linesData, isLoading: linesLoading, isError } = useQuery({
queryKey: ['approval', 'lines'],
queryFn: () => approvalApi.lines(),
retry: false,
});
const { data: myReqData, isLoading: myReqLoading } = useQuery({
queryKey: ['approval', 'mine'],
queryFn: () => approvalApi.myRequests(),
retry: false,
});
const { data: pendingData, isLoading: pendingLoading } = useQuery({
queryKey: ['approval', 'pending'],
queryFn: () => approvalApi.pendingRequests(),
retry: false,
});
const lines = linesData?.list ?? [];
const myRequests = myReqData?.list ?? [];
const pendingRequests = pendingData?.list ?? [];
const openDetail = async (req: ApprovalRequestRow) => {
try {
const detail = await approvalApi.requestDetail(req.requestId);
setSelectedRequest(detail);
setDrawerOpen(true);
} catch { /* handled */ }
};
const handleAdvance = async (approved: boolean) => {
if (!selectedRequest) return;
try {
await approvalApi.advance(selectedRequest.requestId, { approved, comment: approveComment });
message.success(approved ? '승인 완료' : '반려 완료');
qc.invalidateQueries({ queryKey: ['approval'] });
setDrawerOpen(false);
setApproveComment('');
} catch { /* handled */ }
};
const handleCreateReq = async () => {
const values = await form.validateFields() as ApprovalRequestSaveReq;
try {
await approvalApi.requestCreate(values);
message.success('결재 요청 완료');
qc.invalidateQueries({ queryKey: ['approval'] });
setCreateReqOpen(false);
form.resetFields();
} catch { /* handled */ }
};
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer
title="결재 관리"
description="결재선 설정, 결재 요청, 결재 처리"
extra={
<PermissionButton menuCode="APPROVALS" permCode="CREATE" type="primary" onClick={() => setCreateReqOpen(true)}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/approvals 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="lines"
items={[
{
key: 'lines',
label: `결재선 (${lines.length})`,
children: (
<ProCard style={cardStyle}>
<DataGrid<ApprovalLineRow>
rows={lines}
columns={LINE_COLS}
loading={linesLoading}
height={480}
rowKey="lineId"
/>
</ProCard>
),
},
{
key: 'mine',
label: `내 결재 요청 (${myRequests.length})`,
children: (
<ProCard style={cardStyle}>
<DataGrid<ApprovalRequestRow>
rows={myRequests}
columns={REQ_COLS}
loading={myReqLoading}
height={480}
rowKey="requestId"
/>
</ProCard>
),
},
{
key: 'pending',
label: (
<span>
{' '}
{pendingRequests.length > 0 && (
<Tag style={{ background: COLOR_ERROR, color: '#fff', border: 'none', marginLeft: 4, borderRadius: 99 }}>
{pendingRequests.length}
</Tag>
)}
</span>
),
children: (
<ProCard style={cardStyle}>
<DataGrid<ApprovalRequestRow>
rows={pendingRequests}
columns={[
...REQ_COLS,
{
headerName: '상세', width: 80, pinned: 'right',
cellRenderer: (p: { data: ApprovalRequestRow }) => (
<Button type="link" size="small" onClick={() => openDetail(p.data)}></Button>
),
},
]}
loading={pendingLoading}
height={480}
rowKey="requestId"
/>
</ProCard>
),
},
]}
/>
{/* 상세 Drawer */}
<Drawer
title={selectedRequest?.title}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={480}
footer={
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button danger onClick={() => handleAdvance(false)}></Button>
<Button type="primary" onClick={() => handleAdvance(true)}></Button>
</Space>
}
>
{selectedRequest && (
<Space direction="vertical" style={{ width: '100%' }} size={20}>
<div>
<Text style={{ fontSize: 12, color: GRAY[500] }}></Text>
<div style={{ fontSize: 14, color: GRAY[800], marginTop: 4 }}>{selectedRequest.requesterName}</div>
</div>
<div>
<Text style={{ fontSize: 12, color: GRAY[500] }}></Text>
<div style={{ fontSize: 14, color: GRAY[800], marginTop: 4, whiteSpace: 'pre-wrap' }}>{selectedRequest.content}</div>
</div>
{selectedRequest.steps && selectedRequest.steps.length > 0 && (
<div>
<Text style={{ fontSize: 12, color: GRAY[500], display: 'block', marginBottom: 8 }}> </Text>
<ApprovalProgress
steps={selectedRequest.steps}
currentStep={selectedRequest.currentStep}
/>
</div>
)}
<div>
<Text style={{ fontSize: 12, color: GRAY[500], display: 'block', marginBottom: 4 }}></Text>
<TextArea
rows={3}
value={approveComment}
onChange={(e) => setApproveComment(e.target.value)}
placeholder="승인/반려 사유 (선택)"
/>
</div>
</Space>
)}
</Drawer>
{/* 결재 요청 모달 */}
<Modal
title="결재 요청"
open={createReqOpen}
onOk={handleCreateReq}
onCancel={() => setCreateReqOpen(false)}
okText="요청"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="lineId" label="결재선 ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="title" label="제목" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="content" label="내용" rules={[{ required: true }]}>
<TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Alert, Checkbox, Form, Input, Modal, Space, Tag, Typography, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import CodeBadge from '@/components/common/CodeBadge';
import { noticeApi, NoticeRow, NoticeSaveReq, NoticeSearchParam } from '@/api/notice';
import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens';
const { Text } = Typography;
const { TextArea } = Input;
const COLUMNS: ColDef<NoticeRow>[] = [
{
field: 'isPinned', headerName: '고정', width: 70,
cellRenderer: (p: { value: boolean }) =>
p.value ? <Tag color="error" style={{ margin: 0, fontSize: 11 }}></Tag> : null,
},
{
field: 'noticeType', headerName: '유형', width: 100,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="NOTICE_TYPE" value={p.value} />,
},
{ field: 'title', headerName: '제목', flex: 1 },
{ field: 'authorName', headerName: '작성자', width: 110 },
{ field: 'viewCount', headerName: '조회수', width: 80, type: 'numericColumn' },
{ field: 'publishedAt', headerName: '게시일', width: 120 },
{ field: 'expiresAt', headerName: '만료일', width: 120 },
{
field: 'status', headerName: '상태', width: 90,
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="NOTICE_STATUS" value={p.value} />,
},
];
export default function Notices() {
const qc = useQueryClient();
const [params, setParams] = useState<NoticeSearchParam>({ pageSize: 50 });
const [modalOpen, setModalOpen] = useState(false);
const [editingNotice, setEditingNotice] = useState<NoticeRow | null>(null);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['notices', params],
queryFn: () => noticeApi.list(params),
retry: false,
});
const rows = data?.list ?? [];
const openCreate = () => { setEditingNotice(null); form.resetFields(); setModalOpen(true); };
const openEdit = (row: NoticeRow) => { setEditingNotice(row); form.setFieldsValue(row); setModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields() as NoticeSaveReq;
try {
if (editingNotice) {
await noticeApi.update(editingNotice.noticeId, values);
message.success('공지 수정 완료');
} else {
await noticeApi.create(values);
message.success('공지 등록 완료');
}
qc.invalidateQueries({ queryKey: ['notices'] });
setModalOpen(false);
} catch { /* handled */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '공지 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await noticeApi.delete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['notices'] });
},
});
return (
<PageContainer
title="공지사항 관리"
description="시스템 공지사항 등록 및 관리"
extra={
<PermissionButton menuCode="NOTICES" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
}
>
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/notices 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'title', label: '제목', span: 8 },
{ type: 'code', name: 'noticeType', label: '유형', groupCode: 'NOTICE_TYPE', span: 6 },
]}
onSearch={(v) => setParams({ ...v as NoticeSearchParam, pageSize: 50 })}
onReset={() => setParams({ pageSize: 50 })}
/>
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
<DataGrid<NoticeRow>
rows={rows}
columns={[
...COLUMNS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: NoticeRow }) => (
<Space>
<PermissionButton menuCode="NOTICES" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="NOTICES" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.noticeId)}></PermissionButton>
</Space>
),
},
]}
loading={isLoading}
height={600}
rowKey="noticeId"
/>
</ProCard>
<Modal
title={editingNotice ? '공지 수정' : '공지 등록'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
okText="저장"
cancelText="취소"
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="제목" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="noticeType" label="유형" rules={[{ required: true }]}>
<Input placeholder="GENERAL / IMPORTANT / SYSTEM" />
</Form.Item>
<Form.Item name="content" label="내용" rules={[{ required: true }]}>
<TextArea rows={6} />
</Form.Item>
<Form.Item name="isPinned" label="고정 여부" valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name="publishedAt" label="게시일">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="expiresAt" label="만료일">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}