17ab1098ec
PayInstallmentStep(Step 4.5): 1200%룰로 이연된 분급(installment_plan SCHEDULED)을 도래월(settle_month=정산월)에 recruit_ledger로 편입해 실제 지급. 이전엔 이연만 되고 도래월 지급 절차가 없어 차액이 영구 미지급되던 갭을 메움. - BatchConfig job flow에 step4b(calcRecruit→payInstallment→calcMaintain) 등록 - InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML)로 재실행 멱등 (진입 시 PAID→SCHEDULED 역산, Step4 deleteBySettleMonth가 원장 정리) - RecruitLedgerMapper insert에 reconcile_status 컬럼, SettlementContext.installmentPaidCount - 단위테스트 3건(정상지급/멱등/계약미존재) GREEN 신입교육 주석: 컨트롤러/서비스/공통/프론트 전반에 도메인·코드 설명 주석 추가 (동작 변경 없음 — 빌드/테스트/타입체크 전부 GREEN으로 무회귀 확인). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
9.0 KiB
TypeScript
211 lines
9.0 KiB
TypeScript
/**
|
|
* [수금수수료] 화면
|
|
* - 업무: 보험료 수납(수금) 실적에 대해 주는 수수료의 "룰"을 관리하고, 월별 지급 "원장"을 조회하는 화면.
|
|
* 상단 탭 2개: (1) 수수료 룰(등록/수정/삭제) (2) 수금 원장(조회 전용).
|
|
* - 호출 API: commissionApi.collectionRules / ...RuleCreate·Update·Delete (룰 CRUD),
|
|
* collectionLedgers (원장 조회).
|
|
* - 주요 상태: ledgerParams(원장 검색조건), ruleModalOpen/editingRule(룰 모달).
|
|
* - 도메인 용어:
|
|
* - 수금수수료: 계약 체결이 아니라 보험료를 실제로 거둬들인(수납) 것에 대해 지급하는 수수료.
|
|
* - 룰은 보험사/상품유형/납입주기별로 수수료율을 정의한다.
|
|
*/
|
|
import { useState } from 'react';
|
|
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Tabs, message } from 'antd';
|
|
import { ProCard } from '@ant-design/pro-components';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import type { ColDef } from '@ag-grid-community/core';
|
|
import dayjs from 'dayjs';
|
|
import PageContainer from '@/components/common/PageContainer';
|
|
import SearchForm from '@/components/common/SearchForm';
|
|
import DataGrid from '@/components/common/DataGrid';
|
|
import PermissionButton from '@/components/common/PermissionButton';
|
|
import {
|
|
commissionApi,
|
|
CollectionRuleRow, CollectionLedgerRow,
|
|
CommissionSearchParam,
|
|
} from '@/api/commission';
|
|
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
|
|
|
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
|
|
|
const RULE_COLS: ColDef<CollectionRuleRow>[] = [
|
|
{ field: 'carrierName', headerName: '보험사', width: 140 },
|
|
{ field: 'productType', headerName: '상품유형', width: 120 },
|
|
{ field: 'paymentCycle', headerName: '납입주기', width: 100 },
|
|
{ field: 'ratePercent', headerName: '수수료율(%)', width: 120, type: 'numericColumn' },
|
|
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
|
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
|
{ field: 'status', headerName: '상태', width: 90 },
|
|
];
|
|
|
|
const LEDGER_COLS: ColDef<CollectionLedgerRow>[] = [
|
|
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
|
{ field: 'agentName', headerName: '설계사', width: 120 },
|
|
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
|
{ field: 'collectionAmount', headerName: '수납금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
|
{ field: 'ratePercent', headerName: '수수료율(%)', width: 110, type: 'numericColumn' },
|
|
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
|
{ field: 'status', headerName: '상태', width: 90 },
|
|
];
|
|
|
|
export default function CollectionCommission() {
|
|
const qc = useQueryClient();
|
|
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
|
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
|
});
|
|
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
|
const [editingRule, setEditingRule] = useState<CollectionRuleRow | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
|
queryKey: ['collection', 'rules'],
|
|
queryFn: () => commissionApi.collectionRules(),
|
|
retry: false,
|
|
});
|
|
|
|
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
|
queryKey: ['collection', 'ledgers', ledgerParams],
|
|
queryFn: () => commissionApi.collectionLedgers(ledgerParams),
|
|
retry: false,
|
|
});
|
|
|
|
const rules = rulesData?.list ?? [];
|
|
const ledgers = ledgersData?.list ?? [];
|
|
|
|
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
|
const openEdit = (row: CollectionRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
|
|
|
const handleSave = async () => {
|
|
const values = await form.validateFields();
|
|
try {
|
|
if (editingRule) {
|
|
await commissionApi.collectionRuleUpdate(editingRule.ruleId, values);
|
|
message.success('수정 완료');
|
|
} else {
|
|
await commissionApi.collectionRuleCreate(values);
|
|
message.success('등록 완료');
|
|
}
|
|
qc.invalidateQueries({ queryKey: ['collection', 'rules'] });
|
|
setRuleModalOpen(false);
|
|
} catch { /* request.ts 처리 */ }
|
|
};
|
|
|
|
const handleDelete = (id: number) => Modal.confirm({
|
|
title: '룰 삭제', content: '삭제하시겠습니까?',
|
|
onOk: async () => {
|
|
await commissionApi.collectionRuleDelete(id);
|
|
message.success('삭제 완료');
|
|
qc.invalidateQueries({ queryKey: ['collection', 'rules'] });
|
|
},
|
|
});
|
|
|
|
const pinnedLedger = ledgers.length > 0 ? {
|
|
agentName: '합계',
|
|
collectionAmount: ledgers.reduce((s, r) => s + (r.collectionAmount ?? 0), 0),
|
|
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
|
|
} as Partial<CollectionLedgerRow> : undefined;
|
|
|
|
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
|
|
|
return (
|
|
<PageContainer title="수금수수료" description="수금수수료 룰 관리 및 원장 조회">
|
|
{rulesError && (
|
|
<Alert type="warning" showIcon message="API 미응답" description="/api/collection-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
|
|
)}
|
|
|
|
<Tabs
|
|
defaultActiveKey="rules"
|
|
items={[
|
|
{
|
|
key: 'rules',
|
|
label: '수수료 룰',
|
|
children: (
|
|
<ProCard style={cardStyle}>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<PermissionButton menuCode="COLLECT_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
|
|
+ 룰 등록
|
|
</PermissionButton>
|
|
</div>
|
|
<DataGrid<CollectionRuleRow>
|
|
rows={rules}
|
|
columns={[
|
|
...RULE_COLS,
|
|
{
|
|
headerName: '액션', width: 160, pinned: 'right',
|
|
cellRenderer: (p: { data: CollectionRuleRow }) => (
|
|
<Space>
|
|
<PermissionButton menuCode="COLLECT_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
|
<PermissionButton menuCode="COLLECT_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}>삭제</PermissionButton>
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
loading={rulesLoading}
|
|
height={520}
|
|
rowKey="ruleId"
|
|
/>
|
|
</ProCard>
|
|
),
|
|
},
|
|
{
|
|
key: 'ledgers',
|
|
label: '수금 원장',
|
|
children: (
|
|
<>
|
|
<SearchForm
|
|
conditions={[
|
|
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
|
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
|
]}
|
|
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
|
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
|
/>
|
|
<ProCard style={cardStyle}>
|
|
<DataGrid<CollectionLedgerRow>
|
|
rows={ledgers}
|
|
columns={LEDGER_COLS}
|
|
loading={ledgersLoading}
|
|
height={520}
|
|
rowKey="ledgerId"
|
|
pinnedBottomRow={pinnedLedger}
|
|
/>
|
|
</ProCard>
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Modal
|
|
title={editingRule ? '룰 수정' : '룰 등록'}
|
|
open={ruleModalOpen}
|
|
onOk={handleSave}
|
|
onCancel={() => setRuleModalOpen(false)}
|
|
okText="저장"
|
|
cancelText="취소"
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="carrierName" label="보험사명" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="productType" label="상품유형" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="paymentCycle" label="납입주기">
|
|
<Input placeholder="MONTHLY / ANNUAL" />
|
|
</Form.Item>
|
|
<Form.Item name="ratePercent" label="수수료율(%)" rules={[{ required: true }]}>
|
|
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} />
|
|
</Form.Item>
|
|
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
|
|
<Input placeholder="YYYY-MM-DD" />
|
|
</Form.Item>
|
|
<Form.Item name="effectiveTo" label="적용종료">
|
|
<Input placeholder="YYYY-MM-DD" />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</PageContainer>
|
|
);
|
|
}
|