2026-06-12 23:40:28 +09:00
|
|
|
/**
|
|
|
|
|
* [환수감면신청] 화면
|
|
|
|
|
* - 업무: 설계사에게 부과된 "환수액"의 일부/전부를 감면해 달라는 신청을 등록하고, 관리자가 승인/반려하는 워크플로 화면.
|
|
|
|
|
* - 호출 API: commissionApi.chargebackWaiverList(조회), chargebackWaiverCreate(신청),
|
|
|
|
|
* chargebackWaiverApprove(승인), chargebackWaiverReject(반려).
|
|
|
|
|
* - 주요 상태: searchParams(검색조건), createOpen(신청 모달), rejectTarget/rejectReason(반려 모달).
|
|
|
|
|
* - 도메인 용어:
|
|
|
|
|
* - 환수: 계약 해지 등으로 이미 지급한 수수료를 회수하는 것.
|
|
|
|
|
* - 감면(waiver): 환수액 중 일부를 사정 참작해 면제해 주는 것.
|
|
|
|
|
* - 승인상태: 신청(REQUESTED) -> 승인(APPROVED) 또는 반려(REJECTED).
|
|
|
|
|
*/
|
2026-06-06 17:32:25 +09:00
|
|
|
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: '반려' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 그리드 컬럼: 감면 신청 목록(원환수액 대비 감면액, 사유, 승인상태)
|
2026-06-06 17:32:25 +09:00
|
|
|
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>();
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 감면 신청 목록 조회(읽기). 검색조건 변경 시 자동 재조회
|
2026-06-06 17:32:25 +09:00
|
|
|
const { data: listData, isLoading, isError } = useQuery({
|
|
|
|
|
queryKey: ['chargebackWaiver', 'list', searchParams],
|
|
|
|
|
queryFn: () => commissionApi.chargebackWaiverList(searchParams),
|
|
|
|
|
retry: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rows = listData?.list ?? [];
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 감면 신청 등록 mutation: 성공 시 목록 새로고침 + 모달 닫기
|
2026-06-06 17:32:25 +09:00
|
|
|
const createMutation = useMutation({
|
|
|
|
|
mutationFn: (body: ChargebackWaiverSaveReq) => commissionApi.chargebackWaiverCreate(body),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
message.success('신청 완료');
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
|
|
|
|
setCreateOpen(false);
|
|
|
|
|
form.resetFields();
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 신청 승인 mutation
|
2026-06-06 17:32:25 +09:00
|
|
|
const approveMutation = useMutation({
|
|
|
|
|
mutationFn: (requestId: number) => commissionApi.chargebackWaiverApprove(requestId),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
message.success('승인 완료');
|
|
|
|
|
qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 신청 반려 mutation(사유 필요)
|
2026-06-06 17:32:25 +09:00
|
|
|
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('');
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 신청 모달 "신청" 버튼: 폼 검증 후 등록
|
2026-06-06 17:32:25 +09:00
|
|
|
const handleCreate = async () => {
|
|
|
|
|
const values = await form.validateFields();
|
|
|
|
|
createMutation.mutate(values);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 승인 버튼: 확인창 후 승인 처리
|
2026-06-06 17:32:25 +09:00
|
|
|
const handleApprove = (row: ChargebackWaiverRow) => Modal.confirm({
|
|
|
|
|
title: '승인',
|
|
|
|
|
content: `${row.agentName} 감면 신청(감면액: ${fmt(row.waiverAmount)})을 승인하시겠습니까?`,
|
|
|
|
|
onOk: () => approveMutation.mutateAsync(row.requestId),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 반려 모달 열기(대상 행 지정)
|
2026-06-06 17:32:25 +09:00
|
|
|
const openReject = (row: ChargebackWaiverRow) => {
|
|
|
|
|
setRejectTarget(row);
|
|
|
|
|
setRejectReason('');
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-12 23:40:28 +09:00
|
|
|
// 반려 확정: 사유 필수 검증 후 반려 처리
|
2026-06-06 17:32:25 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|