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>
This commit is contained in:
@@ -30,6 +30,15 @@ const GuaranteeInsurance = React.lazy(() => import('@/pages/org/GuaranteeInsur
|
||||
const ReserveFund = React.lazy(() => import('@/pages/commission/ReserveFund'));
|
||||
const CommissionSimulator = React.lazy(() => import('@/pages/commission/CommissionSimulator'));
|
||||
|
||||
// MOD-1~7: 신규 수수료 모듈
|
||||
const RecruiterCommission = React.lazy(() => import('@/pages/commission/RecruiterCommission'));
|
||||
const AdvancePayment = React.lazy(() => import('@/pages/settle/AdvancePayment'));
|
||||
const ClawbackInstallment = React.lazy(() => import('@/pages/commission/ClawbackInstallment'));
|
||||
const LapsedReinstatement = React.lazy(() => import('@/pages/commission/LapsedReinstatement'));
|
||||
const CarrierReconIn = React.lazy(() => import('@/pages/settle/CarrierReconIn'));
|
||||
const StatuteConfig = React.lazy(() => import('@/pages/commission/StatuteConfig'));
|
||||
const ChargebackWaiver = React.lazy(() => import('@/pages/commission/ChargebackWaiver'));
|
||||
|
||||
// P9: 환수채권
|
||||
const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable'));
|
||||
|
||||
@@ -233,6 +242,15 @@ export default function App() {
|
||||
<Route path="commission/reserve" element={<ReserveFund />} />
|
||||
<Route path="commission/simulator" element={<CommissionSimulator />} />
|
||||
|
||||
{/* MOD-1~7: 신규 수수료 모듈 */}
|
||||
<Route path="commission/recruiter" element={<RecruiterCommission />} />
|
||||
<Route path="settle/advance-payment" element={<AdvancePayment />} />
|
||||
<Route path="commission/clawback-installment" element={<ClawbackInstallment />} />
|
||||
<Route path="commission/lapsed-reinstatement" element={<LapsedReinstatement />} />
|
||||
<Route path="settle/carrier-recon-in" element={<CarrierReconIn />} />
|
||||
<Route path="commission/statute" element={<StatuteConfig />} />
|
||||
<Route path="commission/chargeback-waiver" element={<ChargebackWaiver />} />
|
||||
|
||||
{/* P9: 환수채권 */}
|
||||
<Route path="commission/clawback-receivable" element={<ClawbackReceivable />} />
|
||||
|
||||
|
||||
@@ -318,6 +318,102 @@ export interface BranchAllowanceLedgerRow extends Record<string, unknown> {
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── MOD-1: RecruiterCommission ────────────────────
|
||||
export interface RecruiterRuleRow extends Record<string, unknown> {
|
||||
ruleId: number;
|
||||
recruiterType: string;
|
||||
calcType: string;
|
||||
commissionRate?: number;
|
||||
fixedAmount?: number;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export interface RecruiterLedgerRow extends Record<string, unknown> {
|
||||
ledgerId: number;
|
||||
recruiterAgentId: number;
|
||||
recruiterAgentName: string;
|
||||
recruitAgentId: number;
|
||||
recruitAgentName: string;
|
||||
settleMonth: string;
|
||||
recruiterType: string;
|
||||
baseAmount: number;
|
||||
commissionAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── MOD-3: ClawbackInstallment ────────────────────
|
||||
export interface ClawbackInstallmentRow extends Record<string, unknown> {
|
||||
planId: number;
|
||||
receivableId: number;
|
||||
installmentNo: number;
|
||||
installmentAmount: number;
|
||||
dueMonth: string;
|
||||
status: string;
|
||||
recoveredAt?: string;
|
||||
}
|
||||
|
||||
export interface ClawbackInstallmentGenerateReq {
|
||||
receivableId: number;
|
||||
installmentCount: number;
|
||||
startMonth: string;
|
||||
}
|
||||
|
||||
// ── MOD-4: LapsedReinstatement ────────────────────
|
||||
export interface LapsedReinstatementRow extends Record<string, unknown> {
|
||||
recoveryId: number;
|
||||
contractId: number;
|
||||
contractNo?: string;
|
||||
agentId: number;
|
||||
agentName?: string;
|
||||
lapsedMonth: string;
|
||||
reinstatedMonth: string;
|
||||
withheldAmount: number;
|
||||
repaidAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LapsedReinstatementSaveReq {
|
||||
contractId: number;
|
||||
agentId: number;
|
||||
lapsedMonth: string;
|
||||
reinstatedMonth: string;
|
||||
withheldAmount: number;
|
||||
}
|
||||
|
||||
// ── MOD-6: StatuteConfig ──────────────────────────
|
||||
export interface StatuteConfigRow extends Record<string, unknown> {
|
||||
configId: number;
|
||||
entityType: string;
|
||||
expirationMonths: number;
|
||||
isActive: string;
|
||||
}
|
||||
|
||||
// ── MOD-7: ChargebackWaiver ───────────────────────
|
||||
export interface ChargebackWaiverRow extends Record<string, unknown> {
|
||||
requestId: number;
|
||||
contractId: number;
|
||||
contractNo?: string;
|
||||
agentId: number;
|
||||
agentName?: string;
|
||||
settleMonth: string;
|
||||
originalChargeback: number;
|
||||
waiverAmount: number;
|
||||
reason?: string;
|
||||
approvalStatus: string;
|
||||
approverId?: number;
|
||||
approvedAt?: string;
|
||||
}
|
||||
|
||||
export interface ChargebackWaiverSaveReq {
|
||||
contractId: number;
|
||||
agentId: number;
|
||||
settleMonth: string;
|
||||
originalChargeback: number;
|
||||
waiverAmount: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────
|
||||
export const commissionApi = {
|
||||
// CommissionMaster
|
||||
@@ -513,4 +609,62 @@ export const commissionApi = {
|
||||
unwrap<PageResponse<BranchAllowanceLedgerRow>>(
|
||||
api.get('/api/branch-allowances/ledgers', { params: p }),
|
||||
),
|
||||
|
||||
// ── MOD-1: RecruiterCommission ────────────────────
|
||||
recruiterRules: (p?: Partial<CommissionSearchParam>) =>
|
||||
unwrap<PageResponse<RecruiterRuleRow>>(
|
||||
api.get('/api/recruiter-commissions/rules', { params: p }),
|
||||
),
|
||||
recruiterRuleCreate: (body: Partial<RecruiterRuleRow>) =>
|
||||
unwrap<RecruiterRuleRow>(api.post('/api/recruiter-commissions/rules', body)),
|
||||
recruiterRuleUpdate: (id: number, body: Partial<RecruiterRuleRow>) =>
|
||||
unwrap<RecruiterRuleRow>(api.put(`/api/recruiter-commissions/rules/${id}`, body)),
|
||||
recruiterRuleDelete: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/recruiter-commissions/rules/${id}`)),
|
||||
recruiterLedgers: (p: CommissionSearchParam) =>
|
||||
unwrap<PageResponse<RecruiterLedgerRow>>(
|
||||
api.get('/api/recruiter-commissions/ledgers', { params: p }),
|
||||
),
|
||||
|
||||
// ── MOD-3: ClawbackInstallment ────────────────────
|
||||
clawbackInstallmentList: (p?: Partial<CommissionSearchParam & { receivableId?: number; dueMonth?: string }>) =>
|
||||
unwrap<PageResponse<ClawbackInstallmentRow>>(
|
||||
api.get('/api/clawback-installments', { params: p }),
|
||||
),
|
||||
clawbackInstallmentGenerate: (body: ClawbackInstallmentGenerateReq) =>
|
||||
unwrap<ClawbackInstallmentRow[]>(api.post('/api/clawback-installments/generate', body)),
|
||||
clawbackInstallmentCancel: (planId: number) =>
|
||||
unwrap<void>(api.put(`/api/clawback-installments/${planId}/cancel`)),
|
||||
|
||||
// ── MOD-4: LapsedReinstatement ────────────────────
|
||||
lapsedReinstatementList: (p?: Partial<CommissionSearchParam & { status?: string }>) =>
|
||||
unwrap<PageResponse<LapsedReinstatementRow>>(
|
||||
api.get('/api/lapsed-reinstatements', { params: p }),
|
||||
),
|
||||
lapsedReinstatementCreate: (body: LapsedReinstatementSaveReq) =>
|
||||
unwrap<LapsedReinstatementRow>(api.post('/api/lapsed-reinstatements', body)),
|
||||
lapsedReinstatementRepay: (recoveryId: number) =>
|
||||
unwrap<void>(api.put(`/api/lapsed-reinstatements/${recoveryId}/repay`)),
|
||||
lapsedReinstatementCancel: (recoveryId: number) =>
|
||||
unwrap<void>(api.put(`/api/lapsed-reinstatements/${recoveryId}/cancel`)),
|
||||
|
||||
// ── MOD-6: StatuteConfig ──────────────────────────
|
||||
statuteConfigs: () =>
|
||||
unwrap<PageResponse<StatuteConfigRow>>(api.get('/api/statute/configs')),
|
||||
statuteConfigUpdate: (configId: number, body: Partial<StatuteConfigRow>) =>
|
||||
unwrap<StatuteConfigRow>(api.put(`/api/statute/configs/${configId}`, body)),
|
||||
statuteExpire: (baseMonth: string) =>
|
||||
unwrap<{ expiredCount: number }>(api.post('/api/statute/expire', null, { params: { baseMonth } })),
|
||||
|
||||
// ── MOD-7: ChargebackWaiver ───────────────────────
|
||||
chargebackWaiverList: (p?: Partial<CommissionSearchParam & { approvalStatus?: string }>) =>
|
||||
unwrap<PageResponse<ChargebackWaiverRow>>(
|
||||
api.get('/api/chargeback-waivers', { params: p }),
|
||||
),
|
||||
chargebackWaiverCreate: (body: ChargebackWaiverSaveReq) =>
|
||||
unwrap<ChargebackWaiverRow>(api.post('/api/chargeback-waivers', body)),
|
||||
chargebackWaiverApprove: (requestId: number) =>
|
||||
unwrap<void>(api.put(`/api/chargeback-waivers/${requestId}/approve`)),
|
||||
chargebackWaiverReject: (requestId: number, reason?: string) =>
|
||||
unwrap<void>(api.put(`/api/chargeback-waivers/${requestId}/reject`, { reason })),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface ContractResp {
|
||||
status: string;
|
||||
statusName?: string;
|
||||
contractDate: string;
|
||||
salesChannel?: string;
|
||||
salesChannelName?: string;
|
||||
}
|
||||
|
||||
export interface ContractSearchParam {
|
||||
@@ -45,6 +47,7 @@ export interface ContractSaveReq {
|
||||
coveragePeriod?: number;
|
||||
contractDate?: string;
|
||||
effectiveDate?: string;
|
||||
salesChannel?: string;
|
||||
}
|
||||
|
||||
export const contractApi = {
|
||||
|
||||
@@ -45,6 +45,105 @@ export const settleApi = {
|
||||
unwrap<void>(api.put(`/api/settle/${settleId}/release`)),
|
||||
};
|
||||
|
||||
// ── MOD-2: AdvancePayment ─────────────────────────
|
||||
export interface AdvancePaymentRow extends Record<string, unknown> {
|
||||
advanceId: number;
|
||||
agentId: number;
|
||||
agentName?: string;
|
||||
advanceMonth: string;
|
||||
principalAmount: number;
|
||||
recoveredAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface AdvancePaymentSaveReq {
|
||||
agentId: number;
|
||||
advanceMonth: string;
|
||||
principalAmount: number;
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface AdvanceRecoveryRow extends Record<string, unknown> {
|
||||
recoveryId: number;
|
||||
advanceId: number;
|
||||
settleMonth: string;
|
||||
recoveryAmount: number;
|
||||
}
|
||||
|
||||
export interface AdvancePaymentSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
status?: string;
|
||||
advanceMonth?: string;
|
||||
}
|
||||
|
||||
// ── MOD-5: CarrierReconIn ─────────────────────────
|
||||
export interface CarrierReconInRow extends Record<string, unknown> {
|
||||
reconId: number;
|
||||
companyId: number;
|
||||
companyName?: string;
|
||||
settleMonth: string;
|
||||
fileName?: string;
|
||||
recordCount: number;
|
||||
reportedTotal: number;
|
||||
systemTotal: number;
|
||||
varianceAmount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CarrierReconInSaveReq {
|
||||
companyId: number;
|
||||
settleMonth: string;
|
||||
fileName?: string;
|
||||
recordCount: number;
|
||||
reportedTotal: number;
|
||||
}
|
||||
|
||||
export interface ReconciliationVarianceRow extends Record<string, unknown> {
|
||||
varianceId: number;
|
||||
reconId: number;
|
||||
policyNo?: string;
|
||||
expectedAmount?: number;
|
||||
reportedAmount?: number;
|
||||
varianceReason?: string;
|
||||
}
|
||||
|
||||
export interface CarrierReconSearchParam {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
settleMonth?: string;
|
||||
companyId?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const advancePaymentApi = {
|
||||
list: (p: AdvancePaymentSearchParam) =>
|
||||
unwrap<PageResponse<AdvancePaymentRow>>(api.get('/api/advance-payments', { params: p })),
|
||||
create: (body: AdvancePaymentSaveReq) =>
|
||||
unwrap<AdvancePaymentRow>(api.post('/api/advance-payments', body)),
|
||||
waive: (advanceId: number) =>
|
||||
unwrap<void>(api.put(`/api/advance-payments/${advanceId}/waive`)),
|
||||
recoveries: (advanceId: number) =>
|
||||
unwrap<AdvanceRecoveryRow[]>(api.get(`/api/advance-payments/${advanceId}/recoveries`)),
|
||||
};
|
||||
|
||||
export const carrierReconInApi = {
|
||||
list: (p: CarrierReconSearchParam) =>
|
||||
unwrap<PageResponse<CarrierReconInRow>>(api.get('/api/carrier-recon-in', { params: p })),
|
||||
create: (body: CarrierReconInSaveReq) =>
|
||||
unwrap<CarrierReconInRow>(api.post('/api/carrier-recon-in', body)),
|
||||
reconcile: (reconId: number) =>
|
||||
unwrap<CarrierReconInRow>(api.post(`/api/carrier-recon-in/${reconId}/reconcile`)),
|
||||
approve: (reconId: number) =>
|
||||
unwrap<void>(api.put(`/api/carrier-recon-in/${reconId}/approve`)),
|
||||
variances: (reconId: number) =>
|
||||
unwrap<ReconciliationVarianceRow[]>(api.get(`/api/carrier-recon-in/${reconId}/variances`)),
|
||||
};
|
||||
|
||||
export interface BatchJobLog {
|
||||
jobId: number;
|
||||
jobName: string;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Select, Space, 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,
|
||||
ChargebackWaiverRow,
|
||||
ChargebackWaiverSaveReq,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'REQUESTED', label: '신청' },
|
||||
{ value: 'APPROVED', label: '승인' },
|
||||
{ value: 'REJECTED', label: '반려' },
|
||||
];
|
||||
|
||||
const COLS: ColDef<ChargebackWaiverRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'contractNo', headerName: '증권번호', width: 140 },
|
||||
{ field: 'originalChargeback', headerName: '원환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'waiverAmount', headerName: '감면액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'reason', headerName: '사유', flex: 1 },
|
||||
{ field: 'approvalStatus', headerName: '승인상태', width: 110 },
|
||||
{ field: 'approvedAt', headerName: '승인일시', width: 160 },
|
||||
];
|
||||
|
||||
export default function ChargebackWaiver() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<CommissionSearchParam & { approvalStatus?: string }>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<ChargebackWaiverRow | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [form] = Form.useForm<ChargebackWaiverSaveReq>();
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['chargebackWaiver', 'list', searchParams],
|
||||
queryFn: () => commissionApi.chargebackWaiverList(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: ChargebackWaiverSaveReq) => commissionApi.chargebackWaiverCreate(body),
|
||||
onSuccess: () => {
|
||||
message.success('신청 완료');
|
||||
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (requestId: number) => commissionApi.chargebackWaiverApprove(requestId),
|
||||
onSuccess: () => {
|
||||
message.success('승인 완료');
|
||||
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ requestId, reason }: { requestId: number; reason: string }) =>
|
||||
commissionApi.chargebackWaiverReject(requestId, reason),
|
||||
onSuccess: () => {
|
||||
message.success('반려 완료');
|
||||
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
||||
setRejectTarget(null);
|
||||
setRejectReason('');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleApprove = (row: ChargebackWaiverRow) => Modal.confirm({
|
||||
title: '승인',
|
||||
content: `${row.agentName} 감면 신청(감면액: ${fmt(row.waiverAmount)})을 승인하시겠습니까?`,
|
||||
onOk: () => approveMutation.mutateAsync(row.requestId),
|
||||
});
|
||||
|
||||
const openReject = (row: ChargebackWaiverRow) => {
|
||||
setRejectTarget(row);
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (!rejectReason.trim()) { message.warning('반려 사유를 입력하세요'); return; }
|
||||
rejectMutation.mutate({ requestId: rejectTarget!.requestId, reason: rejectReason });
|
||||
};
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
originalChargeback: rows.reduce((s, r) => s + (r.originalChargeback ?? 0), 0),
|
||||
waiverAmount: rows.reduce((s, r) => s + (r.waiverAmount ?? 0), 0),
|
||||
} as Partial<ChargebackWaiverRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="환수감면신청" description="환수 감면 신청·승인·반려 워크플로">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/chargeback-waivers 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'code', name: 'approvalStatus', label: '승인상태', span: 6, groupCode: 'WAIVER_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ ...v as typeof searchParams, pageSize: 200 })}
|
||||
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="CHARGEBACK_WAIVER" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 감면 신청
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ChargebackWaiverRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLS,
|
||||
{
|
||||
headerName: '액션', width: 180, pinned: 'right',
|
||||
cellRenderer: (p: { data: ChargebackWaiverRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="CHARGEBACK_WAIVER" permCode="UPDATE" size="small"
|
||||
disabled={p.data.approvalStatus !== 'REQUESTED'}
|
||||
onClick={() => handleApprove(p.data)}
|
||||
>
|
||||
승인
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="CHARGEBACK_WAIVER" permCode="UPDATE" size="small" danger
|
||||
disabled={p.data.approvalStatus !== 'REQUESTED'}
|
||||
onClick={() => openReject(p.data)}
|
||||
>
|
||||
반려
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="requestId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 신청 모달 */}
|
||||
<Modal
|
||||
title="감면 신청"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
|
||||
okText="신청"
|
||||
cancelText="취소"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractId" label="계약 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<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="예: 202601" />
|
||||
</Form.Item>
|
||||
<Form.Item name="originalChargeback" label="원 환수액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="waiverAmount" label="감면 요청액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="사유">
|
||||
<Input.TextArea rows={3} placeholder="감면 사유를 입력하세요" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 반려 사유 모달 */}
|
||||
<Modal
|
||||
title="반려 사유 입력"
|
||||
open={!!rejectTarget}
|
||||
onOk={handleReject}
|
||||
onCancel={() => { setRejectTarget(null); setRejectReason(''); }}
|
||||
okText="반려"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="취소"
|
||||
confirmLoading={rejectMutation.isPending}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="반려 사유를 입력하세요"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Space, 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 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,
|
||||
ClawbackInstallmentRow,
|
||||
ClawbackInstallmentGenerateReq,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
interface SearchParam {
|
||||
receivableId?: number;
|
||||
dueMonth?: string;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const COLS: ColDef<ClawbackInstallmentRow>[] = [
|
||||
{ field: 'receivableId', headerName: '채권ID', width: 110, pinned: 'left' },
|
||||
{ field: 'installmentNo', headerName: '회차', width: 80, type: 'numericColumn' },
|
||||
{ field: 'installmentAmount', headerName: '분할금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'dueMonth', headerName: '납부월', width: 110 },
|
||||
{ field: 'status', headerName: '상태', width: 110 },
|
||||
{ field: 'recoveredAt', headerName: '회수일시', width: 160 },
|
||||
];
|
||||
|
||||
export default function ClawbackInstallment() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<SearchParam>({ pageSize: 200 });
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [form] = Form.useForm<ClawbackInstallmentGenerateReq>();
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['clawbackInstallment', 'list', searchParams],
|
||||
queryFn: () => commissionApi.clawbackInstallmentList(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: (body: ClawbackInstallmentGenerateReq) =>
|
||||
commissionApi.clawbackInstallmentGenerate(body),
|
||||
onSuccess: (result) => {
|
||||
message.success(`분할 계획 ${result.length}건 생성 완료`);
|
||||
qc.invalidateQueries({ queryKey: ['clawbackInstallment', 'list'] });
|
||||
setGenerateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (planId: number) => commissionApi.clawbackInstallmentCancel(planId),
|
||||
onSuccess: () => {
|
||||
message.success('취소 완료');
|
||||
qc.invalidateQueries({ queryKey: ['clawbackInstallment', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const values = await form.validateFields();
|
||||
generateMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleCancel = (row: ClawbackInstallmentRow) => Modal.confirm({
|
||||
title: '회차 취소',
|
||||
content: `채권 ${row.receivableId} / ${row.installmentNo}회차를 취소하시겠습니까?`,
|
||||
onOk: () => cancelMutation.mutateAsync(row.planId),
|
||||
});
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
installmentNo: undefined,
|
||||
installmentAmount: rows.reduce((s, r) => s + (r.installmentAmount ?? 0), 0),
|
||||
dueMonth: '합계',
|
||||
} as Partial<ClawbackInstallmentRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="환수분할상환" description="대액 환수채권 분할 납부 계획 관리">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/clawback-installments 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'receivableId', label: '채권ID', span: 6 },
|
||||
{ type: 'month', name: 'dueMonth', label: '납부월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ ...v as SearchParam, pageSize: 200 })}
|
||||
onReset={() => setSearchParams({ pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="CLAWBACK_INSTALLMENT" permCode="CREATE" type="primary" onClick={() => setGenerateOpen(true)}>
|
||||
+ 분할 계획 생성
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<ClawbackInstallmentRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLS,
|
||||
{
|
||||
headerName: '액션', width: 100, pinned: 'right',
|
||||
cellRenderer: (p: { data: ClawbackInstallmentRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="CLAWBACK_INSTALLMENT" permCode="UPDATE" size="small" danger
|
||||
disabled={p.data.status !== 'SCHEDULED'}
|
||||
onClick={() => handleCancel(p.data)}
|
||||
>
|
||||
취소
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="planId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title="분할 계획 생성"
|
||||
open={generateOpen}
|
||||
onOk={handleGenerate}
|
||||
onCancel={() => { setGenerateOpen(false); form.resetFields(); }}
|
||||
okText="생성"
|
||||
cancelText="취소"
|
||||
confirmLoading={generateMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="receivableId" label="채권 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="환수채권 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="installmentCount" label="분할 회차수" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={2} max={60} placeholder="예: 12" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startMonth" label="시작월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: 202602" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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,
|
||||
LapsedReinstatementRow,
|
||||
LapsedReinstatementSaveReq,
|
||||
CommissionSearchParam,
|
||||
} from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'PENDING', label: '대기' },
|
||||
{ value: 'REPAID', label: '재지급완료' },
|
||||
{ value: 'CANCELLED', label: '취소' },
|
||||
];
|
||||
|
||||
const COLS: ColDef<LapsedReinstatementRow>[] = [
|
||||
{ field: 'reinstatedMonth', headerName: '부활월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'contractNo', headerName: '증권번호', width: 140 },
|
||||
{ field: 'lapsedMonth', headerName: '실효월', width: 110 },
|
||||
{ field: 'withheldAmount', headerName: '보류액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'repaidAmount', headerName: '재지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 110 },
|
||||
];
|
||||
|
||||
export default function LapsedReinstatement() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<CommissionSearchParam & { status?: string }>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm<LapsedReinstatementSaveReq>();
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['lapsedReinstatement', 'list', searchParams],
|
||||
queryFn: () => commissionApi.lapsedReinstatementList(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: LapsedReinstatementSaveReq) => commissionApi.lapsedReinstatementCreate(body),
|
||||
onSuccess: () => {
|
||||
message.success('등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['lapsedReinstatement', 'list'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const repayMutation = useMutation({
|
||||
mutationFn: (recoveryId: number) => commissionApi.lapsedReinstatementRepay(recoveryId),
|
||||
onSuccess: () => {
|
||||
message.success('재지급 처리 완료');
|
||||
qc.invalidateQueries({ queryKey: ['lapsedReinstatement', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (recoveryId: number) => commissionApi.lapsedReinstatementCancel(recoveryId),
|
||||
onSuccess: () => {
|
||||
message.success('취소 완료');
|
||||
qc.invalidateQueries({ queryKey: ['lapsedReinstatement', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleRepay = (row: LapsedReinstatementRow) => Modal.confirm({
|
||||
title: '재지급 처리',
|
||||
content: `${row.agentName} 보류액 ${fmt(row.withheldAmount)}원을 재지급 처리하시겠습니까?`,
|
||||
onOk: () => repayMutation.mutateAsync(row.recoveryId),
|
||||
});
|
||||
|
||||
const handleCancel = (row: LapsedReinstatementRow) => Modal.confirm({
|
||||
title: '취소',
|
||||
content: '해당 부활 재지급 건을 취소하시겠습니까?',
|
||||
onOk: () => cancelMutation.mutateAsync(row.recoveryId),
|
||||
});
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
withheldAmount: rows.reduce((s, r) => s + (r.withheldAmount ?? 0), 0),
|
||||
repaidAmount: rows.reduce((s, r) => s + (r.repaidAmount ?? 0), 0),
|
||||
} as Partial<LapsedReinstatementRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="익월부활재지급" description="실효 계약 부활 시 보류 수수료 재지급 관리">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/lapsed-reinstatements 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'settleMonth', label: '부활월', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', span: 6, groupCode: 'LAPSE_RECOVERY_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ ...v as typeof searchParams, pageSize: 200 })}
|
||||
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="LAPSED_REINSTATEMENT" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 부활 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<LapsedReinstatementRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLS,
|
||||
{
|
||||
headerName: '액션', width: 180, pinned: 'right',
|
||||
cellRenderer: (p: { data: LapsedReinstatementRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="LAPSED_REINSTATEMENT" permCode="UPDATE" size="small"
|
||||
disabled={p.data.status !== 'PENDING'}
|
||||
onClick={() => handleRepay(p.data)}
|
||||
>
|
||||
재지급
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="LAPSED_REINSTATEMENT" permCode="UPDATE" size="small" danger
|
||||
disabled={p.data.status !== 'PENDING'}
|
||||
onClick={() => handleCancel(p.data)}
|
||||
>
|
||||
취소
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="recoveryId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title="부활 재지급 등록"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
|
||||
okText="등록"
|
||||
cancelText="취소"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractId" label="계약 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="lapsedMonth" label="실효월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: 202511" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reinstatedMonth" label="부활월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: 202512" />
|
||||
</Form.Item>
|
||||
<Form.Item name="withheldAmount" label="보류액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Descriptions, Form, Input, InputNumber, Modal, Space, 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 DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { commissionApi, StatuteConfigRow } from '@/api/commission';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const COLS: ColDef<StatuteConfigRow>[] = [
|
||||
{ field: 'entityType', headerName: '대상유형', width: 200 },
|
||||
{ field: 'expirationMonths', headerName: '시효(개월)', width: 130, type: 'numericColumn' },
|
||||
{ field: 'isActive', headerName: '활성', width: 80 },
|
||||
];
|
||||
|
||||
export default function StatuteConfig() {
|
||||
const qc = useQueryClient();
|
||||
const [editTarget, setEditTarget] = useState<StatuteConfigRow | null>(null);
|
||||
const [expireMonth, setExpireMonth] = useState(dayjs().format('YYYYMM'));
|
||||
const [expireResult, setExpireResult] = useState<{ expiredCount: number } | null>(null);
|
||||
const [form] = Form.useForm<Partial<StatuteConfigRow>>();
|
||||
|
||||
const { data: configData, isLoading, isError } = useQuery({
|
||||
queryKey: ['statuteConfig', 'list'],
|
||||
queryFn: () => commissionApi.statuteConfigs(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = configData?.list ?? [];
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ configId, values }: { configId: number; values: Partial<StatuteConfigRow> }) =>
|
||||
commissionApi.statuteConfigUpdate(configId, values),
|
||||
onSuccess: () => {
|
||||
message.success('설정 수정 완료');
|
||||
qc.invalidateQueries({ queryKey: ['statuteConfig', 'list'] });
|
||||
setEditTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
const expireMutation = useMutation({
|
||||
mutationFn: (baseMonth: string) => commissionApi.statuteExpire(baseMonth),
|
||||
onSuccess: (result) => {
|
||||
setExpireResult(result);
|
||||
message.success(`만료 처리 완료 — ${result.expiredCount}건`);
|
||||
qc.invalidateQueries({ queryKey: ['statuteConfig', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const values = await form.validateFields();
|
||||
updateMutation.mutate({ configId: editTarget!.configId, values });
|
||||
};
|
||||
|
||||
const openEdit = (row: StatuteConfigRow) => {
|
||||
setEditTarget(row);
|
||||
form.setFieldsValue({
|
||||
expirationMonths: row.expirationMonths,
|
||||
isActive: row.isActive,
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpire = () => Modal.confirm({
|
||||
title: '만료 실행',
|
||||
content: `기준월 ${expireMonth} 기준으로 시효 만료된 채권을 WRITTEN_OFF 처리합니다. 계속하시겠습니까?`,
|
||||
okType: 'danger',
|
||||
onOk: () => expireMutation.mutateAsync(expireMonth),
|
||||
});
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="환수시효관리" description="환수채권 시효 설정 및 만료 실행">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/statute 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<ProCard style={{ ...cardStyle, marginBottom: 16 }} title="시효 설정">
|
||||
<DataGrid<StatuteConfigRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...COLS,
|
||||
{
|
||||
headerName: '액션', width: 100, pinned: 'right',
|
||||
cellRenderer: (p: { data: StatuteConfigRow }) => (
|
||||
<PermissionButton
|
||||
menuCode="STATUTE_CONFIG" permCode="UPDATE" size="small"
|
||||
onClick={() => openEdit(p.data)}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={200}
|
||||
rowKey="configId"
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<ProCard style={cardStyle} title="만료 실행">
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<span>기준월:</span>
|
||||
<Input
|
||||
value={expireMonth}
|
||||
onChange={(e) => setExpireMonth(e.target.value)}
|
||||
placeholder="YYYYMM"
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode="STATUTE_CONFIG" permCode="UPDATE"
|
||||
type="primary" danger
|
||||
loading={expireMutation.isPending}
|
||||
onClick={handleExpire}
|
||||
>
|
||||
만료 실행
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
{expireResult && (
|
||||
<Descriptions size="small" bordered>
|
||||
<Descriptions.Item label="처리 결과">
|
||||
{expireResult.expiredCount}건이 WRITTEN_OFF 처리되었습니다.
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Space>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title="시효 설정 수정"
|
||||
open={!!editTarget}
|
||||
onOk={handleUpdate}
|
||||
onCancel={() => setEditTarget(null)}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
confirmLoading={updateMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="expirationMonths" label="시효 개월수" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="활성(Y/N)" rules={[{ required: true }]}>
|
||||
<Input placeholder="Y 또는 N" maxLength={1} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,10 @@ import { agentApi } from '@/api/agent';
|
||||
export default function ContractList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
|
||||
const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS');
|
||||
const { data: payCycleCodes = [] } = useCommonCodes('PAY_CYCLE');
|
||||
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
|
||||
const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS');
|
||||
const { data: payCycleCodes = [] } = useCommonCodes('PAY_CYCLE');
|
||||
const { data: salesChannelCodes = [] } = useCommonCodes('SALES_CHANNEL');
|
||||
|
||||
// 등록 모달용 - 상품/설계사 옵션
|
||||
const { data: products } = useQuery({
|
||||
@@ -74,6 +75,10 @@ export default function ContractList() {
|
||||
render: (_, r) => <CodeBadge groupCode="PAY_CYCLE" value={r.payCycle} />,
|
||||
},
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 110, search: false },
|
||||
{
|
||||
title: '판매채널', dataIndex: 'salesChannel', width: 110, search: false,
|
||||
render: (_, r) => r.salesChannelName ?? r.salesChannel ?? '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -138,6 +143,13 @@ export default function ContractList() {
|
||||
<ProFormDatePicker name="contractDate" label="계약일" width="md" />
|
||||
<ProFormDatePicker name="effectiveDate" label="효력발생일" width="md" />
|
||||
</ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="salesChannel"
|
||||
label="판매채널"
|
||||
width="md"
|
||||
options={salesChannelCodes.map((c) => ({ value: c.code, label: c.codeName }))}
|
||||
placeholder="선택 안 함(직판 기본)"
|
||||
/>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, 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 PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import {
|
||||
advancePaymentApi,
|
||||
AdvancePaymentRow,
|
||||
AdvancePaymentSaveReq,
|
||||
AdvanceRecoveryRow,
|
||||
AdvancePaymentSearchParam,
|
||||
} from '@/api/settle';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'OUTSTANDING', label: '미상환' },
|
||||
{ value: 'PARTIAL', label: '부분상환' },
|
||||
{ value: 'CLEARED', label: '완료' },
|
||||
{ value: 'WAIVED', label: '면제' },
|
||||
];
|
||||
|
||||
const LIST_COLS: ColDef<AdvancePaymentRow>[] = [
|
||||
{ field: 'advanceMonth', headerName: '선지급월', width: 110, pinned: 'left' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'principalAmount', headerName: '원금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'recoveredAmount', headerName: '회수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'balance', headerName: '잔액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 110 },
|
||||
{ field: 'memo', headerName: '메모', flex: 1 },
|
||||
];
|
||||
|
||||
const RECOVERY_COLS: ColDef<AdvanceRecoveryRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 120 },
|
||||
{ field: 'recoveryAmount', headerName: '상계액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
];
|
||||
|
||||
export default function AdvancePayment() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<AdvancePaymentSearchParam>({ pageSize: 200 });
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [drawerTarget, setDrawerTarget] = useState<AdvancePaymentRow | null>(null);
|
||||
const [form] = Form.useForm<AdvancePaymentSaveReq>();
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['advancePayment', 'list', searchParams],
|
||||
queryFn: () => advancePaymentApi.list(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: recoveries = [], isLoading: recLoading } = useQuery({
|
||||
queryKey: ['advancePayment', 'recoveries', drawerTarget?.advanceId],
|
||||
queryFn: () => advancePaymentApi.recoveries(drawerTarget!.advanceId),
|
||||
enabled: !!drawerTarget,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: AdvancePaymentSaveReq) => advancePaymentApi.create(body),
|
||||
onSuccess: () => {
|
||||
message.success('등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['advancePayment', 'list'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const waiveMutation = useMutation({
|
||||
mutationFn: (advanceId: number) => advancePaymentApi.waive(advanceId),
|
||||
onSuccess: () => {
|
||||
message.success('면제 처리 완료');
|
||||
qc.invalidateQueries({ queryKey: ['advancePayment', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleWaive = (row: AdvancePaymentRow) => Modal.confirm({
|
||||
title: '면제 처리',
|
||||
content: `${row.agentName} 의 선지급 잔액 ${fmt(row.balance)}원을 면제하시겠습니까?`,
|
||||
onOk: () => waiveMutation.mutateAsync(row.advanceId),
|
||||
});
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
agentName: '합계',
|
||||
principalAmount: rows.reduce((s, r) => s + (r.principalAmount ?? 0), 0),
|
||||
recoveredAmount: rows.reduce((s, r) => s + (r.recoveredAmount ?? 0), 0),
|
||||
balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0),
|
||||
} as Partial<AdvancePaymentRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="선지급정산" description="선지급금 등록·회수·잔액 관리">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/advance-payments 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'advanceMonth', label: '선지급월', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', span: 6, groupCode: 'ADVANCE_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ ...v as AdvancePaymentSearchParam, pageSize: 200 })}
|
||||
onReset={() => setSearchParams({ pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="ADVANCE_PAYMENT" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 선지급 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<AdvancePaymentRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...LIST_COLS,
|
||||
{
|
||||
headerName: '액션', width: 180, pinned: 'right',
|
||||
cellRenderer: (p: { data: AdvancePaymentRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="ADVANCE_PAYMENT" permCode="READ" size="small"
|
||||
onClick={() => setDrawerTarget(p.data)}
|
||||
>
|
||||
상계내역
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="ADVANCE_PAYMENT" permCode="UPDATE" size="small"
|
||||
disabled={p.data.status === 'CLEARED' || p.data.status === 'WAIVED'}
|
||||
onClick={() => handleWaive(p.data)}
|
||||
>
|
||||
면제
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="advanceId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 등록 모달 */}
|
||||
<Modal
|
||||
title="선지급 등록"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
|
||||
okText="등록"
|
||||
cancelText="취소"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="설계사 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="advanceMonth" label="선지급월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: 202601" />
|
||||
</Form.Item>
|
||||
<Form.Item name="principalAmount" label="선지급 원금" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 상계내역 Drawer */}
|
||||
<Drawer
|
||||
title={drawerTarget ? `상계내역 — ${drawerTarget.agentName} / ${drawerTarget.advanceMonth}` : '상계내역'}
|
||||
open={!!drawerTarget}
|
||||
onClose={() => setDrawerTarget(null)}
|
||||
width={480}
|
||||
>
|
||||
{drawerTarget && (
|
||||
<>
|
||||
<Descriptions size="small" column={2} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="원금">{fmt(drawerTarget.principalAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="회수액">{fmt(drawerTarget.recoveredAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="잔액">{fmt(drawerTarget.balance)}</Descriptions.Item>
|
||||
<Descriptions.Item label="상태">{drawerTarget.status}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<DataGrid<AdvanceRecoveryRow>
|
||||
rows={recoveries}
|
||||
columns={RECOVERY_COLS}
|
||||
loading={recLoading}
|
||||
height={300}
|
||||
rowKey="recoveryId"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Descriptions, Drawer, Form, Input, InputNumber, Modal, Space, 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 {
|
||||
carrierReconInApi,
|
||||
CarrierReconInRow,
|
||||
CarrierReconInSaveReq,
|
||||
ReconciliationVarianceRow,
|
||||
CarrierReconSearchParam,
|
||||
} from '@/api/settle';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'RECEIVED', label: '입수' },
|
||||
{ value: 'MATCHED', label: '일치' },
|
||||
{ value: 'VARIANCE', label: '차이' },
|
||||
{ value: 'APPROVED', label: '승인' },
|
||||
];
|
||||
|
||||
const LIST_COLS: ColDef<CarrierReconInRow>[] = [
|
||||
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
|
||||
{ field: 'companyName', headerName: '보험사', width: 130 },
|
||||
{ field: 'fileName', headerName: '파일명', flex: 1 },
|
||||
{ field: 'recordCount', headerName: '건수', width: 80, type: 'numericColumn' },
|
||||
{ field: 'reportedTotal', headerName: '보고합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'systemTotal', headerName: '시스템합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'varianceAmount', headerName: '차이금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'status', headerName: '상태', width: 110 },
|
||||
];
|
||||
|
||||
const VARIANCE_COLS: ColDef<ReconciliationVarianceRow>[] = [
|
||||
{ field: 'policyNo', headerName: '증권번호', width: 140 },
|
||||
{ field: 'expectedAmount', headerName: '시스템금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'reportedAmount', headerName: '보고금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'varianceReason', headerName: '차이사유', flex: 1 },
|
||||
];
|
||||
|
||||
export default function CarrierReconIn() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<CarrierReconSearchParam>({
|
||||
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
|
||||
});
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [drawerTarget, setDrawerTarget] = useState<CarrierReconInRow | null>(null);
|
||||
const [form] = Form.useForm<CarrierReconInSaveReq>();
|
||||
|
||||
const { data: listData, isLoading, isError } = useQuery({
|
||||
queryKey: ['carrierReconIn', 'list', searchParams],
|
||||
queryFn: () => carrierReconInApi.list(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: variances = [], isLoading: varLoading } = useQuery({
|
||||
queryKey: ['carrierReconIn', 'variances', drawerTarget?.reconId],
|
||||
queryFn: () => carrierReconInApi.variances(drawerTarget!.reconId),
|
||||
enabled: !!drawerTarget,
|
||||
});
|
||||
|
||||
const rows = listData?.list ?? [];
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CarrierReconInSaveReq) => carrierReconInApi.create(body),
|
||||
onSuccess: () => {
|
||||
message.success('대사 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['carrierReconIn', 'list'] });
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
},
|
||||
});
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: (reconId: number) => carrierReconInApi.reconcile(reconId),
|
||||
onSuccess: () => {
|
||||
message.success('대사 실행 완료');
|
||||
qc.invalidateQueries({ queryKey: ['carrierReconIn', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (reconId: number) => carrierReconInApi.approve(reconId),
|
||||
onSuccess: () => {
|
||||
message.success('승인 완료');
|
||||
qc.invalidateQueries({ queryKey: ['carrierReconIn', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const values = await form.validateFields();
|
||||
createMutation.mutate(values);
|
||||
};
|
||||
|
||||
const handleReconcile = (row: CarrierReconInRow) => Modal.confirm({
|
||||
title: '대사 실행',
|
||||
content: `${row.companyName} / ${row.settleMonth} 대사를 실행하시겠습니까?`,
|
||||
onOk: () => reconcileMutation.mutateAsync(row.reconId),
|
||||
});
|
||||
|
||||
const handleApprove = (row: CarrierReconInRow) => Modal.confirm({
|
||||
title: '승인',
|
||||
content: '해당 대사 결과를 승인하시겠습니까?',
|
||||
onOk: () => approveMutation.mutateAsync(row.reconId),
|
||||
});
|
||||
|
||||
const pinnedRow = rows.length > 0 ? {
|
||||
companyName: '합계',
|
||||
reportedTotal: rows.reduce((s, r) => s + (r.reportedTotal ?? 0), 0),
|
||||
systemTotal: rows.reduce((s, r) => s + (r.systemTotal ?? 0), 0),
|
||||
varianceAmount: rows.reduce((s, r) => s + (r.varianceAmount ?? 0), 0),
|
||||
} as Partial<CarrierReconInRow> : undefined;
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
return (
|
||||
<PageContainer title="정산대사입수" description="보험사 통보 정산자료 vs GA 원장 대사">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/carrier-recon-in 을 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
|
||||
{ type: 'code', name: 'status', label: '상태', span: 6, groupCode: 'RECON_IN_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ ...v as CarrierReconSearchParam, pageSize: 200 })}
|
||||
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="CARRIER_RECON_IN" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||
+ 대사 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<CarrierReconInRow>
|
||||
rows={rows}
|
||||
columns={[
|
||||
...LIST_COLS,
|
||||
{
|
||||
headerName: '액션', width: 220, pinned: 'right',
|
||||
cellRenderer: (p: { data: CarrierReconInRow }) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="CARRIER_RECON_IN" permCode="UPDATE" size="small"
|
||||
disabled={p.data.status === 'APPROVED'}
|
||||
onClick={() => handleReconcile(p.data)}
|
||||
>
|
||||
대사실행
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="CARRIER_RECON_IN" permCode="UPDATE" size="small"
|
||||
disabled={p.data.status === 'APPROVED' || p.data.status === 'RECEIVED'}
|
||||
onClick={() => handleApprove(p.data)}
|
||||
>
|
||||
승인
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="CARRIER_RECON_IN" permCode="READ" size="small"
|
||||
onClick={() => setDrawerTarget(p.data)}
|
||||
>
|
||||
차이내역
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="reconId"
|
||||
pinnedBottomRow={pinnedRow}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 등록 모달 */}
|
||||
<Modal
|
||||
title="대사 등록"
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => { setCreateOpen(false); form.resetFields(); }}
|
||||
okText="등록"
|
||||
cancelText="취소"
|
||||
confirmLoading={createMutation.isPending}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="companyId" label="보험사 ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: 202601" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fileName" label="파일명">
|
||||
<Input placeholder="업로드 파일명" />
|
||||
</Form.Item>
|
||||
<Form.Item name="recordCount" label="건수" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reportedTotal" label="보고 합계금액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 차이내역 Drawer */}
|
||||
<Drawer
|
||||
title={drawerTarget ? `차이내역 — ${drawerTarget.companyName} / ${drawerTarget.settleMonth}` : '차이내역'}
|
||||
open={!!drawerTarget}
|
||||
onClose={() => setDrawerTarget(null)}
|
||||
width={560}
|
||||
>
|
||||
{drawerTarget && (
|
||||
<>
|
||||
<Descriptions size="small" column={2} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="보고합계">{fmt(drawerTarget.reportedTotal)}</Descriptions.Item>
|
||||
<Descriptions.Item label="시스템합계">{fmt(drawerTarget.systemTotal)}</Descriptions.Item>
|
||||
<Descriptions.Item label="차이금액">{fmt(drawerTarget.varianceAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="상태">{drawerTarget.status}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<DataGrid<ReconciliationVarianceRow>
|
||||
rows={variances}
|
||||
columns={VARIANCE_COLS}
|
||||
loading={varLoading}
|
||||
height={380}
|
||||
rowKey="varianceId"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user