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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user