This commit is contained in:
ysukkyu
2026-05-14 18:14:49 +09:00
parent c0105c6847
commit 88e14edffd
50 changed files with 4870 additions and 172 deletions
@@ -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>
);
}