1
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ProTable,
|
||||
type ActionType,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import DatePicker from 'antd/es/date-picker';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import {
|
||||
disputeApi,
|
||||
DisputeResp,
|
||||
DisputeSaveReq,
|
||||
DisputeSearchParam,
|
||||
} from '@/api/dispute';
|
||||
|
||||
const { MonthPicker } = DatePicker;
|
||||
|
||||
// ── 상태 Badge 색상 정의 ─────────────────────────────────────────────────────
|
||||
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||
PENDING: { color: 'orange', label: '대기' },
|
||||
REVIEWING: { color: 'blue', label: '검토중' },
|
||||
APPROVED: { color: 'green', label: '승인' },
|
||||
REJECTED: { color: 'red', label: '반려' },
|
||||
};
|
||||
|
||||
const STATUS_ENUM: Record<string, { text: string }> = {
|
||||
PENDING: { text: '대기' },
|
||||
REVIEWING: { text: '검토중' },
|
||||
APPROVED: { text: '승인' },
|
||||
REJECTED: { text: '반려' },
|
||||
};
|
||||
|
||||
// ── 등록 폼 내부 타입 ────────────────────────────────────────────────────────
|
||||
interface RegisterFormValues {
|
||||
agentId: number;
|
||||
settleMonthPicker?: unknown;
|
||||
settleId?: number;
|
||||
disputedAmount?: number;
|
||||
claimAmount?: number;
|
||||
reason: string;
|
||||
evidenceUrl?: string;
|
||||
}
|
||||
|
||||
export default function CommissionDispute() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
// 이의신청 등록 모달
|
||||
const [registerOpen, setRegisterOpen] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [registerForm] = Form.useForm<RegisterFormValues>();
|
||||
|
||||
// 승인 모달
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [approveTarget, setApproveTarget] = useState<DisputeResp | null>(null);
|
||||
const [approveForm] = Form.useForm<{ resolution: string; adjustmentAmount?: number }>();
|
||||
|
||||
// 반려 모달
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<DisputeResp | null>(null);
|
||||
const [rejectForm] = Form.useForm<{ resolution: string }>();
|
||||
|
||||
// ── 등록 처리 ────────────────────────────────────────────────────────────────
|
||||
const handleRegister = async () => {
|
||||
const values = await registerForm.validateFields();
|
||||
const payload: DisputeSaveReq = {
|
||||
agentId: values.agentId,
|
||||
settleMonth: values.settleMonthPicker
|
||||
? dayjs(values.settleMonthPicker as Parameters<typeof dayjs>[0]).format('YYYYMM')
|
||||
: '',
|
||||
settleId: values.settleId,
|
||||
disputedAmount: values.disputedAmount,
|
||||
claimAmount: values.claimAmount,
|
||||
reason: values.reason,
|
||||
evidenceUrl: values.evidenceUrl,
|
||||
};
|
||||
setRegistering(true);
|
||||
try {
|
||||
await disputeApi.create(payload);
|
||||
message.success('이의신청이 등록되었습니다');
|
||||
setRegisterOpen(false);
|
||||
registerForm.resetFields();
|
||||
actionRef.current?.reload();
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 검토시작 처리 ────────────────────────────────────────────────────────────
|
||||
const handleReview = (record: DisputeResp) => {
|
||||
Modal.confirm({
|
||||
title: '검토 시작',
|
||||
content: `"${record.agentName}"의 이의신청을 검토 상태로 변경하시겠습니까?`,
|
||||
okText: '검토시작',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await disputeApi.review(record.disputeId);
|
||||
message.success('검토 상태로 변경되었습니다');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── 승인 처리 ────────────────────────────────────────────────────────────────
|
||||
const openApprove = (record: DisputeResp) => {
|
||||
setApproveTarget(record);
|
||||
approveForm.resetFields();
|
||||
setApproveOpen(true);
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approveTarget) return;
|
||||
const values = await approveForm.validateFields();
|
||||
setApproving(true);
|
||||
try {
|
||||
await disputeApi.approve(
|
||||
approveTarget.disputeId,
|
||||
values.resolution,
|
||||
values.adjustmentAmount,
|
||||
);
|
||||
message.success('이의신청이 승인되었습니다');
|
||||
setApproveOpen(false);
|
||||
setApproveTarget(null);
|
||||
actionRef.current?.reload();
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 반려 처리 ────────────────────────────────────────────────────────────────
|
||||
const openReject = (record: DisputeResp) => {
|
||||
setRejectTarget(record);
|
||||
rejectForm.resetFields();
|
||||
setRejectOpen(true);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectTarget) return;
|
||||
const { resolution } = await rejectForm.validateFields();
|
||||
setRejecting(true);
|
||||
try {
|
||||
await disputeApi.reject(rejectTarget.disputeId, resolution);
|
||||
message.success('이의신청이 반려되었습니다');
|
||||
setRejectOpen(false);
|
||||
setRejectTarget(null);
|
||||
actionRef.current?.reload();
|
||||
} finally {
|
||||
setRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 컬럼 정의 ────────────────────────────────────────────────────────────────
|
||||
const columns: ProColumns<DisputeResp>[] = [
|
||||
{
|
||||
title: '설계사명',
|
||||
dataIndex: 'agentName',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '조직명',
|
||||
dataIndex: 'orgName',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '정산월',
|
||||
dataIndex: 'settleMonth',
|
||||
width: 90,
|
||||
valueType: 'dateMonth',
|
||||
fieldProps: { format: 'YYYYMM' },
|
||||
},
|
||||
{
|
||||
title: '이의금액',
|
||||
dataIndex: 'disputedAmount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.disputedAmount} />,
|
||||
},
|
||||
{
|
||||
title: '청구금액',
|
||||
dataIndex: 'claimAmount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.claimAmount} />,
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
valueType: 'select',
|
||||
valueEnum: STATUS_ENUM,
|
||||
render: (_, r) => {
|
||||
const info = STATUS_TAG[r.status] ?? { color: 'default', label: r.statusName ?? r.status };
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '이의사유',
|
||||
dataIndex: 'reason',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '검토자',
|
||||
dataIndex: 'reviewerName',
|
||||
width: 90,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '처리자',
|
||||
dataIndex: 'resolvedByName',
|
||||
width: 90,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
dataIndex: 'disputeId',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{r.status === 'PENDING' && (
|
||||
<PermissionButton
|
||||
menuCode="DISPUTE"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="default"
|
||||
onClick={() => handleReview(r)}
|
||||
>
|
||||
검토시작
|
||||
</PermissionButton>
|
||||
)}
|
||||
{r.status === 'REVIEWING' && (
|
||||
<>
|
||||
<PermissionButton
|
||||
menuCode="DISPUTE"
|
||||
permCode="APPROVE"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => openApprove(r)}
|
||||
>
|
||||
승인
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode="DISPUTE"
|
||||
permCode="APPROVE"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => openReject(r)}
|
||||
>
|
||||
반려
|
||||
</PermissionButton>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="수수료 이의신청 관리"
|
||||
description="설계사 수수료 이의신청 접수 · 검토 · 승인/반려"
|
||||
>
|
||||
<ProTable<DisputeResp, DisputeSearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="disputeId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1400 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, settleMonth, status } = params as any;
|
||||
const res = await disputeApi.list({
|
||||
settleMonth: settleMonth
|
||||
? dayjs(settleMonth).format('YYYYMM')
|
||||
: undefined,
|
||||
status,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||
}}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
toolBarRender={() => [
|
||||
<PermissionButton
|
||||
key="register"
|
||||
menuCode="DISPUTE"
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
registerForm.resetFields();
|
||||
setRegisterOpen(true);
|
||||
}}
|
||||
>
|
||||
이의신청 등록
|
||||
</PermissionButton>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── 이의신청 등록 모달 ────────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
title="이의신청 등록"
|
||||
open={registerOpen}
|
||||
onOk={handleRegister}
|
||||
onCancel={() => setRegisterOpen(false)}
|
||||
confirmLoading={registering}
|
||||
okText="등록"
|
||||
cancelText="닫기"
|
||||
destroyOnClose
|
||||
width={560}
|
||||
>
|
||||
<Form form={registerForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item
|
||||
name="agentId"
|
||||
label="설계사 ID"
|
||||
rules={[{ required: true, message: '설계사 ID를 입력해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="설계사 ID" min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="settleMonthPicker"
|
||||
label="정산월"
|
||||
rules={[{ required: true, message: '정산월을 선택해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="YYYYMM" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="settleId" label="정산 ID (선택)">
|
||||
<InputNumber style={{ width: '100%' }} placeholder="정산 ID" min={1} />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item name="disputedAmount" label="이의금액" style={{ flex: 1 }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="이의금액"
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="claimAmount" label="청구금액" style={{ flex: 1 }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="청구금액"
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="reason"
|
||||
label="이의 사유"
|
||||
rules={[{ required: true, message: '이의 사유를 입력해주세요' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="이의 사유를 상세히 입력해주세요" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="evidenceUrl" label="증빙 파일 URL">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ── 승인 모달 ──────────────────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
title={`이의신청 승인 — ${approveTarget?.agentName ?? ''}`}
|
||||
open={approveOpen}
|
||||
onOk={handleApprove}
|
||||
onCancel={() => setApproveOpen(false)}
|
||||
confirmLoading={approving}
|
||||
okText="승인"
|
||||
okButtonProps={{ type: 'primary' }}
|
||||
cancelText="닫기"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={approveForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="resolution"
|
||||
label="처리 결과"
|
||||
rules={[{ required: true, message: '처리 결과를 입력해주세요' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="처리 결과 내용" />
|
||||
</Form.Item>
|
||||
<Form.Item name="adjustmentAmount" label="조정 금액">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="조정 금액 (선택)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ── 반려 모달 ──────────────────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
title={`이의신청 반려 — ${rejectTarget?.agentName ?? ''}`}
|
||||
open={rejectOpen}
|
||||
onOk={handleReject}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
confirmLoading={rejecting}
|
||||
okText="반려"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="닫기"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={rejectForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="resolution"
|
||||
label="처리 결과"
|
||||
rules={[{ required: true, message: '처리 결과를 입력해주세요' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="반려 사유를 입력해주세요" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, Space, message } from 'antd';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormDigit,
|
||||
ProFormSelect,
|
||||
ProFormText,
|
||||
ProTable,
|
||||
type ActionType,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import {
|
||||
deductionApi,
|
||||
DeductionResp,
|
||||
DeductionSaveReq,
|
||||
DeductionSearchParam,
|
||||
} from '@/api/deduction';
|
||||
|
||||
// ── 상수 ─────────────────────────────────────────────────────────────────
|
||||
const MENU_CODE = 'DEDUCTION';
|
||||
|
||||
// YYYYMM 유효성 검사
|
||||
function isValidYYYYMM(v: string): boolean {
|
||||
if (!/^\d{6}$/.test(v)) return false;
|
||||
const year = parseInt(v.slice(0, 4), 10);
|
||||
const month = parseInt(v.slice(4, 6), 10);
|
||||
return year >= 2000 && year <= 2099 && month >= 1 && month <= 12;
|
||||
}
|
||||
|
||||
// ── 컴포넌트 ──────────────────────────────────────────────────────────────
|
||||
export default function DeductionList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modal, setModal] = useState<{ open: boolean; editRecord?: DeductionResp }>({
|
||||
open: false,
|
||||
});
|
||||
|
||||
const { data: statusCodes = [] } = useCommonCodes('DEDUCTION_STATUS');
|
||||
const { data: typeCodes = [] } = useCommonCodes('DEDUCTION_TYPE');
|
||||
|
||||
// ── 열기 ───────────────────────────────────────────────────────────────
|
||||
const openCreate = () => setModal({ open: true });
|
||||
const openEdit = (record: DeductionResp) => setModal({ open: true, editRecord: record });
|
||||
|
||||
// ── 취소 ───────────────────────────────────────────────────────────────
|
||||
const onCancel = (record: DeductionResp) => {
|
||||
Modal.confirm({
|
||||
title: '공제 취소',
|
||||
content: `[${record.agentName}] ${record.settleMonth} 공제를 취소하시겠습니까?`,
|
||||
okType: 'danger',
|
||||
okText: '취소 처리',
|
||||
cancelText: '닫기',
|
||||
onOk: async () => {
|
||||
await deductionApi.cancel(record.deductionId);
|
||||
message.success('취소 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── 저장 ───────────────────────────────────────────────────────────────
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
const req: DeductionSaveReq = {
|
||||
agentId: values.agentId as number,
|
||||
settleMonth: values.settleMonth as string,
|
||||
deductionType: values.deductionType as string,
|
||||
amount: values.amount as number,
|
||||
description: values.description as string | undefined,
|
||||
};
|
||||
|
||||
if (modal.editRecord) {
|
||||
await deductionApi.update(modal.editRecord.deductionId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await deductionApi.create(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── 컬럼 ───────────────────────────────────────────────────────────────
|
||||
const columns: ProColumns<DeductionResp>[] = [
|
||||
{
|
||||
title: '설계사명',
|
||||
dataIndex: 'agentName',
|
||||
width: 110,
|
||||
fixed: 'left',
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '설계사ID',
|
||||
dataIndex: 'agentId',
|
||||
width: 90,
|
||||
hideInTable: true,
|
||||
valueType: 'digit',
|
||||
},
|
||||
{
|
||||
title: '정산월',
|
||||
dataIndex: 'settleMonth',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '차감유형',
|
||||
dataIndex: 'deductionType',
|
||||
width: 120,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
typeCodes.map((c) => [c.code, { text: c.codeName }]),
|
||||
),
|
||||
render: (_, r) => (
|
||||
<CodeBadge groupCode="DEDUCTION_TYPE" value={r.deductionType} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '금액',
|
||||
dataIndex: 'amount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.amount} />,
|
||||
},
|
||||
{
|
||||
title: '설명',
|
||||
dataIndex: 'description',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
statusCodes.map((c) => [c.code, { text: c.codeName }]),
|
||||
),
|
||||
render: (_, r) => (
|
||||
<CodeBadge groupCode="DEDUCTION_STATUS" value={r.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
search: false,
|
||||
render: (_, r) => dayjs(r.createdAt).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
valueType: 'option',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, r) =>
|
||||
r.status !== 'CANCELLED' ? (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
key="edit"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => openEdit(r)}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
key="cancel"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => onCancel(r)}
|
||||
>
|
||||
취소
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 렌더링 ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<PageContainer title="공제차감 관리" description="설계사별 정산월 공제 항목 등록 및 관리">
|
||||
<ProTable<DeductionResp, DeductionSearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="deductionId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, agentId, settleMonth, deductionType, status } =
|
||||
params as DeductionSearchParam & { current?: number; pageSize?: number };
|
||||
const res = await deductionApi.list({
|
||||
agentId,
|
||||
settleMonth,
|
||||
deductionType,
|
||||
status,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||
}}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton
|
||||
key="create"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
onClick={openCreate}
|
||||
>
|
||||
+ 공제 등록
|
||||
</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
{/* ── 등록/수정 모달 ─────────────────────────────────────────────── */}
|
||||
<ModalForm
|
||||
title={modal.editRecord ? '공제 수정' : '공제 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={520}
|
||||
layout="vertical"
|
||||
key={modal.editRecord?.deductionId ?? 'new'}
|
||||
initialValues={
|
||||
modal.editRecord
|
||||
? {
|
||||
agentId: modal.editRecord.agentId,
|
||||
settleMonth: modal.editRecord.settleMonth,
|
||||
deductionType: modal.editRecord.deductionType,
|
||||
amount: modal.editRecord.amount,
|
||||
description: modal.editRecord.description,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onFinish={onSubmit}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText: modal.editRecord ? '수정' : '등록',
|
||||
resetText: '취소',
|
||||
},
|
||||
}}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="agentId"
|
||||
label="설계사 ID"
|
||||
rules={[{ required: true, message: '설계사 ID를 입력하세요' }]}
|
||||
width="sm"
|
||||
min={1}
|
||||
fieldProps={{ precision: 0 }}
|
||||
/>
|
||||
<ProFormText
|
||||
name="settleMonth"
|
||||
label="정산월"
|
||||
rules={[
|
||||
{ required: true, message: '정산월을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
!v || isValidYYYYMM(v)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
|
||||
},
|
||||
]}
|
||||
width="sm"
|
||||
placeholder="예: 202501"
|
||||
fieldProps={{ maxLength: 6 }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="deductionType"
|
||||
label="차감유형"
|
||||
rules={[{ required: true, message: '차감유형을 선택하세요' }]}
|
||||
width="md"
|
||||
options={typeCodes.map((c) => ({ value: c.code, label: c.codeName }))}
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="amount"
|
||||
label="금액"
|
||||
rules={[
|
||||
{ required: true, message: '금액을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
v === undefined || v === null || v > 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('금액은 양수여야 합니다')),
|
||||
},
|
||||
]}
|
||||
width="md"
|
||||
min={1}
|
||||
fieldProps={{
|
||||
precision: 0,
|
||||
formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||
}}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProFormText
|
||||
name="description"
|
||||
label="설명"
|
||||
width="xl"
|
||||
placeholder="공제 사유를 입력하세요"
|
||||
/>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ProTable,
|
||||
type ActionType,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import DatePicker from 'antd/es/date-picker';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import {
|
||||
incentiveApi,
|
||||
IncentiveProgramResp,
|
||||
IncentiveProgramSaveReq,
|
||||
IncentiveProgramSearchParam,
|
||||
} from '@/api/incentive';
|
||||
|
||||
const { MonthPicker } = DatePicker;
|
||||
|
||||
// ── 옵션 상수 ────────────────────────────────────────────────────────────────
|
||||
const TARGET_TYPE_OPTIONS = [
|
||||
{ label: '설계사', value: 'AGENT' },
|
||||
{ label: '조직', value: 'ORG' },
|
||||
{ label: '직급', value: 'GRADE' },
|
||||
];
|
||||
|
||||
const PAY_TYPE_OPTIONS = [
|
||||
{ label: '지급률 (%)', value: 'RATE' },
|
||||
{ label: '고정금액 (원)', value: 'FIXED_AMOUNT' },
|
||||
];
|
||||
|
||||
const TARGET_TYPE_COLOR: Record<string, string> = {
|
||||
AGENT: 'cyan',
|
||||
ORG: 'purple',
|
||||
GRADE: 'geekblue',
|
||||
};
|
||||
|
||||
// ── 폼 내부 타입 ─────────────────────────────────────────────────────────────
|
||||
interface ProgramFormValues extends Omit<IncentiveProgramSaveReq, 'startMonth' | 'endMonth'> {
|
||||
startMonthPicker?: unknown;
|
||||
endMonthPicker?: unknown;
|
||||
}
|
||||
|
||||
export default function IncentiveProgram() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
// 생성/수정 모달
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<IncentiveProgramResp | null>(null);
|
||||
const [form] = Form.useForm<ProgramFormValues>();
|
||||
const payType = Form.useWatch('payType', form);
|
||||
|
||||
// ── 모달 열기 ────────────────────────────────────────────────────────────────
|
||||
const openCreate = () => {
|
||||
setEditTarget(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ payType: 'RATE' });
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (record: IncentiveProgramResp) => {
|
||||
setEditTarget(record);
|
||||
form.setFieldsValue({
|
||||
programName: record.programName,
|
||||
programType: record.programType,
|
||||
targetType: record.targetType as 'AGENT' | 'ORG' | 'GRADE',
|
||||
targetGradeId: record.targetGradeId,
|
||||
targetOrgId: record.targetOrgId,
|
||||
productCategory: record.productCategory,
|
||||
startMonthPicker: record.startMonth
|
||||
? dayjs(record.startMonth, 'YYYYMM')
|
||||
: undefined,
|
||||
endMonthPicker: record.endMonth
|
||||
? dayjs(record.endMonth, 'YYYYMM')
|
||||
: undefined,
|
||||
conditionDesc: record.conditionDesc,
|
||||
payRate: record.payRate,
|
||||
payAmountFixed: record.payAmountFixed,
|
||||
payType: record.payType,
|
||||
budgetAmount: record.budgetAmount,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
// ── 저장 처리 ────────────────────────────────────────────────────────────────
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
const payload: IncentiveProgramSaveReq = {
|
||||
programName: values.programName,
|
||||
programType: values.programType,
|
||||
targetType: values.targetType,
|
||||
targetGradeId: values.targetGradeId,
|
||||
targetOrgId: values.targetOrgId,
|
||||
productCategory: values.productCategory,
|
||||
startMonth: values.startMonthPicker
|
||||
? dayjs(values.startMonthPicker as Parameters<typeof dayjs>[0]).format('YYYYMM')
|
||||
: '',
|
||||
endMonth: values.endMonthPicker
|
||||
? dayjs(values.endMonthPicker as Parameters<typeof dayjs>[0]).format('YYYYMM')
|
||||
: '',
|
||||
conditionDesc: values.conditionDesc,
|
||||
payRate: values.payType === 'RATE' ? values.payRate : undefined,
|
||||
payAmountFixed: values.payType === 'FIXED_AMOUNT' ? values.payAmountFixed : undefined,
|
||||
payType: values.payType,
|
||||
budgetAmount: values.budgetAmount,
|
||||
};
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editTarget) {
|
||||
await incentiveApi.update(editTarget.programId, payload);
|
||||
message.success('시책 프로그램이 수정되었습니다');
|
||||
} else {
|
||||
await incentiveApi.create(payload);
|
||||
message.success('시책 프로그램이 등록되었습니다');
|
||||
}
|
||||
setFormOpen(false);
|
||||
setEditTarget(null);
|
||||
actionRef.current?.reload();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 비활성화 처리 ────────────────────────────────────────────────────────────
|
||||
const handleDeactivate = (record: IncentiveProgramResp) => {
|
||||
Modal.confirm({
|
||||
title: '시책 비활성화',
|
||||
content: `"${record.programName}"을(를) 비활성화하시겠습니까?`,
|
||||
okText: '비활성화',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await incentiveApi.deactivate(record.programId);
|
||||
message.success('비활성화되었습니다');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── 컬럼 정의 ────────────────────────────────────────────────────────────────
|
||||
const columns: ProColumns<IncentiveProgramResp>[] = [
|
||||
{
|
||||
title: '시책명',
|
||||
dataIndex: 'programName',
|
||||
width: 180,
|
||||
fixed: 'left',
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '유형',
|
||||
dataIndex: 'programType',
|
||||
width: 110,
|
||||
search: false,
|
||||
render: (_, r) => <Tag color="blue">{r.programTypeName ?? r.programType}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '대상구분',
|
||||
dataIndex: 'targetType',
|
||||
width: 90,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
TARGET_TYPE_OPTIONS.map((o) => [o.value, { text: o.label }]),
|
||||
),
|
||||
render: (_, r) => (
|
||||
<Tag color={TARGET_TYPE_COLOR[r.targetType] ?? 'default'}>
|
||||
{TARGET_TYPE_OPTIONS.find((o) => o.value === r.targetType)?.label ?? r.targetType}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '대상 조직/직급',
|
||||
dataIndex: 'targetOrgName',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
render: (_, r) => r.targetOrgName ?? r.targetGradeName ?? '-',
|
||||
},
|
||||
{
|
||||
title: '시작월',
|
||||
dataIndex: 'startMonth',
|
||||
width: 90,
|
||||
valueType: 'dateMonth',
|
||||
fieldProps: { format: 'YYYYMM' },
|
||||
},
|
||||
{
|
||||
title: '종료월',
|
||||
dataIndex: 'endMonth',
|
||||
width: 90,
|
||||
valueType: 'dateMonth',
|
||||
fieldProps: { format: 'YYYYMM' },
|
||||
},
|
||||
{
|
||||
title: '지급방식',
|
||||
dataIndex: 'payType',
|
||||
width: 100,
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.payType === 'RATE' ? (
|
||||
<Tag color="gold">지급률</Tag>
|
||||
) : (
|
||||
<Tag color="volcano">고정금액</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '지급률/지급액',
|
||||
dataIndex: 'payRate',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.payType === 'RATE' ? (
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{r.payRate != null ? `${r.payRate}%` : '-'}
|
||||
</span>
|
||||
) : (
|
||||
<MoneyText value={r.payAmountFixed} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '예산',
|
||||
dataIndex: 'budgetAmount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.budgetAmount} />,
|
||||
},
|
||||
{
|
||||
title: '활성여부',
|
||||
dataIndex: 'isActive',
|
||||
width: 90,
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.isActive ? (
|
||||
<Badge status="success" text="활성" />
|
||||
) : (
|
||||
<Badge status="default" text="비활성" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
dataIndex: 'programId',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
menuCode="INCENTIVE_PROG"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
onClick={() => openEdit(r)}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
{r.isActive && (
|
||||
<PermissionButton
|
||||
menuCode="INCENTIVE_PROG"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDeactivate(r)}
|
||||
>
|
||||
비활성화
|
||||
</PermissionButton>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="시책 프로그램 관리"
|
||||
description="설계사/조직/직급 대상 시책 프로그램 등록 및 관리"
|
||||
>
|
||||
<ProTable<IncentiveProgramResp, IncentiveProgramSearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="programId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1400 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, programType, targetType, startMonth, endMonth } =
|
||||
params as any;
|
||||
const res = await incentiveApi.list({
|
||||
programType,
|
||||
targetType,
|
||||
startMonth: startMonth
|
||||
? dayjs(startMonth).format('YYYYMM')
|
||||
: undefined,
|
||||
endMonth: endMonth
|
||||
? dayjs(endMonth).format('YYYYMM')
|
||||
: undefined,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||
}}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
toolBarRender={() => [
|
||||
<PermissionButton
|
||||
key="create"
|
||||
menuCode="INCENTIVE_PROG"
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCreate}
|
||||
>
|
||||
시책 등록
|
||||
</PermissionButton>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── 생성/수정 모달 ────────────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
title={editTarget ? '시책 프로그램 수정' : '시책 프로그램 등록'}
|
||||
open={formOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
confirmLoading={saving}
|
||||
okText={editTarget ? '수정' : '등록'}
|
||||
cancelText="닫기"
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="programName"
|
||||
label="시책명"
|
||||
rules={[{ required: true, message: '시책명을 입력해주세요' }]}
|
||||
>
|
||||
<Input placeholder="시책 프로그램명" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item
|
||||
name="programType"
|
||||
label="시책 유형"
|
||||
rules={[{ required: true, message: '시책 유형을 입력해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="시책 유형 코드 (예: RECRUIT_BOOST)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="targetType"
|
||||
label="대상 구분"
|
||||
rules={[{ required: true, message: '대상 구분을 선택해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select placeholder="대상 구분" options={TARGET_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item name="targetOrgId" label="대상 조직 ID" style={{ flex: 1 }}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="조직 ID (선택)" min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="targetGradeId" label="대상 직급 ID" style={{ flex: 1 }}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="직급 ID (선택)" min={1} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="productCategory" label="상품 카테고리">
|
||||
<Input placeholder="상품 카테고리 (선택)" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item
|
||||
name="startMonthPicker"
|
||||
label="시작월"
|
||||
rules={[{ required: true, message: '시작월을 선택해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="YYYYMM" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="endMonthPicker"
|
||||
label="종료월"
|
||||
rules={[{ required: true, message: '종료월을 선택해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="YYYYMM" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="conditionDesc" label="조건 설명">
|
||||
<Input.TextArea rows={2} placeholder="지급 조건 상세 설명" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="payType"
|
||||
label="지급 방식"
|
||||
rules={[{ required: true, message: '지급 방식을 선택해주세요' }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
{PAY_TYPE_OPTIONS.map((o) => (
|
||||
<Radio key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{payType === 'RATE' && (
|
||||
<Form.Item
|
||||
name="payRate"
|
||||
label="지급률 (%)"
|
||||
rules={[{ required: true, message: '지급률을 입력해주세요' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={100}
|
||||
precision={2}
|
||||
placeholder="지급률 (예: 5.5)"
|
||||
addonAfter="%"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{payType === 'FIXED_AMOUNT' && (
|
||||
<Form.Item
|
||||
name="payAmountFixed"
|
||||
label="고정 지급액 (원)"
|
||||
rules={[{ required: true, message: '고정 지급액을 입력해주세요' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="고정 지급액"
|
||||
min={0}
|
||||
addonAfter="원"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="budgetAmount" label="예산 (원)">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="예산 (선택)"
|
||||
min={0}
|
||||
addonAfter="원"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm,
|
||||
ProFormText,
|
||||
ProFormTextArea,
|
||||
ProTable,
|
||||
type ActionType,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import {
|
||||
periodCloseApi,
|
||||
PeriodCloseResp,
|
||||
PeriodCloseCreateReq,
|
||||
} from '@/api/period-close';
|
||||
|
||||
// ── 상수 ─────────────────────────────────────────────────────────────────
|
||||
const MENU_CODE = 'PERIOD_CLOSE';
|
||||
|
||||
// YYYYMM 유효성 검사
|
||||
function isValidYYYYMM(v: string): boolean {
|
||||
if (!/^\d{6}$/.test(v)) return false;
|
||||
const year = parseInt(v.slice(0, 4), 10);
|
||||
const month = parseInt(v.slice(4, 6), 10);
|
||||
return year >= 2000 && year <= 2099 && month >= 1 && month <= 12;
|
||||
}
|
||||
|
||||
// ── 마감상태 Tag 렌더러 ───────────────────────────────────────────────────
|
||||
function CloseStatusTag({ status }: { status: 'CLOSED' | 'REOPENED' | string }) {
|
||||
if (status === 'CLOSED') {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
color: '#E24B4A',
|
||||
background: '#FCEBEB',
|
||||
border: '1px solid #F4C8C8',
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
height: 22,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
마감
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'REOPENED') {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
color: '#BA7517',
|
||||
background: '#FAEEDA',
|
||||
border: '1px solid #F1D9A8',
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
height: 22,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
재오픈
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
|
||||
// ── 컴포넌트 ──────────────────────────────────────────────────────────────
|
||||
export default function PeriodClose() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
// 마감 등록 모달
|
||||
const [closeModal, setCloseModal] = useState(false);
|
||||
// 재오픈 모달
|
||||
const [reopenModal, setReopenModal] = useState<{
|
||||
open: boolean;
|
||||
record?: PeriodCloseResp;
|
||||
}>({ open: false });
|
||||
|
||||
const { data: statusCodes = [] } = useCommonCodes('PERIOD_CLOSE_STATUS');
|
||||
|
||||
// ── 마감 저장 ──────────────────────────────────────────────────────────
|
||||
const onClose = async (values: Record<string, unknown>) => {
|
||||
const req: PeriodCloseCreateReq = {
|
||||
closeMonth: values.closeMonth as string,
|
||||
note: values.note as string | undefined,
|
||||
};
|
||||
await periodCloseApi.close(req);
|
||||
message.success('마감 처리 완료');
|
||||
setCloseModal(false);
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── 재오픈 저장 ────────────────────────────────────────────────────────
|
||||
const onReopen = async (values: Record<string, unknown>) => {
|
||||
if (!reopenModal.record) return false;
|
||||
await periodCloseApi.reopen(reopenModal.record.closeId, {
|
||||
reopenReason: values.reopenReason as string,
|
||||
});
|
||||
message.success('재오픈 처리 완료');
|
||||
setReopenModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── 날짜 포맷 ──────────────────────────────────────────────────────────
|
||||
const fmtDt = (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
|
||||
|
||||
// ── 컬럼 ───────────────────────────────────────────────────────────────
|
||||
const columns: ProColumns<PeriodCloseResp>[] = [
|
||||
{
|
||||
title: '정산월',
|
||||
dataIndex: 'closeMonth',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
// API 검색 파라미터명: settleMonth
|
||||
fieldProps: { placeholder: '예: 202501', maxLength: 6 },
|
||||
},
|
||||
{
|
||||
title: '마감상태',
|
||||
dataIndex: 'closeStatus',
|
||||
width: 100,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
statusCodes.map((c) => [c.code, { text: c.codeName }]),
|
||||
),
|
||||
render: (_, r) => <CloseStatusTag status={r.closeStatus} />,
|
||||
},
|
||||
{
|
||||
title: '마감일시',
|
||||
dataIndex: 'closedAt',
|
||||
width: 150,
|
||||
search: false,
|
||||
render: (_, r) => fmtDt(r.closedAt),
|
||||
},
|
||||
{
|
||||
title: '마감자',
|
||||
dataIndex: 'closedByName',
|
||||
width: 100,
|
||||
search: false,
|
||||
render: (_, r) => r.closedByName ?? '-',
|
||||
},
|
||||
{
|
||||
title: '재오픈일시',
|
||||
dataIndex: 'reopenAt',
|
||||
width: 150,
|
||||
search: false,
|
||||
render: (_, r) => fmtDt(r.reopenAt),
|
||||
},
|
||||
{
|
||||
title: '재오픈자',
|
||||
dataIndex: 'reopenByName',
|
||||
width: 100,
|
||||
search: false,
|
||||
render: (_, r) => r.reopenByName ?? '-',
|
||||
},
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'note',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
render: (_, r) => r.note ?? '-',
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
valueType: 'option',
|
||||
width: 100,
|
||||
fixed: 'right',
|
||||
render: (_, r) =>
|
||||
r.closeStatus === 'CLOSED' ? (
|
||||
<PermissionButton
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => setReopenModal({ open: true, record: r })}
|
||||
>
|
||||
재오픈
|
||||
</PermissionButton>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 렌더링 ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<PageContainer title="정산 마감 관리" description="정산월 마감 처리 및 재오픈 관리">
|
||||
<ProTable<PeriodCloseResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="closeId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1000 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, closeMonth, closeStatus } =
|
||||
params as { current?: number; pageSize?: number; closeMonth?: string; closeStatus?: string };
|
||||
const res = await periodCloseApi.list({
|
||||
settleMonth: closeMonth, // API 파라미터명: settleMonth
|
||||
closeStatus,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||
}}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton
|
||||
key="close"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
onClick={() => setCloseModal(true)}
|
||||
>
|
||||
+ 마감 처리
|
||||
</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
{/* ── 마감 등록 모달 ─────────────────────────────────────────────── */}
|
||||
<ModalForm
|
||||
title="마감 처리"
|
||||
open={closeModal}
|
||||
onOpenChange={(o) => !o && setCloseModal(false)}
|
||||
width={440}
|
||||
layout="vertical"
|
||||
key={closeModal ? 'close-open' : 'close-closed'}
|
||||
onFinish={onClose}
|
||||
submitter={{ searchConfig: { submitText: '마감 처리', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProFormText
|
||||
name="closeMonth"
|
||||
label="정산월"
|
||||
rules={[
|
||||
{ required: true, message: '정산월을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
!v || isValidYYYYMM(v)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
|
||||
},
|
||||
]}
|
||||
width="md"
|
||||
placeholder="예: 202501"
|
||||
fieldProps={{ maxLength: 6 }}
|
||||
/>
|
||||
<ProFormTextArea
|
||||
name="note"
|
||||
label="비고"
|
||||
width="xl"
|
||||
placeholder="마감 관련 메모를 입력하세요 (선택)"
|
||||
fieldProps={{ rows: 3 }}
|
||||
/>
|
||||
</ModalForm>
|
||||
|
||||
{/* ── 재오픈 모달 ────────────────────────────────────────────────── */}
|
||||
<ModalForm
|
||||
title={`재오픈 처리 — ${reopenModal.record?.closeMonth ?? ''}`}
|
||||
open={reopenModal.open}
|
||||
onOpenChange={(o) => !o && setReopenModal({ open: false })}
|
||||
width={440}
|
||||
layout="vertical"
|
||||
key={reopenModal.record?.closeId ?? 'reopen-closed'}
|
||||
onFinish={onReopen}
|
||||
submitter={{ searchConfig: { submitText: '재오픈', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<div style={{ marginBottom: 16, color: '#666', fontSize: 13 }}>
|
||||
재오픈 사유를 입력하면 해당 정산월이 다시 편집 가능 상태로 변경됩니다.
|
||||
</div>
|
||||
<ProFormTextArea
|
||||
name="reopenReason"
|
||||
label="재오픈 사유"
|
||||
rules={[{ required: true, message: '재오픈 사유를 입력하세요' }]}
|
||||
width="xl"
|
||||
placeholder="재오픈 사유를 상세히 입력하세요"
|
||||
fieldProps={{ rows: 4 }}
|
||||
/>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Input, Modal, Space, message } from 'antd';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormDigit,
|
||||
ProFormSelect,
|
||||
ProFormText,
|
||||
ProFormTextArea,
|
||||
ProTable,
|
||||
type ActionType,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||
import {
|
||||
settleCorrectionApi,
|
||||
SettleCorrectionResp,
|
||||
SettleCorrectionSaveReq,
|
||||
SettleCorrectionSearchParam,
|
||||
} from '@/api/settle-correction';
|
||||
|
||||
// ── 상수 ─────────────────────────────────────────────────────────────────
|
||||
const MENU_CODE = 'SETTLE_CORRECTION';
|
||||
|
||||
// YYYYMM 유효성 검사
|
||||
function isValidYYYYMM(v: string): boolean {
|
||||
if (!/^\d{6}$/.test(v)) return false;
|
||||
const year = parseInt(v.slice(0, 4), 10);
|
||||
const month = parseInt(v.slice(4, 6), 10);
|
||||
return year >= 2000 && year <= 2099 && month >= 1 && month <= 12;
|
||||
}
|
||||
|
||||
// ── 차액 컬러 렌더러 ─────────────────────────────────────────────────────
|
||||
function DiffAmountCell({ value }: { value: number }) {
|
||||
if (value === 0) return <span style={{ color: '#888', fontVariantNumeric: 'tabular-nums' }}>0</span>;
|
||||
const color = value > 0 ? '#185FA5' : '#E24B4A';
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return (
|
||||
<span style={{ color, fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
|
||||
{sign}{value.toLocaleString()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 컴포넌트 ──────────────────────────────────────────────────────────────
|
||||
export default function SettleCorrection() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
|
||||
const { data: statusCodes = [] } = useCommonCodes('CORRECTION_STATUS');
|
||||
const { data: reasonCodes = [] } = useCommonCodes('CORRECTION_REASON');
|
||||
|
||||
// ── 승인 ───────────────────────────────────────────────────────────────
|
||||
const onApprove = (record: SettleCorrectionResp) => {
|
||||
Modal.confirm({
|
||||
title: '정정 승인',
|
||||
content: `[${record.agentName}] ${record.settleMonth} 정산 정정을 승인하시겠습니까?`,
|
||||
okText: '승인',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await settleCorrectionApi.approve(record.correctionId);
|
||||
message.success('승인 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── 취소 ───────────────────────────────────────────────────────────────
|
||||
const onCancel = (record: SettleCorrectionResp) => {
|
||||
let cancelReason = '';
|
||||
Modal.confirm({
|
||||
title: '정정 취소',
|
||||
content: (
|
||||
<Input
|
||||
placeholder="취소 사유를 입력하세요"
|
||||
onChange={(e) => { cancelReason = e.target.value; }}
|
||||
/>
|
||||
),
|
||||
okType: 'danger',
|
||||
okText: '취소 처리',
|
||||
cancelText: '닫기',
|
||||
onOk: async () => {
|
||||
if (!cancelReason.trim()) {
|
||||
message.warning('취소 사유를 입력하세요');
|
||||
return Promise.reject(new Error('취소 사유 미입력'));
|
||||
}
|
||||
await settleCorrectionApi.cancel(record.correctionId, cancelReason);
|
||||
message.success('취소 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ── 저장 ───────────────────────────────────────────────────────────────
|
||||
const onSubmit = async (values: Record<string, unknown>) => {
|
||||
const req: SettleCorrectionSaveReq = {
|
||||
originalSettleId: values.originalSettleId as number,
|
||||
agentId: values.agentId as number,
|
||||
settleMonth: values.settleMonth as string,
|
||||
beforeAmount: values.beforeAmount as number,
|
||||
afterAmount: values.afterAmount as number,
|
||||
reasonCode: values.reasonCode as string,
|
||||
reasonDetail: values.reasonDetail as string | undefined,
|
||||
};
|
||||
await settleCorrectionApi.create(req);
|
||||
message.success('정정 등록 완료');
|
||||
setCreateModal(false);
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── 컬럼 ───────────────────────────────────────────────────────────────
|
||||
const columns: ProColumns<SettleCorrectionResp>[] = [
|
||||
{
|
||||
title: '설계사명',
|
||||
dataIndex: 'agentName',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '설계사ID',
|
||||
dataIndex: 'agentId',
|
||||
width: 90,
|
||||
hideInTable: true,
|
||||
valueType: 'digit',
|
||||
},
|
||||
{
|
||||
title: '조직명',
|
||||
dataIndex: 'orgName',
|
||||
width: 140,
|
||||
search: false,
|
||||
render: (_, r) => r.orgName ?? '-',
|
||||
},
|
||||
{
|
||||
title: '정산월',
|
||||
dataIndex: 'settleMonth',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '정정전금액',
|
||||
dataIndex: 'beforeAmount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.beforeAmount} />,
|
||||
},
|
||||
{
|
||||
title: '정정후금액',
|
||||
dataIndex: 'afterAmount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.afterAmount} />,
|
||||
},
|
||||
{
|
||||
title: '차액',
|
||||
dataIndex: 'diffAmount',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <DiffAmountCell value={r.diffAmount} />,
|
||||
},
|
||||
{
|
||||
title: '사유',
|
||||
dataIndex: 'reasonCode',
|
||||
width: 120,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
reasonCodes.map((c) => [c.code, { text: c.codeName }]),
|
||||
),
|
||||
render: (_, r) => (
|
||||
<CodeBadge groupCode="CORRECTION_REASON" value={r.reasonCode} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
statusCodes.map((c) => [c.code, { text: c.codeName }]),
|
||||
),
|
||||
render: (_, r) => (
|
||||
<CodeBadge groupCode="CORRECTION_STATUS" value={r.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '신청자',
|
||||
dataIndex: 'requestedByName',
|
||||
width: 90,
|
||||
search: false,
|
||||
render: (_, r) => r.requestedByName ?? '-',
|
||||
},
|
||||
{
|
||||
title: '신청일',
|
||||
dataIndex: 'requestedAt',
|
||||
width: 150,
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.requestedAt ? dayjs(r.requestedAt).format('YYYY-MM-DD HH:mm') : '-',
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
valueType: 'option',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, r) =>
|
||||
r.status === 'DRAFT' ? (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
key="approve"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="APPROVE"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => onApprove(r)}
|
||||
>
|
||||
승인
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
key="cancel"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => onCancel(r)}
|
||||
>
|
||||
취소
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 렌더링 ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<PageContainer title="정산 정정 관리" description="설계사 정산 금액 정정 요청 및 승인 관리">
|
||||
<ProTable<SettleCorrectionResp, SettleCorrectionSearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="correctionId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1400 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, agentId, settleMonth, status, reasonCode } =
|
||||
params as SettleCorrectionSearchParam & {
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
const res = await settleCorrectionApi.list({
|
||||
agentId,
|
||||
settleMonth,
|
||||
status,
|
||||
reasonCode,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||
}}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton
|
||||
key="create"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
onClick={() => setCreateModal(true)}
|
||||
>
|
||||
+ 정정 등록
|
||||
</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
{/* ── 정정 등록 모달 ─────────────────────────────────────────────── */}
|
||||
<ModalForm
|
||||
title="정산 정정 등록"
|
||||
open={createModal}
|
||||
onOpenChange={(o) => !o && setCreateModal(false)}
|
||||
width={560}
|
||||
layout="vertical"
|
||||
key={createModal ? 'correction-open' : 'correction-closed'}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="originalSettleId"
|
||||
label="원 정산 ID"
|
||||
rules={[{ required: true, message: '원 정산 ID를 입력하세요' }]}
|
||||
width="sm"
|
||||
min={1}
|
||||
fieldProps={{ precision: 0 }}
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="agentId"
|
||||
label="설계사 ID"
|
||||
rules={[{ required: true, message: '설계사 ID를 입력하세요' }]}
|
||||
width="sm"
|
||||
min={1}
|
||||
fieldProps={{ precision: 0 }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="settleMonth"
|
||||
label="정산월"
|
||||
rules={[
|
||||
{ required: true, message: '정산월을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
!v || isValidYYYYMM(v)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
|
||||
},
|
||||
]}
|
||||
width="sm"
|
||||
placeholder="예: 202501"
|
||||
fieldProps={{ maxLength: 6 }}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="reasonCode"
|
||||
label="사유코드"
|
||||
rules={[{ required: true, message: '사유코드를 선택하세요' }]}
|
||||
width="md"
|
||||
options={reasonCodes.map((c) => ({ value: c.code, label: c.codeName }))}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="beforeAmount"
|
||||
label="정정전금액"
|
||||
rules={[{ required: true, message: '정정전금액을 입력하세요' }]}
|
||||
width="md"
|
||||
fieldProps={{
|
||||
precision: 0,
|
||||
formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||
}}
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="afterAmount"
|
||||
label="정정후금액"
|
||||
rules={[{ required: true, message: '정정후금액을 입력하세요' }]}
|
||||
width="md"
|
||||
fieldProps={{
|
||||
precision: 0,
|
||||
formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||
}}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProFormTextArea
|
||||
name="reasonDetail"
|
||||
label="정정 상세사유"
|
||||
width="xl"
|
||||
placeholder="정정 사유를 상세히 입력하세요 (선택)"
|
||||
fieldProps={{ rows: 4 }}
|
||||
/>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ProTable,
|
||||
type ActionType,
|
||||
type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import DatePicker from 'antd/es/date-picker';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import {
|
||||
taxInvoiceApi,
|
||||
TaxInvoiceResp,
|
||||
TaxInvoiceSaveReq,
|
||||
TaxInvoiceSearchParam,
|
||||
} from '@/api/tax-invoice';
|
||||
|
||||
const { MonthPicker } = DatePicker;
|
||||
|
||||
const INVOICE_TYPE_OPTIONS = [
|
||||
{ label: '매출(공급)', value: 'SUPPLY' },
|
||||
{ label: '매입(구매)', value: 'PURCHASE' },
|
||||
];
|
||||
|
||||
const INVOICE_TYPE_TAG: Record<string, { color: string; label: string }> = {
|
||||
SUPPLY: { color: 'blue', label: '매출(공급)' },
|
||||
PURCHASE: { color: 'orange', label: '매입(구매)' },
|
||||
};
|
||||
|
||||
export default function TaxInvoiceList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
|
||||
// 발행 모달
|
||||
const [issueOpen, setIssueOpen] = useState(false);
|
||||
const [issuing, setIssuing] = useState(false);
|
||||
const [issueForm] = Form.useForm<TaxInvoiceSaveReq & { issueDatePicker?: unknown; settleMonthPicker?: unknown }>();
|
||||
|
||||
// 취소 모달
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelTarget, setCancelTarget] = useState<TaxInvoiceResp | null>(null);
|
||||
const [cancelForm] = Form.useForm<{ cancelReason: string }>();
|
||||
|
||||
// ── 발행 처리 ────────────────────────────────────────────────────────────────
|
||||
const handleIssue = async () => {
|
||||
const values = await issueForm.validateFields();
|
||||
const payload: TaxInvoiceSaveReq = {
|
||||
invoiceNo: values.invoiceNo,
|
||||
invoiceType: values.invoiceType,
|
||||
issueDate: values.issueDatePicker
|
||||
? dayjs(values.issueDatePicker as Parameters<typeof dayjs>[0]).format('YYYY-MM-DD')
|
||||
: '',
|
||||
supplyAmount: values.supplyAmount,
|
||||
taxAmount: values.taxAmount,
|
||||
counterpartName: values.counterpartName,
|
||||
counterpartBizNo: values.counterpartBizNo,
|
||||
counterpartRep: values.counterpartRep,
|
||||
counterpartAddr: values.counterpartAddr,
|
||||
agentId: values.agentId,
|
||||
settleMonth: values.settleMonthPicker
|
||||
? dayjs(values.settleMonthPicker as Parameters<typeof dayjs>[0]).format('YYYYMM')
|
||||
: undefined,
|
||||
description: values.description,
|
||||
};
|
||||
setIssuing(true);
|
||||
try {
|
||||
await taxInvoiceApi.create(payload);
|
||||
message.success('세금계산서가 발행되었습니다');
|
||||
setIssueOpen(false);
|
||||
issueForm.resetFields();
|
||||
actionRef.current?.reload();
|
||||
} finally {
|
||||
setIssuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 취소 처리 ────────────────────────────────────────────────────────────────
|
||||
const openCancel = (record: TaxInvoiceResp) => {
|
||||
setCancelTarget(record);
|
||||
cancelForm.resetFields();
|
||||
setCancelOpen(true);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!cancelTarget) return;
|
||||
const { cancelReason } = await cancelForm.validateFields();
|
||||
setCancelling(true);
|
||||
try {
|
||||
await taxInvoiceApi.cancel(cancelTarget.invoiceId, cancelReason);
|
||||
message.success('세금계산서가 취소되었습니다');
|
||||
setCancelOpen(false);
|
||||
setCancelTarget(null);
|
||||
actionRef.current?.reload();
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── 컬럼 정의 ────────────────────────────────────────────────────────────────
|
||||
const columns: ProColumns<TaxInvoiceResp>[] = [
|
||||
{
|
||||
title: '발행번호',
|
||||
dataIndex: 'invoiceNo',
|
||||
width: 160,
|
||||
fixed: 'left',
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
title: '유형',
|
||||
dataIndex: 'invoiceType',
|
||||
width: 110,
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(
|
||||
INVOICE_TYPE_OPTIONS.map((o) => [o.value, { text: o.label }]),
|
||||
),
|
||||
render: (_, r) => {
|
||||
const info = INVOICE_TYPE_TAG[r.invoiceType] ?? { color: 'default', label: r.invoiceType };
|
||||
return <Tag color={info.color}>{info.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '발행일',
|
||||
dataIndex: 'issueDate',
|
||||
width: 110,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '공급가액',
|
||||
dataIndex: 'supplyAmount',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.supplyAmount} />,
|
||||
},
|
||||
{
|
||||
title: '세액',
|
||||
dataIndex: 'taxAmount',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => <MoneyText value={r.taxAmount} />,
|
||||
},
|
||||
{
|
||||
title: '합계금액',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<strong>
|
||||
<MoneyText value={r.totalAmount} />
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '거래처명',
|
||||
dataIndex: 'counterpartName',
|
||||
width: 140,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '사업자번호',
|
||||
dataIndex: 'counterpartBizNo',
|
||||
width: 130,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '설계사명',
|
||||
dataIndex: 'agentName',
|
||||
width: 100,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '정산월',
|
||||
dataIndex: 'settleMonth',
|
||||
width: 90,
|
||||
valueType: 'dateMonth',
|
||||
fieldProps: { format: 'YYYYMM' },
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'cancelDate',
|
||||
width: 80,
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.cancelDate ? (
|
||||
<Tag color="red">취소</Tag>
|
||||
) : (
|
||||
<Tag color="green">발행</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
dataIndex: 'invoiceId',
|
||||
width: 90,
|
||||
fixed: 'right',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.cancelDate ? null : (
|
||||
<PermissionButton
|
||||
menuCode="TAX_INVOICE"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => openCancel(r)}
|
||||
>
|
||||
취소
|
||||
</PermissionButton>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="세금계산서 관리"
|
||||
description="매출·매입 세금계산서 발행 및 취소 관리"
|
||||
>
|
||||
<ProTable<TaxInvoiceResp, TaxInvoiceSearchParam>
|
||||
actionRef={actionRef}
|
||||
rowKey="invoiceId"
|
||||
columns={columns}
|
||||
scroll={{ x: 1400 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, invoiceType, settleMonth } = params as any;
|
||||
const res = await taxInvoiceApi.list({
|
||||
invoiceType,
|
||||
settleMonth: settleMonth
|
||||
? dayjs(settleMonth).format('YYYYMM')
|
||||
: undefined,
|
||||
pageNum: current,
|
||||
pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: 30,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100'],
|
||||
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||
}}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
toolBarRender={() => [
|
||||
<PermissionButton
|
||||
key="issue"
|
||||
menuCode="TAX_INVOICE"
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
issueForm.resetFields();
|
||||
setIssueOpen(true);
|
||||
}}
|
||||
>
|
||||
세금계산서 발행
|
||||
</PermissionButton>,
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* ── 발행 모달 ──────────────────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
title="세금계산서 발행"
|
||||
open={issueOpen}
|
||||
onOk={handleIssue}
|
||||
onCancel={() => setIssueOpen(false)}
|
||||
confirmLoading={issuing}
|
||||
okText="발행"
|
||||
cancelText="닫기"
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form form={issueForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="invoiceNo"
|
||||
label="발행번호"
|
||||
rules={[{ required: true, message: '발행번호를 입력해주세요' }]}
|
||||
>
|
||||
<Input placeholder="발행번호" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="invoiceType"
|
||||
label="유형"
|
||||
rules={[{ required: true, message: '유형을 선택해주세요' }]}
|
||||
>
|
||||
<Select placeholder="유형 선택" options={INVOICE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="issueDatePicker"
|
||||
label="발행일"
|
||||
rules={[{ required: true, message: '발행일을 선택해주세요' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item
|
||||
name="supplyAmount"
|
||||
label="공급가액"
|
||||
rules={[{ required: true, message: '공급가액을 입력해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="공급가액"
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="taxAmount"
|
||||
label="세액"
|
||||
rules={[{ required: true, message: '세액을 입력해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
|
||||
placeholder="세액"
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item
|
||||
name="counterpartName"
|
||||
label="거래처명"
|
||||
rules={[{ required: true, message: '거래처명을 입력해주세요' }]}
|
||||
>
|
||||
<Input placeholder="거래처명" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item
|
||||
name="counterpartBizNo"
|
||||
label="사업자번호"
|
||||
rules={[{ required: true, message: '사업자번호를 입력해주세요' }]}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="000-00-00000" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="counterpartRep"
|
||||
label="대표자명"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Input placeholder="대표자명" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="counterpartAddr" label="거래처 주소">
|
||||
<Input placeholder="거래처 주소" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
|
||||
<Form.Item name="agentId" label="설계사 ID" style={{ flex: 1 }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="설계사 ID"
|
||||
min={1}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="settleMonthPicker" label="정산월" style={{ flex: 1 }}>
|
||||
<MonthPicker
|
||||
style={{ width: '100%' }}
|
||||
format="YYYYMM"
|
||||
placeholder="정산월 (선택)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item name="description" label="비고">
|
||||
<Input.TextArea rows={2} placeholder="비고" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ── 취소 모달 ──────────────────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
title={`세금계산서 취소 — ${cancelTarget?.invoiceNo ?? ''}`}
|
||||
open={cancelOpen}
|
||||
onOk={handleCancel}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
confirmLoading={cancelling}
|
||||
okText="취소 처리"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="닫기"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={cancelForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="cancelReason"
|
||||
label="취소 사유"
|
||||
rules={[{ required: true, message: '취소 사유를 입력해주세요' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="취소 사유를 입력해주세요" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user