동기화
This commit is contained in:
+414
-120
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user