feat(frontend): Ant Design Pro 패턴 전면 적용

@ant-design/pro-components ^2.8.10 + @ant-design/icons ^6.2 추가.

ProLayout (MainLayout):
- mix 레이아웃 (사이드바 + 헤더 통합), navTheme=light
- 자체 메뉴 트리 렌더링 (children 트리), 동적 메뉴 매핑
- 헤더 actionsRender: 정산월 MonthPicker / 알림 뱃지
- avatarProps: 그라디언트 아바타 + Dropdown 로그아웃
- token: 사이드바/헤더 컬러, pageContainer padding
- footerRender: 버전 표기

PageContainer (Pro):
- @ant-design/pro-layout 의 PageContainer 사용
- 자동 Breadcrumb / Title / extra 슬롯

ProTable 마이그레이션 (6 페이지):
- AgentList: search + valueEnum, request → PageHelper 변환
- ContractList, RecruitLedger, ExceptionLedger, SettleList, PaymentList, UserList
- 검색 폼 자동 생성 (valueType: select/dateMonth 등)
- toolBarRender: 등록/엑셀 버튼
- options: density/fullScreen/reload/setting

Dashboard:
- ProCard + StatisticCard 로 KPI 4종 그리드
- 차트 + 배치 진행 + 알림을 ProCard 로 묶음

