Files
ga-commission-system/ga-frontend/src/pages/commission/RecruiterCommission.tsx
T
GA Pro ff6d399dd3 feat: 수수료 공식 갭 보완 + 8종 신규모듈 풀스택 (V116~V123)
수수료 공식 갭분석(5 병렬감사) 후 확정 버그 3건 수정 + 개념적으로 빠진 모듈 8종 풀스택 구현.

[확정 버그 3건]
- 1200% 누적한도(checkContractTotal)를 배치 CalcRecruitStep에 연결(기존 1차년 한도만 적용
  → 두 한도 중 더 제한적인 값으로 clip+이연). +CalcRecruitStepTest
- BatchInstallmentPlanGenerator planMonth 반영(하드코딩 1 제거, API판과 동일). +단위테스트
- PersistencyBonus 임계유지율 게이트 + FIXED 정액 룰 도출. +PersistencyBonusServiceTest

[신규 모듈 8종]
- MOD-1 도입수수료(recruiter/development) / MOD-2 선지급·차익정산 / MOD-3 환수 분할상환
- MOD-4 익월부활 재지급 / MOD-5 정산대사 입수 / MOD-6 환수 시효 / MOD-7 환수 감면
- MOD-8 채널별 수수료차등(sales_channel)
각 DB(테이블+메뉴+권한+공통코드)→VO/Mapper/Enum→Service/Controller→화면 풀스택.
배치 정산연계: AggregateStep에 도입/익월부활 가산(11종), 선지급 FIFO 상계(멱등 reverse),
MOD-8 채널 우선 요율조회(NULL 폴백 하위호환).

[검증]
- 전체 ./gradlew build(전 모듈+전 테스트) GREEN
- Flyway V116~V123 운영DB 적용(schema v115→v123)
- 라이브 스모크 8 GET=200, MOD-1 RATE공식 50000 실측, 액션 POST 5xx 0건
- 적대적 코드리뷰 APPROVED(MOD-8 계산기 미연결 버그 발견·수정)

스펙: docs/DOMAIN_GAP_MOD1-8.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:13 +09:00

211 lines
8.7 KiB
TypeScript

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, useMutation, 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,
RecruiterRuleRow,
RecruiterLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const RECRUITER_TYPE_OPTIONS = [
{ value: 'NEW_RECRUIT', label: '신인도입' },
{ value: 'DEVELOPMENT', label: '육성' },
{ value: 'TRAINING', label: '교육' },
];
const RULE_COLS: ColDef<RecruiterRuleRow>[] = [
{ field: 'recruiterType', 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<RecruiterLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'recruiterAgentName', headerName: '도입자', width: 130 },
{ field: 'recruitAgentName', headerName: '피도입자', width: 130 },
{ field: 'recruiterType', headerName: '유형', width: 110 },
{ field: 'baseAmount', headerName: '기준금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 110 },
];
export default function RecruiterCommission() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<RecruiterRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['recruiterCommission', 'rules'],
queryFn: () => commissionApi.recruiterRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['recruiterCommission', 'ledgers', ledgerParams],
queryFn: () => commissionApi.recruiterLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const saveMutation = useMutation({
mutationFn: async (values: Partial<RecruiterRuleRow>) => {
if (editingRule) {
return commissionApi.recruiterRuleUpdate(editingRule.ruleId, values);
}
return commissionApi.recruiterRuleCreate(values);
},
onSuccess: () => {
message.success(editingRule ? '수정 완료' : '등록 완료');
qc.invalidateQueries({ queryKey: ['recruiterCommission', 'rules'] });
setRuleModalOpen(false);
},
});
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: RecruiterRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
saveMutation.mutate(values);
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.recruiterRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['recruiterCommission', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
recruiterAgentName: '합계',
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
} as Partial<RecruiterLedgerRow> : 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/recruiter-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '도입 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="RECRUITER_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<RecruiterRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: RecruiterRuleRow }) => (
<Space>
<PermissionButton menuCode="RECRUITER_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="RECRUITER_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<RecruiterLedgerRow>
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="취소"
confirmLoading={saveMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="recruiterType" label="유형" rules={[{ required: true }]}>
<Select options={RECRUITER_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="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>
);
}