feat: MappingEngine + Override 실제 로직 + 프론트 주요 화면
Backend: - MappingEngine: company_field_mapping 기반 동적 변환 구현 - raw_content(JSON) → 표준 원장 (TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP) - policy_no 로 contract 매칭 → contract_id/agent_id 자동 - 실패 시 parse_error_log 자동 적재 - Recruit/Maintain Mapper.sumByAgentMonth 추가 - OrganizationMapper.selectAncestorIds (CTE 재귀 상향) 추가 - CalcOverrideStep 실제 동작: * lower agent 의 모집+유지 합계를 base 로 사용 * 자기 조직 → 상위 조직 순으로 to_grade 직급 ACTIVE 설계사 탐색 * (lowerOrgId, toGrade) 캐싱 Frontend (주요 화면): - 공통 컴포넌트: * CodeSelect (공통코드 드롭다운, React Query 캐시) * CodeBadge (상태 색상 자동 매핑) * MoneyText (천단위 콤마, 음수 빨강, 부호 옵션) * PermissionButton (권한 없으면 미렌더) * SearchForm (조건 배열 → 자동 폼) * ExcelExportButton (blob 다운로드) * PageContainer - 훅: useCommonCodes, usePermission (메뉴별 boolean) - API 모듈: agent, contract, ledger, settle, code - 페이지: AgentList, ContractList, RecruitLedger, SettleList * SettleList: 월 요약 카드 + 확정/보류 액션 + 권한 체크 - 라우터: /org/agents, /contracts, /ledger/recruit, /settle V16 fix: - PAYMENT 메뉴는 V12 에서 level1 directory 로 생성됨 → V16 에서 PAGE 로 UPDATE 후 권한 매트릭스 적용 (자식으로 또 만들면 unique 충돌)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||
import { ledgerApi, LedgerResp, LedgerSearchParam } from '@/api/ledger';
|
||||
|
||||
export default function RecruitLedger() {
|
||||
const [param, setParam] = useState<LedgerSearchParam>({ pageNum: 1, pageSize: 50 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ledger', 'recruit', param],
|
||||
queryFn: () => ledgerApi.recruit(param),
|
||||
});
|
||||
|
||||
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>
|
||||
rowKey="ledgerId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
size="small"
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
|
||||
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
|
||||
}}
|
||||
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} /> },
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 { agentApi, AgentResp, AgentSearchParam } from '@/api/agent';
|
||||
|
||||
export default function AgentList() {
|
||||
const navigate = useNavigate();
|
||||
const [param, setParam] = useState<AgentSearchParam>({ pageNum: 1, pageSize: 20 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['agent', 'list', param],
|
||||
queryFn: () => agentApi.list(param),
|
||||
});
|
||||
|
||||
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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
rowKey="agentId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
pagination={{
|
||||
current: param.pageNum,
|
||||
pageSize: param.pageSize,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, size) => setParam({ ...param, pageNum: page, pageSize: size }),
|
||||
}}
|
||||
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 },
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import { useQuery } 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 { contractApi, ContractResp, ContractSearchParam } from '@/api/contract';
|
||||
|
||||
export default function ContractList() {
|
||||
const [param, setParam] = useState<ContractSearchParam>({ pageNum: 1, pageSize: 20 });
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract', 'list', param],
|
||||
queryFn: () => contractApi.list(param),
|
||||
});
|
||||
|
||||
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>
|
||||
rowKey="contractId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
pagination={{
|
||||
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
|
||||
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
|
||||
}}
|
||||
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 },
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Col, Row, Statistic, Table, message, Modal, Input, Space } from 'antd';
|
||||
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 { settleApi, SettleResp, SettleSearchParam } from '@/api/settle';
|
||||
|
||||
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 { data: summary } = useQuery({
|
||||
queryKey: ['settle', 'summary', param.settleMonth],
|
||||
queryFn: () => settleApi.summary(param.settleMonth!),
|
||||
enabled: !!param.settleMonth,
|
||||
});
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['settle'] });
|
||||
|
||||
const onConfirm = (id: number) =>
|
||||
Modal.confirm({
|
||||
title: '정산 확정', content: '확정 후에는 변경할 수 없습니다. 진행하시겠습니까?',
|
||||
onOk: async () => { await settleApi.confirm(id); message.success('확정 완료'); refresh(); },
|
||||
});
|
||||
|
||||
const onHold = (id: number) => {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '정산 보류',
|
||||
content: <Input placeholder="보류 사유" onChange={(e) => { reason = e.target.value; }} />,
|
||||
onOk: async () => { await settleApi.hold(id, reason); message.success('보류 처리'); refresh(); },
|
||||
});
|
||||
};
|
||||
|
||||
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 })}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<Table<SettleResp>
|
||||
rowKey="settleId"
|
||||
loading={isLoading}
|
||||
dataSource={data?.list}
|
||||
size="small"
|
||||
scroll={{ x: 1600 }}
|
||||
pagination={{
|
||||
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
|
||||
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
|
||||
}}
|
||||
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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user