검증:
- tsc -b 통과
- vite build 성공 (2.6MB / gzip 811KB)
- 백엔드 76건 단위테스트 영향 없음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-10 22:20:33 +09:00
parent e70589fda8
commit 020939e563
12 changed files with 1387 additions and 797 deletions
+122 -137
View File
@@ -1,28 +1,27 @@
import { Card, Col, Row, Typography } from 'antd';
import {
IconCash, IconCheck, IconClock, IconAlertTriangle,
IconTrendingUp, IconTrendingDown,
} from '@tabler/icons-react';
import { ProCard, StatisticCard } from '@ant-design/pro-components';
import { Tag } from 'antd';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Legend,
} from 'recharts';
import {
IconCash, IconCheck, IconClock, IconAlertTriangle, IconTrendingUp,
} from '@tabler/icons-react';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
const { Text } = Typography;
const { Statistic } = StatisticCard;
const KPI_CARDS = [
{ key: 'recruit', label: '모집수수료', value: 124_500_000, delta: +8.2,
icon: <IconCash size={20} />, color: '#1677FF', bg: '#E6F1FB' },
{ key: 'maintain', label: '유지수수료', value: 82_300_000, delta: +3.1,
icon: <IconCheck size={20} />, color: '#0F6E56', bg: '#E1F5EE' },
{ key: 'pending', label: '확정 대기', value: 12, delta: -2.0, suffix: '건',
icon: <IconClock size={20} />, color: '#BA7517', bg: '#FAEEDA' },
{ key: 'alert', label: '대사 차이', value: 3, delta: +50.0, suffix: '건',
icon: <IconAlertTriangle size={20} />, color: '#E24B4A', bg: '#FCEBEB' },
const 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건' },
];
const MONTHLY_TREND = [
const MONTHLY = [
{ month: '12월', recruit: 95, maintain: 70, chargeback: 5 },
{ month: '1월', recruit: 105, maintain: 72, chargeback: 8 },
{ month: '2월', recruit: 110, maintain: 75, chargeback: 6 },
@@ -31,7 +30,7 @@ const MONTHLY_TREND = [
{ month: '5월', recruit: 125, maintain: 82, chargeback: 4 },
];
const BATCH_STEPS = [
const STEPS = [
{ name: '데이터 수신', status: 'done', time: '02:14' },
{ name: '데이터 변환', status: 'done', time: '00:52' },
{ name: '대사 검증', status: 'done', time: '00:21' },
@@ -46,143 +45,129 @@ export default function Dashboard() {
return (
<PageContainer
title="대시보드"
description="이번 달 (2026-05) 정산 요약 / 실시간 배치 진행 / 알림"
description="2026-05 정산 요약 / 실시간 배치 진행 / 알림"
>
{/* KPI 카드 4개 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
{KPI_CARDS.map((c) => {
const isUp = c.delta >= 0;
return (
<Col span={6} key={c.key}>
<Card
bordered={false}
{/* KPI */}
<ProCard
gutter={16}
ghost
style={{ marginBottom: 16 }}
wrap
>
{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={{
boxShadow: 'var(--ga-shadow-card)',
borderTop: `3px solid ${c.color}`,
width: 44, height: 44, borderRadius: 10,
background: k.bg, color: k.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{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>
))}
</ProCard>
{/* 차트 + 배치 진행 */}
<ProCard gutter={16} ghost wrap style={{ marginBottom: 16 }}>
<ProCard
colSpan={16}
title="월별 추이 (최근 6개월)"
headerBordered
bordered={false}
style={{ minHeight: 320 }}
>
<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]} />
</BarChart>
</ResponsiveContainer>
</ProCard>
<ProCard
colSpan={8}
title="배치 진행 (5월)"
headerBordered
bordered={false}
>
{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: 44, height: 44, borderRadius: 10,
background: c.bg, color: c.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{c.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: '#888' }}>{c.label}</div>
<div style={{ fontSize: 20, fontWeight: 600, color: '#222', marginTop: 2 }}>
{c.suffix === '건' ? c.value.toLocaleString() : (
<MoneyText value={c.value} />
)}
<span style={{ fontSize: 12, color: '#888', marginLeft: 4, fontWeight: 400 }}>
{c.suffix ?? '원'}
</span>
</div>
<div
style={{
fontSize: 11, marginTop: 4,
color: isUp ? '#0F6E56' : '#E24B4A',
display: 'flex', alignItems: 'center', gap: 2,
}}
>
{isUp ? <IconTrendingUp size={12} /> : <IconTrendingDown size={12} />}
{Math.abs(c.delta).toFixed(1)}%
</div>
</div>
</div>
</Card>
</Col>
);
})}
</Row>
{/* 차트 + 배치 현황 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={16}>
<Card
title={<span style={{ fontWeight: 600 }}> ( 6)</span>}
bordered={false}
style={{ boxShadow: 'var(--ga-shadow-card)' }}
styles={{ body: { padding: 16, height: 280 } }}
>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={MONTHLY_TREND}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 12, fill: '#666' }} />
<YAxis tick={{ fontSize: 12, fill: '#666' }}
label={{ value: '백만원', angle: 0, position: 'top', fontSize: 11, fill: '#999' }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="recruit" name="모집" fill="#1677FF" radius={[4, 4, 0, 0]} />
<Bar dataKey="maintain" name="유지" fill="#0F6E56" radius={[4, 4, 0, 0]} />
<Bar dataKey="chargeback" name="환수" fill="#E24B4A" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</Card>
</Col>
<Col span={8}>
<Card
title={<span style={{ fontWeight: 600 }}> (5)</span>}
bordered={false}
style={{ boxShadow: 'var(--ga-shadow-card)' }}
styles={{ body: { padding: 16 } }}
>
{BATCH_STEPS.map((s, i) => {
const dotColor = s.status === 'done' ? '#0F6E56' :
s.status === 'progress' ? '#1677FF' : '#ddd';
return (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 0', borderBottom: i < BATCH_STEPS.length - 1 ? '1px dashed #f5f5f5' : 'none',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 8, height: 8, borderRadius: '50%', background: dotColor,
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>
<span style={{ fontSize: 11, color: '#999' }}>{s.time ?? '-'}</span>
}}
/>
<span style={{ fontSize: 13, color: s.status === 'pending' ? '#bbb' : '#333' }}>
Step {i + 1}. {s.name}
</span>
</div>
);
})}
</Card>
</Col>
</Row>
<span style={{ fontSize: 11, color: '#999' }}>{s.time ?? '-'}</span>
</div>
);
})}
</ProCard>
</ProCard>
{/* 알림 */}
<Card
title={<span style={{ fontWeight: 600 }}> </span>}
bordered={false}
style={{ boxShadow: 'var(--ga-shadow-card)' }}
styles={{ body: { padding: 0 } }}
>
<ProCard title="최근 알림" headerBordered bordered={false}>
{[
{ tag: '대사불일치', tagColor: '#E24B4A', tagBg: '#FCEBEB',
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이', time: '10분 전' },
{ tag: '파싱에러', tagColor: '#BA7517', tagBg: '#FAEEDA',
{ tag: '대사불일치', color: 'error',
text: '삼성생명 5월 통보액 vs 시스템 계산액 3건 차이', time: '10분 전' },
{ tag: '파싱에러', color: 'warning',
text: '한화생명 raw 파일 12행 날짜 형식 오류 (parse_error_log #4521)', time: '1시간 전' },
{ tag: '승인대기', tagColor: '#1677FF', tagBg: '#E6F1FB',
{ tag: '승인대기', color: 'processing',
text: '예외금액 원장 승인 대기 5건 (정산월 202605)', time: '3시간 전' },
].map((n, i) => (
].map((n, i, arr) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
borderBottom: i < 2 ? '1px solid #f5f5f5' : 'none',
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
borderBottom: i < arr.length - 1 ? '1px solid #f5f5f5' : 'none',
}}>
<span style={{
fontSize: 11, fontWeight: 500, padding: '2px 8px', borderRadius: 4,
color: n.tagColor, background: n.tagBg,
}}>{n.tag}</span>
<Text style={{ flex: 1, fontSize: 13 }}>{n.text}</Text>
<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>
</div>
))}
</Card>
</ProCard>
</PageContainer>
);
}
@@ -1,26 +1,16 @@
import { useState } from 'react';
import { Table, Space, message, Modal } from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef } from 'react';
import { Modal, Space, message } from 'antd';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import { defaultPagination } from '@/utils/tablePagination';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { exceptionApi, ExceptionLedgerResp } from '@/api/exceptionLedger';
export default function ExceptionLedger() {
const qc = useQueryClient();
const [param, setParam] = useState<{ settleMonth?: string; status?: string; pageNum: number; pageSize: number }>({
pageNum: 1, pageSize: 30,
});
const { data, isLoading } = useQuery({
queryKey: ['ledger', 'exception', param],
queryFn: () => exceptionApi.list(param),
});
const refresh = () => qc.invalidateQueries({ queryKey: ['ledger', 'exception'] });
const actionRef = useRef<ActionType>();
const { data: approveCodes = [] } = useCommonCodes('APPROVE_STATUS');
const onApprove = (id: number, status: 'APPROVED' | 'REJECTED') =>
Modal.confirm({
@@ -28,71 +18,67 @@ export default function ExceptionLedger() {
onOk: async () => {
await exceptionApi.approve(id, status);
message.success(status === 'APPROVED' ? '승인 완료' : '반려 완료');
refresh();
actionRef.current?.reload();
},
});
return (
<PageContainer title="예외금액 원장">
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'status', label: '승인상태', groupCode: 'APPROVE_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
const columns: ProColumns<ExceptionLedgerResp>[] = [
{ title: '정산월', dataIndex: 'settleMonth', width: 90, valueType: 'dateMonth', fieldProps: { format: 'YYYYMM' } },
{
title: '승인상태', dataIndex: 'approveStatus', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(approveCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="APPROVE_STATUS" value={r.approveStatus} />,
},
{ title: '설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
{ title: '예외코드', dataIndex: 'exceptionName', width: 120, search: false },
{
title: '구분', dataIndex: 'direction', width: 80, search: false,
render: (_, r) => <CodeBadge groupCode="EXCEPTION_DIRECTION" value={r.direction} />,
},
{
title: '금액', dataIndex: 'amount', width: 130, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.direction === 'MINUS' ? -Math.abs(r.amount) : r.amount} />,
},
{ title: '내용', dataIndex: 'description', ellipsis: true, search: false },
{
title: '재반복', dataIndex: 'recurringLeft', width: 90, align: 'center', search: false,
render: (_, r) => r.recurringYn === 'Y' ? `${r.recurringLeft}회 남음` : '-',
},
{ title: '승인자', dataIndex: 'approverName', width: 100, search: false },
{ title: '승인일시', dataIndex: 'approveDate', width: 160, search: false },
{
title: '액션', dataIndex: 'ledgerId', width: 180, fixed: 'right', search: false,
render: (_, r) => r.approveStatus === 'PENDING' ? (
<Space>
<PermissionButton menuCode="LEDGER_EXCEPTION" permCode="APPROVE" size="small"
type="primary" onClick={() => onApprove(r.ledgerId, 'APPROVED')}></PermissionButton>
<PermissionButton menuCode="LEDGER_EXCEPTION" permCode="APPROVE" size="small"
danger onClick={() => onApprove(r.ledgerId, 'REJECTED')}></PermissionButton>
</Space>
) : null,
},
];
<Table<ExceptionLedgerResp>
return (
<PageContainer title="예외금액 원장" description="수동 등록 예외 금액 / 승인 워크플로우">
<ProTable<ExceptionLedgerResp>
actionRef={actionRef}
rowKey="ledgerId"
loading={isLoading}
dataSource={data?.list}
size="small"
scroll={{ x: 1400 }}
pagination={{
...defaultPagination,
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
columns={columns}
scroll={{ x: 1500 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await exceptionApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90 },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '예외코드', dataIndex: 'exceptionName', width: 120 },
{
title: '구분', dataIndex: 'direction', width: 80,
render: (v) => <CodeBadge groupCode="EXCEPTION_DIRECTION" value={v} />,
},
{
title: '금액', dataIndex: 'amount', width: 130, align: 'right',
render: (v, r) => <MoneyText value={r.direction === 'MINUS' ? -Math.abs(v) : v} />,
},
{ title: '내용', dataIndex: 'description', ellipsis: true },
{ title: '재반복', dataIndex: 'recurringLeft', width: 90, align: 'center',
render: (v, r) => r.recurringYn === 'Y' ? `${v}회 남음` : '-' },
{
title: '승인상태', dataIndex: 'approveStatus', width: 100,
render: (v) => <CodeBadge groupCode="APPROVE_STATUS" value={v} />,
},
{ title: '승인자', dataIndex: 'approverName', width: 100 },
{ title: '승인일시', dataIndex: 'approveDate', width: 160 },
{
title: '액션', dataIndex: 'ledgerId', width: 180, fixed: 'right',
render: (id, r) =>
r.approveStatus === 'PENDING' ? (
<Space>
<PermissionButton menuCode="LEDGER_EXCEPTION" permCode="APPROVE" size="small"
type="primary" onClick={() => onApprove(id, 'APPROVED')}>
</PermissionButton>
<PermissionButton menuCode="LEDGER_EXCEPTION" permCode="APPROVE" size="small"
danger onClick={() => onApprove(id, 'REJECTED')}>
</PermissionButton>
</Space>
) : null,
},
]}
pagination={{
pageSize: 30, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
+67 -59
View File
@@ -1,75 +1,83 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import MoneyText from '@/components/common/MoneyText';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import { defaultPagination } from '@/utils/tablePagination';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { ledgerApi, LedgerResp, LedgerSearchParam } from '@/api/ledger';
export default function RecruitLedger() {
const [param, setParam] = useState<LedgerSearchParam>({ pageNum: 1, pageSize: 50 });
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const { data: reconCodes = [] } = useCommonCodes('RECON_STATUS');
const { data, isLoading } = useQuery({
queryKey: ['ledger', 'recruit', param],
queryFn: () => ledgerApi.recruit(param),
});
const columns: ProColumns<LedgerResp>[] = [
{
title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left',
valueType: 'dateMonth', fieldProps: { format: 'YYYYMM' },
},
{ title: '증권번호', dataIndex: 'policyNo', width: 130, fixed: 'left',
fieldProps: { placeholder: '증권번호' } },
{
title: '보험종류', dataIndex: 'insuranceType', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(insuranceCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="INSURANCE_TYPE" value={r.insuranceType} />,
},
{
title: '대사상태', dataIndex: 'reconcileStatus', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(reconCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="RECON_STATUS" value={r.reconcileStatus} />,
},
{ title: '설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
{ title: '보험사', dataIndex: 'companyName', width: 120, search: false },
{ title: '상품', dataIndex: 'productName', width: 200, search: false },
{
title: '보험료', dataIndex: 'premium', width: 110, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.premium} />,
},
{
title: '회사율', dataIndex: 'companyRate', width: 80, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.companyRate} fraction={2} />,
},
{
title: '회사수수료', dataIndex: 'companyAmount', width: 120, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.companyAmount} />,
},
{
title: '지급율', dataIndex: 'payoutRate', width: 80, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.payoutRate} fraction={2} />,
},
{
title: '설계사수수료', dataIndex: 'agentAmount', width: 120, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.agentAmount} />,
},
{
title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.taxAmount} />,
},
];
return (
<PageContainer
title="모집수수료 원장"
extra={
<ExcelExportButton url="/api/ledger/recruit/export" params={param} fileName="모집수수료원장" />
}
>
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'text', name: 'policyNo', label: '증권번호' },
{ type: 'code', name: 'insuranceType', label: '보험종류', groupCode: 'INSURANCE_TYPE' },
{ type: 'code', name: 'reconcileStatus', label: '대사상태', groupCode: 'RECON_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 50 })}
/>
<Table<LedgerResp>
<PageContainer title="모집수수료 원장" description="신규 계약 모집에 따른 수수료 원장">
<ProTable<LedgerResp, LedgerSearchParam>
rowKey="ledgerId"
loading={isLoading}
dataSource={data?.list}
size="small"
columns={columns}
scroll={{ x: 1500 }}
pagination={{
...defaultPagination,
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await ledgerApi.recruit({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' },
{ title: '증권번호', dataIndex: 'policyNo', width: 130, fixed: 'left' },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '보험사', dataIndex: 'companyName', width: 120 },
{ title: '상품', dataIndex: 'productName', width: 200 },
{ title: '보험종류', dataIndex: 'insuranceType', width: 100,
render: (v) => <CodeBadge groupCode="INSURANCE_TYPE" value={v} /> },
{ title: '보험료', dataIndex: 'premium', width: 110, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '회사율', dataIndex: 'companyRate', width: 80, align: 'right',
render: (v) => <MoneyText value={v} fraction={2} /> },
{ title: '회사수수료', dataIndex: 'companyAmount', width: 120, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '지급율', dataIndex: 'payoutRate', width: 80, align: 'right',
render: (v) => <MoneyText value={v} fraction={2} /> },
{ title: '설계사수수료', dataIndex: 'agentAmount', width: 120, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '대사상태', dataIndex: 'reconcileStatus', width: 100,
render: (v) => <CodeBadge groupCode="RECON_STATUS" value={v} /> },
pagination={{
pageSize: 50, showSizeChanger: true, pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<ExcelExportButton key="export" url="/api/ledger/recruit/export" fileName="모집수수료원장" />,
]}
dateFormatter="string"
/>
</PageContainer>
);
+71 -58
View File
@@ -1,79 +1,92 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { Space } from 'antd';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import { useNavigate } from 'react-router-dom';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import { defaultPagination } from '@/utils/tablePagination';
import PageContainer from '@/components/common/PageContainer';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { agentApi, AgentResp, AgentSearchParam } from '@/api/agent';
export default function AgentList() {
const navigate = useNavigate();
const [param, setParam] = useState<AgentSearchParam>({ pageNum: 1, pageSize: 20 });
const actionRef = useRef<ActionType>();
const { data: statusCodes = [] } = useCommonCodes('AGENT_STATUS');
const { data, isLoading } = useQuery({
queryKey: ['agent', 'list', param],
queryFn: () => agentApi.list(param),
});
const columns: ProColumns<AgentResp>[] = [
{
title: '설계사명', dataIndex: 'agentName', width: 120, fixed: 'left',
render: (_, r) => (
<a onClick={() => navigate(`/org/agents/${r.agentId}`)} style={{ fontWeight: 500 }}>
{r.agentName}
</a>
),
},
{ title: '사번', dataIndex: 'licenseNo', width: 120, search: false },
{ title: '소속', dataIndex: 'orgName', width: 200, search: false },
{ title: '직급', dataIndex: 'gradeName', width: 100, search: false },
{ title: '전화번호', dataIndex: 'phone', width: 140, search: false },
{
title: '상태', dataIndex: 'status', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(statusCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="AGENT_STATUS" value={r.status} />,
},
{
title: '계약수', dataIndex: 'contractCount', width: 90, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.contractCount} />,
},
{
title: '누적수수료', dataIndex: 'totalCommission', width: 130, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.totalCommission} />,
},
{ title: '입사일', dataIndex: 'joinDate', width: 110, search: false },
{
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '설계사명' },
},
];
return (
<PageContainer
title="설계사 관리"
extra={
<div>
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('/org/agents/new')} style={{ marginRight: 8 }}>
</PermissionButton>
<ExcelExportButton url="/api/agents/export" params={param} fileName="설계사목록" />
</div>
}
description="조직별 설계사 등록·관리, 누적 수수료 추적"
>
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 20 })}
/>
<Table<AgentResp>
<ProTable<AgentResp, AgentSearchParam>
actionRef={actionRef}
rowKey="agentId"
loading={isLoading}
dataSource={data?.list}
pagination={{
...defaultPagination,
current: param.pageNum,
pageSize: param.pageSize,
total: data?.total,
onChange: (page, size) => setParam({ ...param, pageNum: page, pageSize: size }),
columns={columns}
scroll={{ x: 1200 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await agentApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
columns={[
{ title: '설계사명', dataIndex: 'agentName', width: 120 },
{ title: '사번', dataIndex: 'licenseNo', width: 120 },
{ title: '소속', dataIndex: 'orgName', width: 200 },
{ title: '직급', dataIndex: 'gradeName', width: 100 },
{ title: '전화번호', dataIndex: 'phone', width: 140 },
{
title: '상태', dataIndex: 'status', width: 100,
render: (v) => <CodeBadge groupCode="AGENT_STATUS" value={v} />,
},
{
title: '계약수', dataIndex: 'contractCount', align: 'right', width: 90,
render: (v) => <MoneyText value={v} />,
},
{
title: '누적수수료', dataIndex: 'totalCommission', align: 'right', width: 130,
render: (v) => <MoneyText value={v} />,
},
{ title: '입사일', dataIndex: 'joinDate', width: 110 },
pagination={{
pageSize: 20,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{
labelWidth: 'auto',
searchText: '검색',
resetText: '초기화',
collapsed: false,
collapseRender: false,
}}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<ExcelExportButton key="export" url="/api/agents/export" fileName="설계사목록" />,
<PermissionButton
key="create"
menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('/org/agents/new')}
>+ </PermissionButton>,
]}
dateFormatter="string"
headerTitle={<Space size={6}><span style={{ fontWeight: 600 }}> </span></Space>}
/>
</PageContainer>
);
+51 -53
View File
@@ -1,67 +1,65 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import { defaultPagination } from '@/utils/tablePagination';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { contractApi, ContractResp, ContractSearchParam } from '@/api/contract';
export default function ContractList() {
const [param, setParam] = useState<ContractSearchParam>({ pageNum: 1, pageSize: 20 });
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS');
const { data, isLoading } = useQuery({
queryKey: ['contract', 'list', param],
queryFn: () => contractApi.list(param),
});
const columns: ProColumns<ContractResp>[] = [
{
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '계약자/증권번호' },
},
{
title: '보험종류', dataIndex: 'insuranceType', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(insuranceCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="INSURANCE_TYPE" value={r.insuranceType} />,
},
{
title: '상태', dataIndex: 'status', width: 90, valueType: 'select',
valueEnum: Object.fromEntries(statusCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="CONTRACT_STATUS" value={r.status} />,
},
{ title: '증권번호', dataIndex: 'policyNo', width: 140, search: false },
{ title: '계약자', dataIndex: 'contractorName', width: 100, search: false },
{ title: '설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '보험사', dataIndex: 'companyName', width: 120, search: false },
{ title: '상품', dataIndex: 'productName', width: 220, search: false },
{
title: '보험료', dataIndex: 'premium', width: 120, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.premium} />,
},
{
title: '납입주기', dataIndex: 'payCycle', width: 90, search: false,
render: (_, r) => <CodeBadge groupCode="PAY_CYCLE" value={r.payCycle} />,
},
{ title: '계약일', dataIndex: 'contractDate', width: 110, search: false },
];
return (
<PageContainer title="계약 관리">
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '계약자/증권번호' },
{ type: 'code', name: 'insuranceType', label: '보험종류', groupCode: 'INSURANCE_TYPE' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'CONTRACT_STATUS' },
{ type: 'dateRange', name: 'dateRange', label: '계약일' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 20 })}
/>
<Table<ContractResp>
<PageContainer title="계약 관리" description="보험사·상품·계약 통합 관리">
<ProTable<ContractResp, ContractSearchParam>
rowKey="contractId"
loading={isLoading}
dataSource={data?.list}
pagination={{
...defaultPagination,
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
columns={columns}
scroll={{ x: 1300 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await contractApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
columns={[
{ title: '증권번호', dataIndex: 'policyNo', width: 140 },
{ title: '계약자', dataIndex: 'contractorName', width: 100 },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '보험사', dataIndex: 'companyName', width: 120 },
{ title: '상품', dataIndex: 'productName', width: 220 },
{
title: '보험종류', dataIndex: 'insuranceType', width: 100,
render: (v) => <CodeBadge groupCode="INSURANCE_TYPE" value={v} />,
},
{
title: '보험료', dataIndex: 'premium', width: 120, align: 'right',
render: (v) => <MoneyText value={v} />,
},
{
title: '납입주기', dataIndex: 'payCycle', width: 90,
render: (v) => <CodeBadge groupCode="PAY_CYCLE" value={v} />,
},
{
title: '상태', dataIndex: 'status', width: 90,
render: (v) => <CodeBadge groupCode="CONTRACT_STATUS" value={v} />,
},
{ title: '계약일', dataIndex: 'contractDate', width: 110 },
]}
pagination={{
pageSize: 20,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
+38 -46
View File
@@ -1,60 +1,52 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import { defaultPagination } from '@/utils/tablePagination';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { paymentApi, PaymentResp } from '@/api/payment';
export default function PaymentList() {
const [param, setParam] = useState<{ settleMonth?: string; status?: string; pageNum: number; pageSize: number }>({
pageNum: 1, pageSize: 30,
});
const { data: statusCodes = [] } = useCommonCodes('PAYMENT_STATUS');
const { data, isLoading } = useQuery({
queryKey: ['payment', 'list', param],
queryFn: () => paymentApi.list(param),
});
const columns: ProColumns<PaymentResp>[] = [
{
title: '정산월', dataIndex: 'settleMonth', width: 90,
valueType: 'dateMonth', fieldProps: { format: 'YYYYMM' },
},
{
title: '지급상태', dataIndex: 'payStatus', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(statusCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="PAYMENT_STATUS" value={r.payStatus} />,
},
{ title: '설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '은행', dataIndex: 'bankName', width: 100, search: false },
{ title: '계좌', dataIndex: 'accountNo', width: 180, search: false },
{
title: '지급액', dataIndex: 'payAmount', width: 140, align: 'right', search: false,
render: (_, r) => <strong><MoneyText value={r.payAmount} /></strong>,
},
{ title: '지급일', dataIndex: 'payDate', width: 110, search: false },
{ title: '실패사유', dataIndex: 'failReason', ellipsis: true, search: false },
];
return (
<PageContainer title="지급 관리">
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'status', label: '지급상태', groupCode: 'PAYMENT_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
<Table<PaymentResp>
<PageContainer title="지급 관리" description="확정 정산 → 이체 파일 생성 / 결과 추적">
<ProTable<PaymentResp>
rowKey="paymentId"
loading={isLoading}
dataSource={data?.list}
size="small"
pagination={{
...defaultPagination,
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
columns={columns}
scroll={{ x: 1100 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await paymentApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90 },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '은행', dataIndex: 'bankName', width: 100 },
{ title: '계좌', dataIndex: 'accountNo', width: 180 },
{
title: '지급액', dataIndex: 'payAmount', width: 140, align: 'right',
render: (v) => <strong><MoneyText value={v} /></strong>,
},
{ title: '지급일', dataIndex: 'payDate', width: 110 },
{
title: '상태', dataIndex: 'payStatus', width: 100,
render: (v) => <CodeBadge groupCode="PAYMENT_STATUS" value={v} />,
},
{ title: '실패사유', dataIndex: 'failReason', ellipsis: true },
]}
pagination={{
pageSize: 30, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
+87 -75
View File
@@ -1,30 +1,32 @@
import { useState } from 'react';
import { Card, Col, Row, Statistic, Table, message, Modal, Input, Space } from 'antd';
import { useRef, useState } from 'react';
import { Input, Modal, Space, message } from 'antd';
import { ProCard, ProTable, StatisticCard, type ActionType, type ProColumns } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import { defaultPagination } from '@/utils/tablePagination';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { settleApi, SettleResp, SettleSearchParam } from '@/api/settle';
const { Statistic } = StatisticCard;
export default function SettleList() {
const qc = useQueryClient();
const [param, setParam] = useState<SettleSearchParam>({ pageNum: 1, pageSize: 30 });
const { data, isLoading } = useQuery({
queryKey: ['settle', 'list', param],
queryFn: () => settleApi.list(param),
});
const actionRef = useRef<ActionType>();
const [settleMonth, setSettleMonth] = useState<string | undefined>(undefined);
const { data: statusCodes = [] } = useCommonCodes('SETTLE_STATUS');
const { data: summary } = useQuery({
queryKey: ['settle', 'summary', param.settleMonth],
queryFn: () => settleApi.summary(param.settleMonth!),
enabled: !!param.settleMonth,
queryKey: ['settle', 'summary', settleMonth],
queryFn: () => settleApi.summary(settleMonth!),
enabled: !!settleMonth,
});
const refresh = () => qc.invalidateQueries({ queryKey: ['settle'] });
const refresh = () => {
qc.invalidateQueries({ queryKey: ['settle'] });
actionRef.current?.reload();
};
const onConfirm = (id: number) =>
Modal.confirm({
@@ -41,73 +43,83 @@ export default function SettleList() {
});
};
return (
<PageContainer title="정산 결과">
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'SETTLE_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
const columns: ProColumns<SettleResp>[] = [
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left',
valueType: 'dateMonth', fieldProps: { format: 'YYYYMM' } },
{
title: '상태', dataIndex: 'status', width: 100, valueType: 'select', fixed: 'right',
valueEnum: Object.fromEntries(statusCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="SETTLE_STATUS" value={r.status} />,
},
{ title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left', search: false },
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
{ title: '직급', dataIndex: 'gradeName', width: 80, search: false },
{ title: '모집', dataIndex: 'recruitTotal', width: 110, align: 'right', search: false, render: (_, r) => <MoneyText value={r.recruitTotal} /> },
{ title: '유지', dataIndex: 'maintainTotal', width: 110, align: 'right', search: false, render: (_, r) => <MoneyText value={r.maintainTotal} /> },
{ title: '예외(+)', dataIndex: 'exceptionPlus', width: 100, align: 'right', search: false, render: (_, r) => <MoneyText value={r.exceptionPlus} /> },
{ title: '예외(-)', dataIndex: 'exceptionMinus', width: 100, align: 'right', search: false, render: (_, r) => <MoneyText value={r.exceptionMinus} /> },
{ title: '오버라이드', dataIndex: 'overrideTotal', width: 110, align: 'right', search: false, render: (_, r) => <MoneyText value={r.overrideTotal} /> },
{
title: '환수', dataIndex: 'chargebackTotal', width: 100, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.chargebackTotal ? -Math.abs(Number(r.chargebackTotal)) : 0} />,
},
{ title: '총액', dataIndex: 'grossAmount', width: 120, align: 'right', search: false, render: (_, r) => <MoneyText value={r.grossAmount} /> },
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', search: false, render: (_, r) => <MoneyText value={r.taxAmount} /> },
{
title: '실지급', dataIndex: 'netAmount', width: 130, align: 'right', search: false,
render: (_, r) => <strong><MoneyText value={r.netAmount} /></strong>,
},
{
title: '액션', dataIndex: 'settleId', width: 200, fixed: 'right', search: false,
render: (_, r) => (
<Space>
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small" type="primary"
onClick={() => onConfirm(r.settleId)}
disabled={r.status === 'CONFIRMED' || r.status === 'PAID'}></PermissionButton>
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small" danger
onClick={() => onHold(r.settleId)} disabled={r.status === 'PAID'}></PermissionButton>
</Space>
),
},
];
return (
<PageContainer title="정산 결과" description="설계사별 월 정산 합산 / 확정·보류">
{summary && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}><Card><Statistic title="대상 설계사" value={Number(summary.agentCount ?? 0)} suffix="명" /></Card></Col>
<Col span={6}><Card><Statistic title="총 지급액(net)" value={Number(summary.netAmount ?? 0)} /></Card></Col>
<Col span={6}><Card><Statistic title="총 세액" value={Number(summary.taxAmount ?? 0)} valueStyle={{ color: '#cf1322' }} /></Card></Col>
<Col span={6}><Card><Statistic title="확정 건수" value={Number(summary.confirmedCount ?? 0)} suffix="건" /></Card></Col>
</Row>
<ProCard ghost gutter={16} style={{ marginBottom: 16 }} wrap>
<ProCard colSpan={6} bordered={false}>
<Statistic title="대상 설계사" value={Number(summary.agentCount ?? 0)} suffix="명" />
</ProCard>
<ProCard colSpan={6} bordered={false}>
<Statistic title="총 지급액 (net)" value={Number(summary.netAmount ?? 0)} />
</ProCard>
<ProCard colSpan={6} bordered={false}>
<Statistic title="총 세액" value={Number(summary.taxAmount ?? 0)} valueStyle={{ color: '#E24B4A' }} />
</ProCard>
<ProCard colSpan={6} bordered={false}>
<Statistic title="확정 건수" value={Number(summary.confirmedCount ?? 0)} suffix="건" />
</ProCard>
</ProCard>
)}
<Table<SettleResp>
<ProTable<SettleResp, SettleSearchParam>
actionRef={actionRef}
rowKey="settleId"
loading={isLoading}
dataSource={data?.list}
size="small"
scroll={{ x: 1600 }}
pagination={{
...defaultPagination,
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
columns={columns}
scroll={{ x: 1700 }}
request={async (params) => {
const { current, pageSize, settleMonth: sm, ...rest } = params as any;
setSettleMonth(sm);
const res = await settleApi.list({ ...rest, settleMonth: sm, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' },
{ title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left' },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '직급', dataIndex: 'gradeName', width: 80 },
{ title: '모집', dataIndex: 'recruitTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '유지', dataIndex: 'maintainTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '예외(+)', dataIndex: 'exceptionPlus', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '예외(-)', dataIndex: 'exceptionMinus', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '오버라이드', dataIndex: 'overrideTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '환수', dataIndex: 'chargebackTotal', width: 100, align: 'right',
render: (v) => <MoneyText value={v ? -Math.abs(Number(v)) : 0} /> },
{ title: '총액', dataIndex: 'grossAmount', width: 120, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '실지급', dataIndex: 'netAmount', width: 130, align: 'right',
render: (v) => <strong><MoneyText value={v} /></strong> },
{ title: '상태', dataIndex: 'status', width: 100, fixed: 'right',
render: (v) => <CodeBadge groupCode="SETTLE_STATUS" value={v} /> },
{
title: '액션', dataIndex: 'settleId', width: 200, fixed: 'right',
render: (id, r) => (
<Space>
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small"
type="primary" onClick={() => onConfirm(id)}
disabled={r.status === 'CONFIRMED' || r.status === 'PAID'}>
</PermissionButton>
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small"
danger onClick={() => onHold(id)}
disabled={r.status === 'PAID'}>
</PermissionButton>
</Space>
),
},
]}
pagination={{
pageSize: 30, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
+59 -57
View File
@@ -1,25 +1,22 @@
import { useState } from 'react';
import { Table, Space, message, Modal } from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef } from 'react';
import { Modal, Space, message } from 'antd';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import { useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import PermissionButton from '@/components/common/PermissionButton';
import { defaultPagination } from '@/utils/tablePagination';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { userApi, UserResp } from '@/api/user';
export default function UserList() {
const qc = useQueryClient();
const [param, setParam] = useState<{ searchKeyword?: string; status?: string; pageNum: number; pageSize: number }>({
pageNum: 1, pageSize: 30,
});
const actionRef = useRef<ActionType>();
const { data: statusCodes = [] } = useCommonCodes('AGENT_STATUS');
const { data, isLoading } = useQuery({
queryKey: ['user', 'list', param],
queryFn: () => userApi.list(param),
});
const refresh = () => qc.invalidateQueries({ queryKey: ['user'] });
const refresh = () => {
qc.invalidateQueries({ queryKey: ['user'] });
actionRef.current?.reload();
};
const onReset = (id: number) =>
Modal.confirm({
@@ -34,52 +31,57 @@ export default function UserList() {
onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); },
});
return (
<PageContainer title="사용자 관리">
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '아이디/이름' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
const columns: ProColumns<UserResp>[] = [
{
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '아이디 / 이름' },
},
{
title: '상태', dataIndex: 'status', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(statusCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="AGENT_STATUS" value={r.status} />,
},
{ title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false },
{ title: '이름', dataIndex: 'userName', width: 120, search: false },
{ title: '이메일', dataIndex: 'email', width: 200, search: false },
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
{ title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', search: false },
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160, search: false },
{
title: '액션', width: 220, fixed: 'right', search: false,
render: (_, r) => (
<Space>
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
onClick={() => onReset(r.userId)}></PermissionButton>
{r.status === 'LOCKED' && (
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small" type="primary"
onClick={() => onUnlock(r.userId)}></PermissionButton>
)}
</Space>
),
},
];
<Table<UserResp>
return (
<PageContainer title="사용자 관리" description="시스템 사용자 / 역할 매핑 / 비밀번호">
<ProTable<UserResp>
actionRef={actionRef}
rowKey="userId"
loading={isLoading}
dataSource={data?.list}
pagination={{
...defaultPagination,
current: param.pageNum, pageSize: param.pageSize, total: data?.total,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
columns={columns}
scroll={{ x: 1300 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await userApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
columns={[
{ title: '로그인 ID', dataIndex: 'loginId', width: 120 },
{ title: '이름', dataIndex: 'userName', width: 120 },
{ title: '이메일', dataIndex: 'email', width: 200 },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '연결 설계사', dataIndex: 'agentName', width: 100 },
{
title: '상태', dataIndex: 'status', width: 100,
render: (v) => <CodeBadge groupCode="AGENT_STATUS" value={v} />,
},
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right' },
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160 },
{
title: '액션', width: 220, fixed: 'right',
render: (_, r) => (
<Space>
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
onClick={() => onReset(r.userId)}></PermissionButton>
{r.status === 'LOCKED' && (
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small" type="primary"
onClick={() => onUnlock(r.userId)}></PermissionButton>
)}
</Space>
),
},
]}
pagination={{
pageSize: 30, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `${total.toLocaleString()}`,
}}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);