feat: incar 갭 반영 수수료/운영 14개 도메인 풀스택 + 배치통합 + 신입교육자료
incar CMS(116메뉴) 벤치마킹 갭분석으로 ga-pro에 없던 14개 도메인을 풀스택 추가. P7 수수료 계산 7종 (V88~V96): - 수입수수료+수지차, 소개·이관수수료, 정착지원금, 인정실적+환산율, 시상, 생보운영지원수수료, 지점장수당 - 각 rule/ledger + 공식(보험료×요율, 수입−지급 등) Service 내장 P8 운영/정산 7종 (V97~V105): - 단계마감, 본사정산, 등급평가, 공동계약, 보증보험, 적립금, 수수료시뮬레이터 - close/reopen/apply/simulate/upsert 등 액션 + 손익·잔액·등급 산정 로직 배치 통합 (V106): - settle_master.other_commission_total 컬럼 추가(DEFAULT 0, 백필) - 설계사 지급성 9종 원장 aggregateByAgent → AggregateStep gross 합산 - 빈 원장 0 → 기존 정산결과·무결성 불변(비파괴적) 테스트: P7/P8 Service 공식 단위테스트 21건(ga-api:test 57건 전체 통과) 프론트: 화면 14개 + React.lazy 코드 스플리팅(단일 4MB → 130+ 청크) 문서: DOMAIN_GAP_P7/P8.md, 신입교육_보험과수수료_완전기초.md, DOMAIN_KNOWLEDGE/HANDOFF 갱신 검증: 전체 ./gradlew build(test 포함) SUCCESSFUL, Flyway V88~V106 success, GET+액션POST smoke 5xx 0건, 9개 집계쿼리 SQL 유효성 확인, 공식 런타임 실측. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Select, 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,
|
||||
AwardRuleRow,
|
||||
AwardLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const AWARD_TYPE_OPTIONS = [
|
||||
{ value: 'NEW_RECRUIT', label: '신인도입' },
|
||||
{ value: 'CORPORATE', label: '법인' },
|
||||
{ value: 'RETENTION', label: '유지율' },
|
||||
{ value: 'PERFORMANCE', label: '실적' },
|
||||
];
|
||||
|
||||
const RULE_COLS: ColDef<AwardRuleRow>[] = [
|
||||
{ field: 'awardType', headerName: '시상유형', width: 130 },
|
||||
{ field: 'calcType', headerName: '계산방식', width: 110 },
|
||||
{ field: 'thresholdAmount', headerName: '기준실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'awardRate', headerName: '시상율', width: 110, type: 'numericColumn' },
|
||||
{ field: 'awardFixed', headerName: '시상정액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<AwardLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'awardType', headerName: '시상유형', width: 120 },
|
||||
{ field: 'baseAmount', headerName: '기준실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'awardAmount', headerName: '시상금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
export default function Award() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<AwardRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['award', 'rules'],
|
||||
queryFn: () => commissionApi.awardRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['award', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.awardLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: AwardRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.awardRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.awardRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['award', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.awardRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['award', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
|
||||
awardAmount: ledgers.reduce((s, r) => s + (r.awardAmount ?? 0), 0),
|
||||
} as Partial<AwardLedgerRow> : 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/awards 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '시상 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="AWARD" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<AwardRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: AwardRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="AWARD" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="AWARD" 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<AwardLedgerRow>
|
||||
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="awardType" label="시상유형" rules={[{ required: true }]}>
|
||||
<Select options={AWARD_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="calcType" label="계산방식" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'RATE', label: '정률' }, { value: 'FIXED', label: '정액' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="thresholdAmount" label="기준실적(이상)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="awardRate" label="시상율(0~1)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
|
||||
</Form.Item>
|
||||
<Form.Item name="awardFixed" label="시상정액">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, 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,
|
||||
BranchAllowanceRuleRow,
|
||||
BranchAllowanceLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
|
||||
|
||||
const RULE_COLS: ColDef<BranchAllowanceRuleRow>[] = [
|
||||
{ field: 'positionCode', headerName: '직책코드', width: 130 },
|
||||
{ field: 'allowanceRate', headerName: '수당율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'capAmount', headerName: '상한', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<BranchAllowanceLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'managerAgentName',headerName: '지점장', width: 130 },
|
||||
{ field: 'orgName', headerName: '조직명', width: 130 },
|
||||
{ field: 'baseAmount', headerName: '조직실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'allowanceRate', headerName: '수당율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'allowanceAmount', headerName: '수당금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
export default function BranchAllowance() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<BranchAllowanceRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['branchAllowance', 'rules'],
|
||||
queryFn: () => commissionApi.branchAllowanceRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['branchAllowance', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.branchAllowanceLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: BranchAllowanceRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.branchAllowanceRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.branchAllowanceRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['branchAllowance', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.branchAllowanceRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['branchAllowance', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
managerAgentName: '합계',
|
||||
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
|
||||
allowanceAmount: ledgers.reduce((s, r) => s + (r.allowanceAmount ?? 0), 0),
|
||||
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
|
||||
} as Partial<BranchAllowanceLedgerRow> : 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/branch-allowances 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '수당 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="BRANCH_MGR_ALLOWANCE" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<BranchAllowanceRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: BranchAllowanceRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="BRANCH_MGR_ALLOWANCE" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="BRANCH_MGR_ALLOWANCE" 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<BranchAllowanceLedgerRow>
|
||||
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="positionCode" label="직책코드" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: BRANCH_MGR" />
|
||||
</Form.Item>
|
||||
<Form.Item name="allowanceRate" label="수당율(0~1)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
|
||||
</Form.Item>
|
||||
<Form.Item name="capAmount" label="상한(미입력=무제한)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Card, Col, Form, Input, InputNumber, Row, Statistic, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { operationApi, SimulationRow, SimulationRequest } from '@/api/operation';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_ERROR, COLOR_SUCCESS } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
const HIST_COLS: ColDef<SimulationRow>[] = [
|
||||
{ field: 'simName', headerName: '시나리오명', width: 150 },
|
||||
{ field: 'premiumAmount', headerName: '보험료', width: 130, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'companyRate', headerName: '보험사요율(%)', width: 130, type: 'numericColumn',
|
||||
valueFormatter: (p) => `${((p.value as number) * 100).toFixed(2)}%` },
|
||||
{ field: 'payoutRate', headerName: '지급률(%)', width: 110, type: 'numericColumn',
|
||||
valueFormatter: (p) => `${((p.value as number) * 100).toFixed(2)}%` },
|
||||
{ field: 'commissionYear', headerName: '회차', width: 70, type: 'numericColumn' },
|
||||
{ field: 'companyPayment', headerName: '보험사지급액', width: 140, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'agentCommission', headerName: '설계사수수료', width: 140, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'withholdingTax', headerName: '원천세(3.3%)', width: 130, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'netAmount', headerName: '실지급', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'expectedChargeback', headerName: '예상환수', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'createdAt', headerName: '계산일시', flex: 1 },
|
||||
];
|
||||
|
||||
export default function CommissionSimulator() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [result, setResult] = useState<SimulationRow | null>(null);
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['commission-simulation', 'list'],
|
||||
queryFn: () => operationApi.simulationList(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const handleSimulate = async () => {
|
||||
const values = await form.validateFields();
|
||||
// UI: 율은 % 단위 → 백엔드는 0~1
|
||||
const body: SimulationRequest = {
|
||||
simName: values.simName as string,
|
||||
premiumAmount: values.premiumAmount as number,
|
||||
companyRate: (values.companyRate as number) / 100,
|
||||
payoutRate: (values.payoutRate as number) / 100,
|
||||
commissionYear: values.commissionYear as number,
|
||||
};
|
||||
try {
|
||||
const res = await operationApi.simulate(body);
|
||||
setResult(res);
|
||||
message.success('계산 완료');
|
||||
qc.invalidateQueries({ queryKey: ['commission-simulation', 'list'] });
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="수수료 시뮬레이터" description="보험료/요율/지급률 입력 후 수수료 계산">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/commission-simulations 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
{/* 입력 폼 */}
|
||||
<ProCard title="시뮬레이션 입력" style={{ ...cardStyle, marginBottom: 16 }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Form.Item name="simName" label="시나리오명" rules={[{ required: true }]}>
|
||||
<Input placeholder="시나리오명" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item name="premiumAmount" label="보험료(원)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item name="companyRate" label="보험사요율(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} addonAfter="%" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item name="payoutRate" label="지급률(%)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} addonAfter="%" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item name="commissionYear" label="회차" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<PermissionButton menuCode="COMM_SIMULATOR" permCode="CREATE" type="primary" onClick={handleSimulate}>
|
||||
계산
|
||||
</PermissionButton>
|
||||
</Form>
|
||||
</ProCard>
|
||||
|
||||
{/* 결과 카드 */}
|
||||
{result && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={4}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic title="보험사지급액" value={result.companyPayment} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_PRIMARY }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic title="설계사수수료" value={result.agentCommission} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_PRIMARY }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic title="원천세(3.3%)" value={result.withholdingTax} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_ERROR }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic title="실지급" value={result.netAmount} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_SUCCESS }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card style={cardStyle}>
|
||||
<Statistic title="예상환수" value={result.expectedChargeback ?? 0} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_ERROR }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 저장된 시뮬 목록 */}
|
||||
<ProCard title="저장된 시뮬레이션 목록" style={cardStyle}>
|
||||
<DataGrid<SimulationRow>
|
||||
rows={rows}
|
||||
columns={HIST_COLS}
|
||||
loading={isLoading}
|
||||
height={400}
|
||||
rowKey="simId"
|
||||
/>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Button, 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,
|
||||
IncomeLedgerRow,
|
||||
MarginSummaryRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
|
||||
|
||||
const LEDGER_COLS: ColDef<IncomeLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
||||
{ field: 'carrierName', headerName: '보험사', width: 130 },
|
||||
{ field: 'commissionType', headerName: '수수료유형', width: 120 },
|
||||
{ field: 'premiumAmount', headerName: '보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'companyRate', headerName: '보험사요율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'incomeAmount', headerName: '수입수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
const MARGIN_COLS: ColDef<MarginSummaryRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'incomeTotal', headerName: '수입합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'payoutTotal', headerName: '지급합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'marginAmount', headerName: '수지차', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'marginRate', headerName: '수지차율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
];
|
||||
|
||||
export default function IncomeCommission() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [marginParams, setMarginParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [recalcLoading, setRecalcLoading] = useState(false);
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading, isError: ledgersError } = useQuery({
|
||||
queryKey: ['income', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.incomeLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: marginData, isLoading: marginLoading } = useQuery({
|
||||
queryKey: ['income', 'margin', marginParams],
|
||||
queryFn: () => commissionApi.incomeMargins(marginParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
const margins = marginData?.list ?? [];
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
contractNo: '합계',
|
||||
premiumAmount: ledgers.reduce((s, r) => s + (r.premiumAmount ?? 0), 0),
|
||||
incomeAmount: ledgers.reduce((s, r) => s + (r.incomeAmount ?? 0), 0),
|
||||
} as Partial<IncomeLedgerRow> : undefined;
|
||||
|
||||
const pinnedMargin = margins.length > 0 ? {
|
||||
agentName: '합계',
|
||||
incomeTotal: margins.reduce((s, r) => s + (r.incomeTotal ?? 0), 0),
|
||||
payoutTotal: margins.reduce((s, r) => s + (r.payoutTotal ?? 0), 0),
|
||||
marginAmount: margins.reduce((s, r) => s + (r.marginAmount ?? 0), 0),
|
||||
} as Partial<MarginSummaryRow> : undefined;
|
||||
|
||||
const handleRecalc = async () => {
|
||||
setRecalcLoading(true);
|
||||
try {
|
||||
await commissionApi.incomeMarginRecalc(marginParams.settleMonth ?? dayjs().format('YYYYMM'));
|
||||
message.success('수지차 재계산 완료');
|
||||
qc.invalidateQueries({ queryKey: ['income', 'margin'] });
|
||||
} catch { /* request.ts 처리 */ }
|
||||
finally { setRecalcLoading(false); }
|
||||
};
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="수입수수료/수지차" description="수입수수료 원장 조회 및 수지차 집계">
|
||||
{ledgersError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/income-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="ledgers"
|
||||
items={[
|
||||
{
|
||||
key: 'ledgers',
|
||||
label: '수입원장',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'contractNo', 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<IncomeLedgerRow>
|
||||
rows={ledgers}
|
||||
columns={LEDGER_COLS}
|
||||
loading={ledgersLoading}
|
||||
height={520}
|
||||
rowKey="incomeId"
|
||||
pinnedBottomRow={pinnedLedger}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
label: '수지차(margin)',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setMarginParams({ ...v as CommissionSearchParam, pageSize: 200 })}
|
||||
onReset={() => setMarginParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton
|
||||
menuCode="INCOME_COMM"
|
||||
permCode="UPDATE"
|
||||
type="primary"
|
||||
loading={recalcLoading}
|
||||
onClick={handleRecalc}
|
||||
>
|
||||
수지차 재계산
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<MarginSummaryRow>
|
||||
rows={margins}
|
||||
columns={MARGIN_COLS}
|
||||
loading={marginLoading}
|
||||
height={520}
|
||||
rowKey="marginId"
|
||||
pinnedBottomRow={pinnedMargin}
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, 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,
|
||||
LifeSupportRuleRow,
|
||||
LifeSupportLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
|
||||
|
||||
const RULE_COLS: ColDef<LifeSupportRuleRow>[] = [
|
||||
{ field: 'agentGrade', headerName: '설계사등급', width: 130 },
|
||||
{ field: 'supportRate', headerName: '지급률', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'effectiveFrom',headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<LifeSupportLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'baseAmount', headerName: '생보실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'supportRate', headerName: '지급률', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'supportAmount', headerName: '지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'netAmount', headerName: '실지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
export default function LifeSupport() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<LifeSupportRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['lifeSupport', 'rules'],
|
||||
queryFn: () => commissionApi.lifeSupportRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['lifeSupport', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.lifeSupportLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: LifeSupportRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.lifeSupportRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.lifeSupportRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['lifeSupport', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.lifeSupportRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['lifeSupport', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
|
||||
supportAmount: ledgers.reduce((s, r) => s + (r.supportAmount ?? 0), 0),
|
||||
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
|
||||
netAmount: ledgers.reduce((s, r) => s + (r.netAmount ?? 0), 0),
|
||||
} as Partial<LifeSupportLedgerRow> : 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/life-supports 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '지급률 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="LIFE_OP_SUPPORT" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<LifeSupportRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: LifeSupportRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="LIFE_OP_SUPPORT" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="LIFE_OP_SUPPORT" 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<LifeSupportLedgerRow>
|
||||
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="agentGrade" label="설계사등급" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: ENTRY, SENIOR" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportRate" label="지급률(0~1)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, 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,
|
||||
ConversionRateRow,
|
||||
RecognizedLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
|
||||
|
||||
const RULE_COLS: ColDef<ConversionRateRow>[] = [
|
||||
{ field: 'productName', headerName: '상품명', width: 180 },
|
||||
{ field: 'conversionRate', headerName: '환산율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<RecognizedLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
||||
{ field: 'premiumAmount', headerName: '보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'conversionRate', headerName: '환산율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'convertedPremium', headerName: '환산보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'recognitionRate', headerName: '인정율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
|
||||
{ field: 'recognizedAmount', headerName: '인정실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
export default function RecognizedPerformance() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<ConversionRateRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['recognized', 'rules'],
|
||||
queryFn: () => commissionApi.conversionRates(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['recognized', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.recognizedLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: ConversionRateRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.conversionRateUpdate(editingRule.rateId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.conversionRateCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['recognized', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '환산율 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.conversionRateDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['recognized', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
premiumAmount: ledgers.reduce((s, r) => s + (r.premiumAmount ?? 0), 0),
|
||||
convertedPremium: ledgers.reduce((s, r) => s + (r.convertedPremium ?? 0), 0),
|
||||
recognizedAmount: ledgers.reduce((s, r) => s + (r.recognizedAmount ?? 0), 0),
|
||||
} as Partial<RecognizedLedgerRow> : 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/recognized-performances 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '환산율',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="RECOGNIZED_PERF" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 환산율 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ConversionRateRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: ConversionRateRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="RECOGNIZED_PERF" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="RECOGNIZED_PERF" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.rateId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={rulesLoading}
|
||||
height={520}
|
||||
rowKey="rateId"
|
||||
/>
|
||||
</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<RecognizedLedgerRow>
|
||||
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="productId" label="상품ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="conversionRate" label="환산율(0~1)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Select, 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,
|
||||
ReferralRuleRow,
|
||||
ReferralLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const RULE_COLS: ColDef<ReferralRuleRow>[] = [
|
||||
{ field: 'referralType', headerName: '유형', width: 120 },
|
||||
{ field: 'calcType', headerName: '계산방식', width: 110 },
|
||||
{ field: 'commissionRate', headerName: '수수료율', width: 110, type: 'numericColumn' },
|
||||
{ field: 'fixedAmount', headerName: '정액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<ReferralLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'referrerAgentName',headerName: '소개설계사', width: 130 },
|
||||
{ field: 'contractNo', headerName: '계약번호', width: 140 },
|
||||
{ field: 'referralType', headerName: '유형', width: 100 },
|
||||
{ field: 'baseAmount', headerName: '기준금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'commissionRate', headerName: '수수료율', width: 110, type: 'numericColumn' },
|
||||
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 100 },
|
||||
];
|
||||
|
||||
export default function ReferralCommission() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<ReferralRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['referral', 'rules'],
|
||||
queryFn: () => commissionApi.referralRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['referral', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.referralLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: ReferralRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.referralRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.referralRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['referral', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.referralRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['referral', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
referrerAgentName: '합계',
|
||||
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
|
||||
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
|
||||
} as Partial<ReferralLedgerRow> : 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/referral-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '수수료 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="REFERRAL_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ReferralRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: ReferralRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="REFERRAL_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="REFERRAL_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<ReferralLedgerRow>
|
||||
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="referralType" label="유형" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'REFERRAL', label: '소개' }, { value: 'TRANSFER', label: '이관' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="calcType" label="계산방식" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'RATE', label: '정률' }, { value: 'FIXED', label: '정액' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="commissionRate" label="수수료율(0~1)">
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fixedAmount" label="정액">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, 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 { operationApi, ReserveFundRow } from '@/api/operation';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
const COLS: ColDef<ReserveFundRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'reserveIn', headerName: '적립액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'reserveOut', headerName: '인출액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'balance', headerName: '잔액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'createdAt', headerName: '등록일', width: 130 },
|
||||
];
|
||||
|
||||
export default function ReserveFund() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<{ settleMonth?: string; agentName?: string }>({
|
||||
settleMonth: dayjs().format('YYYYMM'),
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['reserve-fund', 'list', searchParams],
|
||||
queryFn: () => operationApi.reserveFundList(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
reserveIn: rows.reduce((s, r) => s + (r.reserveIn ?? 0), 0),
|
||||
reserveOut: rows.reduce((s, r) => s + (r.reserveOut ?? 0), 0),
|
||||
balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0),
|
||||
} as Partial<ReserveFundRow> : undefined;
|
||||
|
||||
const handleUpsert = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
await operationApi.reserveFundUpsert(values);
|
||||
message.success('등록/수정 완료');
|
||||
qc.invalidateQueries({ queryKey: ['reserve-fund', 'list'] });
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="적립금" description="환수 대비 수수료 적립금 관리">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/reserve-funds 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
]}
|
||||
initialValues={{ settleMonth: dayjs() }}
|
||||
onSearch={(v) => setSearchParams({ settleMonth: v.settleMonth as string, agentName: v.agentName as string })}
|
||||
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM') })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="RESERVE_FUND" permCode="CREATE" type="primary" onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||
+ 적립금 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ReserveFundRow>
|
||||
rows={rows}
|
||||
columns={COLS}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="reserveId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title="적립금 등록(upsert)"
|
||||
open={modalOpen}
|
||||
onOk={handleUpsert}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder={dayjs().format('YYYYMM')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reserveIn" label="적립액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reserveOut" label="인출액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, 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,
|
||||
SettlingSupportRuleRow,
|
||||
SettlingSupportLedgerRow,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const RULE_COLS: ColDef<SettlingSupportRuleRow>[] = [
|
||||
{ field: 'agentGrade', headerName: '설계사등급', width: 130 },
|
||||
{ field: 'supportAmount', headerName: '월지원금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'durationMonths', headerName: '지원기간(월)', width: 120, type: 'numericColumn' },
|
||||
{ field: 'clawbackMonths', headerName: '환수기준(월)', width: 120, type: 'numericColumn' },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const LEDGER_COLS: ColDef<SettlingSupportLedgerRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'supportAmount', headerName: '지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'netAmount', headerName: '실지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 110 },
|
||||
];
|
||||
|
||||
export default function SettlingSupport() {
|
||||
const qc = useQueryClient();
|
||||
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<SettlingSupportRuleRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||
queryKey: ['settling', 'rules'],
|
||||
queryFn: () => commissionApi.settlingRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
|
||||
queryKey: ['settling', 'ledgers', ledgerParams],
|
||||
queryFn: () => commissionApi.settlingLedgers(ledgerParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData?.list ?? [];
|
||||
const ledgers = ledgersData?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
|
||||
const openEdit = (row: SettlingSupportRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await commissionApi.settlingRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await commissionApi.settlingRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['settling', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await commissionApi.settlingRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['settling', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pinnedLedger = ledgers.length > 0 ? {
|
||||
agentName: '합계',
|
||||
supportAmount: ledgers.reduce((s, r) => s + (r.supportAmount ?? 0), 0),
|
||||
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
|
||||
netAmount: ledgers.reduce((s, r) => s + (r.netAmount ?? 0), 0),
|
||||
} as Partial<SettlingSupportLedgerRow> : 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/settling-supports 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '지원금 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="SETTLING_SUPPORT" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<SettlingSupportRuleRow>
|
||||
rows={rules}
|
||||
columns={[
|
||||
...RULE_COLS,
|
||||
{
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: SettlingSupportRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="SETTLING_SUPPORT" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="SETTLING_SUPPORT" 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<SettlingSupportLedgerRow>
|
||||
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="agentGrade" label="설계사등급" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: ENTRY, SENIOR" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportAmount" label="월 지원금" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="durationMonths" label="지원기간(월)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="clawbackMonths" label="환수기준(월)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user