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
+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>
);