1
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormDatePicker,
|
||||
ProFormDigit,
|
||||
ProFormSelect,
|
||||
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 MoneyText from '@/components/common/MoneyText';
|
||||
import {
|
||||
chargebackGradeApi,
|
||||
type ChargebackGradeResp,
|
||||
type ChargebackGradeSaveReq,
|
||||
type ChargebackGradeSearchParam,
|
||||
} from '@/api/chargeback-grade';
|
||||
|
||||
const MENU_CODE = 'CHARGEBACK_GRADE';
|
||||
|
||||
const PRODUCT_CATEGORY_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'LIFE', label: '생명' },
|
||||
{ value: 'NON_LIFE', label: '손해' },
|
||||
{ value: 'EXCL_AUTO', label: '자동차제외' },
|
||||
{ value: 'EXCL_LOSS', label: '실손제외' },
|
||||
];
|
||||
|
||||
function ActiveBadge({ active }: { active: boolean }) {
|
||||
if (active) {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
color: '#0F6E56',
|
||||
background: '#E1F5EE',
|
||||
border: '1px solid #B9E5D5',
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
height: 22,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
활성
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
color: '#666666',
|
||||
background: '#F5F5F5',
|
||||
border: '1px solid #DDDDDD',
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
height: 22,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
비활성
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChargebackGradeList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [searchParam, setSearchParam] = useState<ChargebackGradeSearchParam>({
|
||||
pageNum: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
const [modal, setModal] = useState<{
|
||||
open: boolean;
|
||||
editId?: number;
|
||||
}>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<ChargebackGradeResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: ChargebackGradeResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.gradeId });
|
||||
};
|
||||
|
||||
const onDeactivate = (record: ChargebackGradeResp) => {
|
||||
Modal.confirm({
|
||||
title: '환수등급 비활성화',
|
||||
content: `경과월 구간 "${record.monthsRange}"을(를) 비활성화하시겠습니까?`,
|
||||
okType: 'danger',
|
||||
okText: '비활성화',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await chargebackGradeApi.deactivate(record.gradeId);
|
||||
message.success('비활성화 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: ChargebackGradeSaveReq = {
|
||||
productCategory: v.productCategory || null,
|
||||
monthsFrom: v.monthsFrom,
|
||||
monthsTo: v.monthsTo != null && v.monthsTo !== '' ? v.monthsTo : null,
|
||||
chargebackPercent: v.chargebackPercent,
|
||||
effectiveFrom: dayjs(v.effectiveFrom).format('YYYY-MM-DD'),
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : null,
|
||||
};
|
||||
|
||||
if (modal.editId) {
|
||||
await chargebackGradeApi.update(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await chargebackGradeApi.create(req);
|
||||
message.success('구간이 등록되었습니다');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<ChargebackGradeResp>[] = [
|
||||
{
|
||||
title: '상품분류',
|
||||
dataIndex: 'productCategory',
|
||||
width: 130,
|
||||
search: false,
|
||||
render: (_, r) => r.productCategoryName ?? '전체',
|
||||
},
|
||||
{
|
||||
title: '경과월 구간',
|
||||
dataIndex: 'monthsRange',
|
||||
width: 160,
|
||||
search: false,
|
||||
render: (_, r) => <strong>{r.monthsRange}</strong>,
|
||||
},
|
||||
{
|
||||
title: '환수율(%)',
|
||||
dataIndex: 'chargebackPercent',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<strong>
|
||||
<MoneyText value={r.chargebackPercent} fraction={2} />
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '적용시작',
|
||||
dataIndex: 'effectiveFrom',
|
||||
width: 120,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '적용종료',
|
||||
dataIndex: 'effectiveTo',
|
||||
width: 120,
|
||||
search: false,
|
||||
render: (_, r) => r.effectiveTo ?? '현재유효',
|
||||
},
|
||||
{
|
||||
title: '활성',
|
||||
dataIndex: 'isActive',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
search: false,
|
||||
render: (_, r) => <ActiveBadge active={r.isActive} />,
|
||||
},
|
||||
{
|
||||
title: '액션',
|
||||
valueType: 'option',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, r) =>
|
||||
r.isActive
|
||||
? [
|
||||
<PermissionButton
|
||||
key="edit"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => openEdit(r)}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>,
|
||||
<PermissionButton
|
||||
key="deactivate"
|
||||
menuCode={MENU_CODE}
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => onDeactivate(r)}
|
||||
>
|
||||
비활성화
|
||||
</PermissionButton>,
|
||||
]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
title: '상품분류 검색',
|
||||
dataIndex: 'productCategory',
|
||||
hideInTable: true,
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: PRODUCT_CATEGORY_OPTIONS,
|
||||
allowClear: true,
|
||||
placeholder: '전체',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '기준일',
|
||||
dataIndex: 'effectiveDate',
|
||||
hideInTable: true,
|
||||
valueType: 'date',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="환수등급 (차등환수룰) 관리"
|
||||
description="보험 해약 경과월에 따른 차등 환수율 구간 설정 (보험업감독규정)"
|
||||
>
|
||||
<ProTable<ChargebackGradeResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="gradeId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
const { productCategory, effectiveDate, current, pageSize } = params as any;
|
||||
const p: ChargebackGradeSearchParam = {
|
||||
productCategory: productCategory || undefined,
|
||||
effectiveDate: effectiveDate
|
||||
? dayjs(effectiveDate).format('YYYY-MM-DD')
|
||||
: undefined,
|
||||
pageNum: current ?? 1,
|
||||
pageSize: pageSize ?? 50,
|
||||
};
|
||||
setSearchParam(p);
|
||||
const res = await chargebackGradeApi.list(p);
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: searchParam.pageSize ?? 50,
|
||||
showTotal: (t) => `총 ${t.toLocaleString()}건`,
|
||||
}}
|
||||
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"
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
defaultCollapsed: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '환수등급 수정' : '환수등급 구간 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={560}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={
|
||||
editRecord
|
||||
? {
|
||||
productCategory: editRecord.productCategory ?? '',
|
||||
monthsFrom: editRecord.monthsFrom,
|
||||
monthsTo: editRecord.monthsTo ?? undefined,
|
||||
chargebackPercent: editRecord.chargebackPercent,
|
||||
effectiveFrom: editRecord.effectiveFrom
|
||||
? dayjs(editRecord.effectiveFrom)
|
||||
: null,
|
||||
effectiveTo: editRecord.effectiveTo
|
||||
? dayjs(editRecord.effectiveTo)
|
||||
: null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onFinish={onSubmit}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText: modal.editId ? '수정' : '등록',
|
||||
resetText: '취소',
|
||||
},
|
||||
}}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="productCategory"
|
||||
label="상품분류"
|
||||
width="md"
|
||||
placeholder="전체 (비워두면 전체 적용)"
|
||||
options={PRODUCT_CATEGORY_OPTIONS}
|
||||
allowClear
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="monthsFrom"
|
||||
label="경과월 시작"
|
||||
width="md"
|
||||
rules={[
|
||||
{ required: true, message: '경과월 시작을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
v >= 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject('0 이상의 값을 입력하세요'),
|
||||
},
|
||||
]}
|
||||
min={0}
|
||||
max={9999}
|
||||
fieldProps={{ precision: 0 }}
|
||||
placeholder="예: 0"
|
||||
/>
|
||||
<ProFormDigit
|
||||
name="monthsTo"
|
||||
label="경과월 종료"
|
||||
width="md"
|
||||
min={0}
|
||||
max={9999}
|
||||
fieldProps={{ precision: 0 }}
|
||||
placeholder="비워두면 무한대"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, v) => {
|
||||
if (v == null || v === '') return Promise.resolve();
|
||||
if (v >= 0) return Promise.resolve();
|
||||
return Promise.reject('0 이상의 값을 입력하세요');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="chargebackPercent"
|
||||
label="환수율(%)"
|
||||
width="md"
|
||||
rules={[
|
||||
{ required: true, message: '환수율을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
v >= 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject('0 이상의 값을 입력하세요'),
|
||||
},
|
||||
]}
|
||||
min={0}
|
||||
max={100}
|
||||
fieldProps={{ precision: 2, step: 0.01 }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker
|
||||
name="effectiveFrom"
|
||||
label="적용시작"
|
||||
width="md"
|
||||
rules={[{ required: true, message: '적용시작일을 선택하세요' }]}
|
||||
/>
|
||||
<ProFormDatePicker
|
||||
name="effectiveTo"
|
||||
label="적용종료"
|
||||
width="md"
|
||||
placeholder="비워두면 현재유효"
|
||||
/>
|
||||
</ProForm.Group>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Tag, message } from 'antd';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormDatePicker,
|
||||
ProFormDigit,
|
||||
ProFormSelect,
|
||||
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 MoneyText from '@/components/common/MoneyText';
|
||||
import {
|
||||
installmentApi,
|
||||
type InstallmentRatioResp,
|
||||
type InstallmentRatioSaveReq,
|
||||
type InstallmentRatioSearchParam,
|
||||
} from '@/api/installment';
|
||||
|
||||
const MENU_CODE = 'INSTALLMENT';
|
||||
|
||||
/** 상품분류 옵션 — 전체(null)/생명/손해/자동차제외/실손제외 */
|
||||
const PRODUCT_CATEGORY_OPTIONS = [
|
||||
{ value: '', label: '전체' },
|
||||
{ value: 'LIFE', label: '생명' },
|
||||
{ value: 'NON_LIFE', label: '손해' },
|
||||
{ value: 'EXCL_AUTO', label: '자동차제외' },
|
||||
{ value: 'EXCL_LOSS', label: '실손제외' },
|
||||
];
|
||||
|
||||
const PLAN_YEAR_OPTIONS = [1, 2, 3, 4, 5, 6, 7].map((y) => ({
|
||||
value: y,
|
||||
label: `${y}차년도`,
|
||||
}));
|
||||
|
||||
function planYearLabel(y: number): string {
|
||||
return `${y}차년도`;
|
||||
}
|
||||
|
||||
export default function InstallmentRatioList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [searchParam, setSearchParam] = useState<InstallmentRatioSearchParam>({
|
||||
pageNum: 1,
|
||||
pageSize: 50,
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openCreate = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: InstallmentRatioSaveReq = {
|
||||
productCategory: v.productCategory || null,
|
||||
planYear: v.planYear,
|
||||
ratioPercent: v.ratioPercent,
|
||||
effectiveFrom: dayjs(v.effectiveFrom).format('YYYY-MM-DD'),
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : null,
|
||||
};
|
||||
await installmentApi.create(req);
|
||||
message.success('분급비율이 등록되었습니다');
|
||||
setModalOpen(false);
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<InstallmentRatioResp>[] = [
|
||||
{
|
||||
title: '상품분류',
|
||||
dataIndex: 'productCategory',
|
||||
width: 130,
|
||||
search: false,
|
||||
render: (_, r) => r.productCategoryName ?? '전체',
|
||||
},
|
||||
{
|
||||
title: '분급연차',
|
||||
dataIndex: 'planYear',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
search: false,
|
||||
render: (_, r) => planYearLabel(r.planYear),
|
||||
},
|
||||
{
|
||||
title: '비율(%)',
|
||||
dataIndex: 'ratioPercent',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<strong>
|
||||
<MoneyText value={r.ratioPercent} fraction={2} />
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '적용시작',
|
||||
dataIndex: 'effectiveFrom',
|
||||
width: 120,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '적용종료',
|
||||
dataIndex: 'effectiveTo',
|
||||
width: 120,
|
||||
search: false,
|
||||
render: (_, r) => r.effectiveTo ?? '현재유효',
|
||||
},
|
||||
{
|
||||
title: '활성',
|
||||
dataIndex: 'isActive',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.isActive ? (
|
||||
<Tag
|
||||
style={{
|
||||
color: '#0F6E56',
|
||||
background: '#E1F5EE',
|
||||
border: '1px solid #B9E5D5',
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
height: 22,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
활성
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
style={{
|
||||
color: '#666666',
|
||||
background: '#F5F5F5',
|
||||
border: '1px solid #DDDDDD',
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
height: 22,
|
||||
lineHeight: '20px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
비활성
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상품분류 검색',
|
||||
dataIndex: 'productCategory',
|
||||
hideInTable: true,
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: PRODUCT_CATEGORY_OPTIONS,
|
||||
allowClear: true,
|
||||
placeholder: '전체',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="분급비율 설정"
|
||||
description="분급 비율 합계가 100%가 되도록 1~7차년도 각 비율 설정"
|
||||
>
|
||||
<ProTable<InstallmentRatioResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="ratioId"
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
const { productCategory, current, pageSize } = params as any;
|
||||
const p: InstallmentRatioSearchParam = {
|
||||
productCategory: productCategory || undefined,
|
||||
pageNum: current ?? 1,
|
||||
pageSize: pageSize ?? 50,
|
||||
};
|
||||
setSearchParam(p);
|
||||
const res = await installmentApi.list(p);
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{
|
||||
pageSize: searchParam.pageSize ?? 50,
|
||||
showTotal: (t) => `총 ${t.toLocaleString()}건`,
|
||||
}}
|
||||
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"
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
defaultCollapsed: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title="분급비율 등록"
|
||||
open={modalOpen}
|
||||
onOpenChange={(o) => !o && setModalOpen(false)}
|
||||
width={560}
|
||||
layout="vertical"
|
||||
key={modalOpen ? 'open' : 'closed'}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="productCategory"
|
||||
label="상품분류"
|
||||
width="md"
|
||||
placeholder="전체 (비워두면 전체 적용)"
|
||||
options={PRODUCT_CATEGORY_OPTIONS}
|
||||
allowClear
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="planYear"
|
||||
label="분급연차"
|
||||
width="md"
|
||||
rules={[{ required: true, message: '분급연차를 선택하세요' }]}
|
||||
options={PLAN_YEAR_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="ratioPercent"
|
||||
label="비율(%)"
|
||||
width="md"
|
||||
rules={[
|
||||
{ required: true, message: '비율을 입력하세요' },
|
||||
{
|
||||
validator: (_, v) =>
|
||||
v > 0 ? Promise.resolve() : Promise.reject('0보다 큰 값을 입력하세요'),
|
||||
},
|
||||
]}
|
||||
min={0.01}
|
||||
max={100}
|
||||
fieldProps={{ precision: 2, step: 0.01 }}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker
|
||||
name="effectiveFrom"
|
||||
label="적용시작"
|
||||
width="md"
|
||||
rules={[{ required: true, message: '적용시작일을 선택하세요' }]}
|
||||
/>
|
||||
<ProFormDatePicker
|
||||
name="effectiveTo"
|
||||
label="적용종료"
|
||||
width="md"
|
||||
placeholder="비워두면 현재유효"
|
||||
/>
|
||||
</ProForm.Group>
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal, message, Tag, Badge } from 'antd';
|
||||
import {
|
||||
ModalForm, ProForm, ProFormDatePicker, 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 PermissionButton from '@/components/common/PermissionButton';
|
||||
import { regulatoryApi, RegulatoryLimitResp, RegulatoryLimitSaveReq } from '@/api/regulatory';
|
||||
|
||||
const LIMIT_TYPE_OPTIONS = [
|
||||
{ value: 'TOTAL_RATIO', label: '총한도비율 (1200%)' },
|
||||
{ value: 'FIRST_YEAR_RATIO', label: '1차년도한도 (35%)' },
|
||||
{ value: 'INSTALLMENT_YEARS', label: '분급 연한 (7년)' },
|
||||
];
|
||||
|
||||
const PRODUCT_CATEGORY_OPTIONS = [
|
||||
{ value: 'ALL', label: '전체 상품' },
|
||||
{ value: 'LIFE', label: '생명보험' },
|
||||
{ value: 'NON_LIFE', label: '손해보험' },
|
||||
{ value: 'EXCLUDED_AUTO', label: '자동차보험(1200%룰 제외)' },
|
||||
{ value: 'EXCLUDED_HEALTH', label: '실손의료비(1200%룰 제외)' },
|
||||
];
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
{ value: 'PERCENT', label: '% (퍼센트)' },
|
||||
{ value: 'YEAR', label: '년 (연도)' },
|
||||
{ value: 'MONTH', label: '개월' },
|
||||
];
|
||||
|
||||
export default function RegulatoryLimitList() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
|
||||
const [editRecord, setEditRecord] = useState<RegulatoryLimitResp | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditRecord(null);
|
||||
setModal({ open: true });
|
||||
};
|
||||
|
||||
const openEdit = (record: RegulatoryLimitResp) => {
|
||||
setEditRecord(record);
|
||||
setModal({ open: true, editId: record.limitId });
|
||||
};
|
||||
|
||||
const onDeactivate = (record: RegulatoryLimitResp) => {
|
||||
Modal.confirm({
|
||||
title: '규제 한도 비활성화',
|
||||
content: `[${record.limitTypeName ?? record.limitType}] 한도를 비활성화하시겠습니까?`,
|
||||
okType: 'danger',
|
||||
okText: '비활성화',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await regulatoryApi.deactivate(record.limitId);
|
||||
message.success('비활성화 완료');
|
||||
actionRef.current?.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (v: any) => {
|
||||
const req: RegulatoryLimitSaveReq = {
|
||||
...v,
|
||||
productCategory: v.productCategory === 'ALL' ? undefined : v.productCategory,
|
||||
effectiveFrom: v.effectiveFrom ? dayjs(v.effectiveFrom).format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
if (modal.editId) {
|
||||
await regulatoryApi.update(modal.editId, req);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await regulatoryApi.create(req);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
setModal({ open: false });
|
||||
actionRef.current?.reload();
|
||||
return true;
|
||||
};
|
||||
|
||||
const columns: ProColumns<RegulatoryLimitResp>[] = [
|
||||
{
|
||||
title: '한도유형', dataIndex: 'limitType', width: 160,
|
||||
render: (_, r) => (
|
||||
<Tag color={r.limitType === 'TOTAL_RATIO' ? 'red' : r.limitType === 'FIRST_YEAR_RATIO' ? 'orange' : 'blue'}>
|
||||
{r.limitTypeName ?? r.limitType}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '상품분류', dataIndex: 'productCategoryName', width: 160, render: (_, r) => r.productCategoryName ?? '전체 상품' },
|
||||
{
|
||||
title: '한도값', dataIndex: 'limitValue', width: 110, align: 'right',
|
||||
render: (_, r) => <strong>{Number(r.limitValue).toLocaleString()}</strong>,
|
||||
},
|
||||
{ title: '단위', dataIndex: 'unitName', width: 80, render: (_, r) => r.unitName ?? r.unit },
|
||||
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110 },
|
||||
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, render: (v) => v ?? '현재 유효' },
|
||||
{ title: '근거법령', dataIndex: 'regulationRef', width: 200, ellipsis: true },
|
||||
{ title: '설명', dataIndex: 'description', ellipsis: true, search: false },
|
||||
{
|
||||
title: '상태', dataIndex: 'isActive', width: 80, align: 'center',
|
||||
render: (_, r) => r.isActive
|
||||
? <Badge status="success" text="활성" />
|
||||
: <Badge status="default" text="비활성" />,
|
||||
},
|
||||
{
|
||||
title: '액션', valueType: 'option', width: 130, fixed: 'right',
|
||||
render: (_, r) => [
|
||||
<PermissionButton key="edit" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link"
|
||||
onClick={() => openEdit(r)}>수정</PermissionButton>,
|
||||
r.isActive && (
|
||||
<PermissionButton key="deact" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link" danger
|
||||
onClick={() => onDeactivate(r)}>비활성화</PermissionButton>
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="1200%룰 규제 한도 관리"
|
||||
description="보험업감독규정 제5-15조의5 — 모집수수료 총 한도(1200%), 1차년도 한도(35%), 분급 연한(7년) 관리"
|
||||
>
|
||||
<ProTable<RegulatoryLimitResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="limitId"
|
||||
columns={columns}
|
||||
search={false}
|
||||
request={async () => {
|
||||
const res = await regulatoryApi.list();
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
scroll={{ x: 1200 }}
|
||||
toolBarRender={() => [
|
||||
<PermissionButton key="create" menuCode="REGULATORY_LIMIT" permCode="CREATE" type="primary"
|
||||
onClick={openCreate}>+ 한도 등록</PermissionButton>,
|
||||
]}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<ModalForm
|
||||
title={modal.editId ? '규제 한도 수정' : '규제 한도 등록'}
|
||||
open={modal.open}
|
||||
onOpenChange={(o) => !o && setModal({ open: false })}
|
||||
width={620}
|
||||
layout="vertical"
|
||||
key={modal.editId ?? 'new'}
|
||||
initialValues={editRecord ? {
|
||||
...editRecord,
|
||||
productCategory: editRecord.productCategory ?? 'ALL',
|
||||
effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null,
|
||||
effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null,
|
||||
} : { productCategory: 'ALL', unit: 'PERCENT' }}
|
||||
onFinish={onSubmit}
|
||||
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
|
||||
modalProps={{ destroyOnClose: true, maskClosable: false }}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormSelect
|
||||
name="limitType" label="한도유형" rules={[{ required: true }]} width="md"
|
||||
options={LIMIT_TYPE_OPTIONS}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="productCategory" label="상품분류" width="md"
|
||||
options={PRODUCT_CATEGORY_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDigit
|
||||
name="limitValue" label="한도값" rules={[{ required: true }]} width="md"
|
||||
min={0} fieldProps={{ precision: 2 }}
|
||||
/>
|
||||
<ProFormSelect
|
||||
name="unit" label="단위" rules={[{ required: true }]} width="md"
|
||||
options={UNIT_OPTIONS}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormDatePicker name="effectiveFrom" label="적용 시작일" rules={[{ required: true }]} width="md" />
|
||||
<ProFormDatePicker name="effectiveTo" label="적용 종료일 (미입력 = 현재 유효)" width="md" />
|
||||
</ProForm.Group>
|
||||
<ProFormText
|
||||
name="regulationRef" label="근거 법령" width="xl"
|
||||
placeholder="예: 보험업감독규정 제5-15조의5"
|
||||
/>
|
||||
<ProFormTextArea name="description" label="설명" fieldProps={{ rows: 3 }} />
|
||||
</ModalForm>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +1,539 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Col, Empty, Row, Table, Tag, Typography } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card, Col, Empty, Form, Input, InputNumber, Modal,
|
||||
Row, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import { commonCodeApi, CommonCodeGroup } from '@/api/user';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { commonCodeApi, CommonCodeGroup, CommonCode } from '@/api/user';
|
||||
|
||||
const { Text } = Typography;
|
||||
const MENU = 'SYSTEM_CODE';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 내부 상태 타입 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type GroupModalState =
|
||||
| { open: false }
|
||||
| { open: true; mode: 'create' }
|
||||
| { open: true; mode: 'edit'; group: CommonCodeGroup };
|
||||
|
||||
type CodeModalState =
|
||||
| { open: false }
|
||||
| { open: true; mode: 'create' }
|
||||
| { open: true; mode: 'edit'; code: CommonCode };
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 메인 컴포넌트 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function CodeList() {
|
||||
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['code', 'groups'],
|
||||
queryFn: () => commonCodeApi.groups(),
|
||||
/* 선택 그룹 & 검색어 */
|
||||
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
/* 모달 상태 */
|
||||
const [groupModal, setGroupModal] = useState<GroupModalState>({ open: false });
|
||||
const [codeModal, setCodeModal] = useState<CodeModalState>({ open: false });
|
||||
|
||||
/* 폼 인스턴스 */
|
||||
const [groupForm] = Form.useForm();
|
||||
const [codeForm] = Form.useForm();
|
||||
|
||||
/* 로딩 상태 */
|
||||
const [groupSaving, setGroupSaving] = useState(false);
|
||||
const [codeSaving, setCodeSaving] = useState(false);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 쿼리 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const { data: groups = [], isFetching: groupLoading } = useQuery({
|
||||
queryKey: ['code', 'groups', keyword],
|
||||
queryFn: () => commonCodeApi.groups(keyword || undefined),
|
||||
});
|
||||
|
||||
const { data: codes = [] } = useQuery({
|
||||
const { data: codes = [], isFetching: codeLoading } = useQuery({
|
||||
queryKey: ['code', 'codes', selected?.groupCode],
|
||||
queryFn: () => commonCodeApi.codes(selected!.groupCode),
|
||||
enabled: !!selected,
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 공통 리로드 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['code', 'groups'] });
|
||||
const invalidateCodes = (gc: string) =>
|
||||
qc.invalidateQueries({ queryKey: ['code', 'codes', gc] });
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 그룹 모달 열기 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const openGroupCreate = () => {
|
||||
groupForm.resetFields();
|
||||
groupForm.setFieldsValue({ isSystem: 'N', sortOrder: 0 });
|
||||
setGroupModal({ open: true, mode: 'create' });
|
||||
};
|
||||
|
||||
const openGroupEdit = (group: CommonCodeGroup) => {
|
||||
groupForm.resetFields();
|
||||
groupForm.setFieldsValue({
|
||||
groupCode: group.groupCode,
|
||||
groupName: group.groupName,
|
||||
description: group.description,
|
||||
isSystem: group.isSystem,
|
||||
sortOrder: group.sortOrder,
|
||||
});
|
||||
setGroupModal({ open: true, mode: 'edit', group });
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 그룹 저장 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const handleGroupSave = async () => {
|
||||
if (!groupModal.open) return;
|
||||
try {
|
||||
setGroupSaving(true);
|
||||
const values = await groupForm.validateFields();
|
||||
|
||||
if (groupModal.mode === 'create') {
|
||||
await commonCodeApi.createGroup(values);
|
||||
message.success('그룹이 추가되었습니다.');
|
||||
} else {
|
||||
const { group } = groupModal;
|
||||
await commonCodeApi.updateGroup(group.groupCode, {
|
||||
groupName: values.groupName,
|
||||
description: values.description,
|
||||
});
|
||||
message.success('그룹이 수정되었습니다.');
|
||||
// 선택된 그룹 이름 동기화
|
||||
if (selected?.groupCode === group.groupCode) {
|
||||
setSelected((prev) =>
|
||||
prev ? { ...prev, groupName: values.groupName, description: values.description } : null,
|
||||
);
|
||||
}
|
||||
await commonCodeApi.refreshCache(group.groupCode);
|
||||
}
|
||||
|
||||
await invalidateGroups();
|
||||
setGroupModal({ open: false });
|
||||
} catch {
|
||||
// validateFields 실패 시 폼 에러 표시 — 별도 처리 불필요
|
||||
} finally {
|
||||
setGroupSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 그룹 삭제 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const confirmDeleteGroup = (group: CommonCodeGroup) => {
|
||||
Modal.confirm({
|
||||
title: '그룹 삭제',
|
||||
content: (
|
||||
<span>
|
||||
<b>{group.groupName}</b> 그룹과 하위 코드를 모두 삭제합니다.
|
||||
<br />계속하시겠습니까?
|
||||
</span>
|
||||
),
|
||||
okText: '삭제',
|
||||
okType: 'danger',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await commonCodeApi.deleteGroup(group.groupCode);
|
||||
message.success('그룹이 삭제되었습니다.');
|
||||
if (selected?.groupCode === group.groupCode) setSelected(null);
|
||||
await commonCodeApi.refreshCache(group.groupCode);
|
||||
invalidateGroups();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 코드 모달 열기 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const openCodeCreate = () => {
|
||||
codeForm.resetFields();
|
||||
codeForm.setFieldsValue({ isActive: 'Y', sortOrder: 0 });
|
||||
setCodeModal({ open: true, mode: 'create' });
|
||||
};
|
||||
|
||||
const openCodeEdit = (code: CommonCode) => {
|
||||
codeForm.resetFields();
|
||||
codeForm.setFieldsValue({
|
||||
code: code.code,
|
||||
codeName: code.codeName,
|
||||
codeDesc: code.codeDesc,
|
||||
sortOrder: code.sortOrder,
|
||||
isActive: code.isActive,
|
||||
});
|
||||
setCodeModal({ open: true, mode: 'edit', code });
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 코드 저장 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const handleCodeSave = async () => {
|
||||
if (!codeModal.open || !selected) return;
|
||||
try {
|
||||
setCodeSaving(true);
|
||||
const values = await codeForm.validateFields();
|
||||
|
||||
if (codeModal.mode === 'create') {
|
||||
await commonCodeApi.createCode(selected.groupCode, values);
|
||||
message.success('코드가 추가되었습니다.');
|
||||
} else {
|
||||
await commonCodeApi.updateCode(codeModal.code.codeId, values);
|
||||
message.success('코드가 수정되었습니다.');
|
||||
}
|
||||
|
||||
await commonCodeApi.refreshCache(selected.groupCode);
|
||||
invalidateCodes(selected.groupCode);
|
||||
setCodeModal({ open: false });
|
||||
} catch {
|
||||
// 폼 검증 실패 — 별도 처리 불필요
|
||||
} finally {
|
||||
setCodeSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 코드 삭제 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const confirmDeleteCode = (code: CommonCode) => {
|
||||
Modal.confirm({
|
||||
title: '코드 삭제',
|
||||
content: (
|
||||
<span>
|
||||
코드 <b>{code.code} ({code.codeName})</b>을(를) 삭제합니다.
|
||||
<br />계속하시겠습니까?
|
||||
</span>
|
||||
),
|
||||
okText: '삭제',
|
||||
okType: 'danger',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await commonCodeApi.deleteCode(code.codeId);
|
||||
message.success('코드가 삭제되었습니다.');
|
||||
if (selected) {
|
||||
await commonCodeApi.refreshCache(selected.groupCode);
|
||||
invalidateCodes(selected.groupCode);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 렌더 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
return (
|
||||
<PageContainer title="공통코드 관리">
|
||||
<Row gutter={16}>
|
||||
{/* ======================================================= */}
|
||||
{/* 왼쪽: 그룹 목록 */}
|
||||
{/* ======================================================= */}
|
||||
<Col span={10}>
|
||||
<Card title="그룹" size="small">
|
||||
<Table
|
||||
<Card
|
||||
size="small"
|
||||
title="코드 그룹"
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openGroupCreate}
|
||||
>
|
||||
그룹 추가
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="READ"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={invalidateGroups}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="그룹코드 / 그룹명 검색"
|
||||
allowClear
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
onSearch={(v) => setKeyword(v)}
|
||||
onChange={(e) => { if (!e.target.value) setKeyword(''); }}
|
||||
/>
|
||||
<Table<CommonCodeGroup>
|
||||
rowKey="groupCode"
|
||||
dataSource={groups}
|
||||
loading={groupLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 600 }}
|
||||
onRow={(r) => ({ onClick: () => setSelected(r), style: { cursor: 'pointer' } })}
|
||||
rowClassName={(r) => r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''}
|
||||
scroll={{ y: 520 }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelected(r),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
rowClassName={(r) =>
|
||||
r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
columns={[
|
||||
{ title: '그룹코드', dataIndex: 'groupCode', width: 160 },
|
||||
{ title: '그룹명', dataIndex: 'groupName' },
|
||||
{ title: '그룹코드', dataIndex: 'groupCode', width: 140, ellipsis: true },
|
||||
{ title: '그룹명', dataIndex: 'groupName', ellipsis: true },
|
||||
{
|
||||
title: '시스템', dataIndex: 'isSystem', width: 80, align: 'center',
|
||||
render: (v) => v === 'Y' ? <Tag color="orange">시스템</Tag> : <Tag>일반</Tag>,
|
||||
title: '유형',
|
||||
dataIndex: 'isSystem',
|
||||
width: 68,
|
||||
align: 'center',
|
||||
render: (v: string) =>
|
||||
v === 'Y' ? (
|
||||
<Tag color="orange" style={{ margin: 0, fontSize: 11 }}>시스템</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0, fontSize: 11 }}>일반</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '작업',
|
||||
key: 'actions',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Space size={2} onClick={(e) => e.stopPropagation()}>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="UPDATE"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openGroupEdit(r)}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="DELETE"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
disabled={r.isSystem === 'Y'}
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDeleteGroup(r)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* ======================================================= */}
|
||||
{/* 오른쪽: 코드 목록 */}
|
||||
{/* ======================================================= */}
|
||||
<Col span={14}>
|
||||
<Card title={selected ? `상세 코드 — ${selected.groupName}` : '상세 코드'} size="small">
|
||||
<Card
|
||||
size="small"
|
||||
title={selected ? `코드 목록 — ${selected.groupName}` : '코드 목록'}
|
||||
extra={
|
||||
selected && (
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="CREATE"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openCodeCreate}
|
||||
>
|
||||
코드 추가
|
||||
</PermissionButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!selected ? (
|
||||
<Empty description="그룹을 선택하세요" />
|
||||
<Empty description="왼쪽에서 그룹을 선택하세요" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<Table
|
||||
rowKey="codeId"
|
||||
dataSource={codes}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 600 }}
|
||||
columns={[
|
||||
{ title: '코드', dataIndex: 'code', width: 120 },
|
||||
{ title: '코드명', dataIndex: 'codeName' },
|
||||
{ title: '설명', dataIndex: 'codeDesc' },
|
||||
{ title: '정렬', dataIndex: 'sortOrder', width: 60, align: 'right' },
|
||||
{
|
||||
title: '활성', dataIndex: 'isActive', width: 70, align: 'center',
|
||||
render: (v) => v === 'Y' ? <Tag color="green">Y</Tag> : <Tag>N</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{selected?.isSystem === 'Y' && (
|
||||
<Text type="warning" style={{ display: 'block', marginTop: 8 }}>
|
||||
⚠ 시스템 그룹의 코드는 식별자 변경/삭제가 제한됩니다.
|
||||
</Text>
|
||||
<>
|
||||
<Table<CommonCode>
|
||||
rowKey="codeId"
|
||||
dataSource={codes}
|
||||
loading={codeLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 520 }}
|
||||
columns={[
|
||||
{ title: '코드', dataIndex: 'code', width: 120, ellipsis: true },
|
||||
{ title: '코드명', dataIndex: 'codeName', width: 130, ellipsis: true },
|
||||
{ title: '설명', dataIndex: 'codeDesc', ellipsis: true },
|
||||
{ title: '정렬', dataIndex: 'sortOrder', width: 52, align: 'right' },
|
||||
{
|
||||
title: '활성',
|
||||
dataIndex: 'isActive',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
render: (v: string) =>
|
||||
v === 'Y' ? (
|
||||
<Tag color="green" style={{ margin: 0 }}>Y</Tag>
|
||||
) : (
|
||||
<Tag style={{ margin: 0 }}>N</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '작업',
|
||||
key: 'actions',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Space size={2}>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="UPDATE"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openCodeEdit(r)}
|
||||
/>
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="DELETE"
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDeleteCode(r)}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{selected.isSystem === 'Y' && (
|
||||
<Text type="warning" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
시스템 그룹의 코드는 식별자 변경·삭제가 제한됩니다.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ========================================================= */}
|
||||
{/* 그룹 추가 / 수정 모달 */}
|
||||
{/* ========================================================= */}
|
||||
<Modal
|
||||
title={groupModal.open && groupModal.mode === 'create' ? '그룹 추가' : '그룹 수정'}
|
||||
open={groupModal.open}
|
||||
onOk={handleGroupSave}
|
||||
onCancel={() => setGroupModal({ open: false })}
|
||||
okText={groupModal.open && groupModal.mode === 'create' ? '추가' : '수정'}
|
||||
cancelText="취소"
|
||||
confirmLoading={groupSaving}
|
||||
destroyOnClose
|
||||
width={480}
|
||||
>
|
||||
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="groupCode"
|
||||
label="그룹코드"
|
||||
rules={[
|
||||
{ required: true, message: '그룹코드를 입력하세요' },
|
||||
{ pattern: /^[A-Z0-9_]+$/, message: '영문 대문자, 숫자, 밑줄만 사용 가능합니다' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="예) AGENT_STATUS"
|
||||
disabled={groupModal.open && groupModal.mode === 'edit'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="groupName"
|
||||
label="그룹명"
|
||||
rules={[{ required: true, message: '그룹명을 입력하세요' }]}
|
||||
>
|
||||
<Input placeholder="예) 설계사 상태" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="설명">
|
||||
<Input.TextArea rows={2} placeholder="그룹에 대한 설명을 입력하세요" />
|
||||
</Form.Item>
|
||||
{groupModal.open && groupModal.mode === 'create' && (
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="isSystem" label="시스템 여부">
|
||||
<Select options={[{ value: 'Y', label: '시스템' }, { value: 'N', label: '일반' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="sortOrder" label="정렬 순서">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* ========================================================= */}
|
||||
{/* 코드 추가 / 수정 모달 */}
|
||||
{/* ========================================================= */}
|
||||
<Modal
|
||||
title={codeModal.open && codeModal.mode === 'create' ? '코드 추가' : '코드 수정'}
|
||||
open={codeModal.open}
|
||||
onOk={handleCodeSave}
|
||||
onCancel={() => setCodeModal({ open: false })}
|
||||
okText={codeModal.open && codeModal.mode === 'create' ? '추가' : '수정'}
|
||||
cancelText="취소"
|
||||
confirmLoading={codeSaving}
|
||||
destroyOnClose
|
||||
width={460}
|
||||
>
|
||||
<Form form={codeForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="코드값"
|
||||
rules={[
|
||||
{ required: true, message: '코드값을 입력하세요' },
|
||||
{ pattern: /^[A-Z0-9_]+$/, message: '영문 대문자, 숫자, 밑줄만 사용 가능합니다' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="예) ACTIVE"
|
||||
disabled={codeModal.open && codeModal.mode === 'edit'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="codeName"
|
||||
label="코드명"
|
||||
rules={[{ required: true, message: '코드명을 입력하세요' }]}
|
||||
>
|
||||
<Input placeholder="예) 활성" />
|
||||
</Form.Item>
|
||||
<Form.Item name="codeDesc" label="설명">
|
||||
<Input.TextArea rows={2} placeholder="코드에 대한 설명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="sortOrder" label="정렬 순서">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="isActive" label="활성 여부">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'Y', label: '활성' },
|
||||
{ value: 'N', label: '비활성' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,241 @@
|
||||
import { Tag } from 'antd';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Form, Input, Modal, Tag, message } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { ProTable, type ProColumns, type ActionType } from '@ant-design/pro-components';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { configApi, SystemConfigResp } from '@/api/config';
|
||||
|
||||
const MENU = 'SYSTEM_CONFIG';
|
||||
|
||||
const GROUP_COLOR: Record<string, string> = {
|
||||
GENERAL: 'blue', BATCH: 'cyan', SECURITY: 'red', MAIL: 'green', TAX: 'orange',
|
||||
GENERAL: 'blue',
|
||||
BATCH: 'cyan',
|
||||
SECURITY: 'red',
|
||||
MAIL: 'green',
|
||||
TAX: 'orange',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 수정 모달 상태 타입 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type EditState =
|
||||
| { open: false }
|
||||
| { open: true; row: SystemConfigResp };
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 메인 컴포넌트 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export default function SystemConfig() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [form] = Form.useForm<{ value: string }>();
|
||||
const [editState, setEditState] = useState<EditState>({ open: false });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 수정 모달 열기 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const openEdit = (row: SystemConfigResp) => {
|
||||
form.resetFields();
|
||||
// 암호화 항목은 현재값을 초기화하지 않음
|
||||
form.setFieldsValue({ value: row.isEncrypted === 'Y' ? '' : (row.configValue ?? '') });
|
||||
setEditState({ open: true, row });
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 저장 처리 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const handleSave = async () => {
|
||||
if (!editState.open) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const values = await form.validateFields();
|
||||
await configApi.update(editState.row.configKey!, values.value);
|
||||
message.success('설정 값이 수정되었습니다.');
|
||||
setEditState({ open: false });
|
||||
actionRef.current?.reload();
|
||||
} catch {
|
||||
// validateFields 실패 — 폼 에러 표시로 대체
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 컬럼 정의 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
const columns: ProColumns<SystemConfigResp>[] = [
|
||||
{
|
||||
title: '그룹', dataIndex: 'configGroup', width: 110, valueType: 'select',
|
||||
title: '그룹',
|
||||
dataIndex: 'configGroup',
|
||||
width: 110,
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
GENERAL: { text: '일반' }, BATCH: { text: '배치' }, SECURITY: { text: '보안' },
|
||||
MAIL: { text: '메일' }, TAX: { text: '세금' },
|
||||
GENERAL: { text: '일반' },
|
||||
BATCH: { text: '배치' },
|
||||
SECURITY: { text: '보안' },
|
||||
MAIL: { text: '메일' },
|
||||
TAX: { text: '세금' },
|
||||
},
|
||||
render: (_, r) => <Tag color={GROUP_COLOR[r.configGroup ?? ''] ?? 'default'}>
|
||||
{r.configGroup}</Tag>,
|
||||
render: (_, r) => (
|
||||
<Tag color={GROUP_COLOR[r.configGroup ?? ''] ?? 'default'}>
|
||||
{r.configGroup}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '설정 키', dataIndex: 'configKey', width: 220, search: false,
|
||||
render: (_, r) => <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>
|
||||
{r.configKey}</code> },
|
||||
{
|
||||
title: '설정 값', dataIndex: 'configValue', search: false,
|
||||
render: (_, r) => r.isEncrypted === 'Y' ? <Tag color="red">암호화 (***)</Tag> : <span>{r.configValue}</span>,
|
||||
title: '설정 키',
|
||||
dataIndex: 'configKey',
|
||||
width: 240,
|
||||
search: false,
|
||||
render: (_, r) => (
|
||||
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4, fontSize: 12 }}>
|
||||
{r.configKey}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{ title: '타입', dataIndex: 'valueType', width: 90, search: false },
|
||||
{ title: '설명', dataIndex: 'configDesc', ellipsis: true, search: false },
|
||||
{
|
||||
title: '수정 가능', dataIndex: 'isEditable', width: 100, align: 'center', search: false,
|
||||
render: (_, r) => r.isEditable === 'Y' ? <Tag color="success">가능</Tag> : <Tag>잠김</Tag>,
|
||||
title: '설정 값',
|
||||
dataIndex: 'configValue',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.isEncrypted === 'Y' ? (
|
||||
<Tag color="red">암호화 (***)</Tag>
|
||||
) : (
|
||||
<span>{r.configValue}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '타입',
|
||||
dataIndex: 'valueType',
|
||||
width: 90,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '설명',
|
||||
dataIndex: 'configDesc',
|
||||
ellipsis: true,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '수정 가능',
|
||||
dataIndex: 'isEditable',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.isEditable === 'Y' ? (
|
||||
<Tag color="success">가능</Tag>
|
||||
) : (
|
||||
<Tag>잠김</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수정일시',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 160,
|
||||
search: false,
|
||||
},
|
||||
{
|
||||
title: '작업',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
search: false,
|
||||
render: (_, r) => {
|
||||
if (r.isEditable !== 'Y') return null;
|
||||
return (
|
||||
<PermissionButton
|
||||
menuCode={MENU}
|
||||
permCode="UPDATE"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(r)}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '수정일시', dataIndex: 'updatedAt', width: 160, search: false },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 렌더 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
return (
|
||||
<PageContainer title="시스템 설정" description="배치 / 보안 / 세금 / 메일 등 키-값 설정">
|
||||
<PageContainer
|
||||
title="시스템 설정"
|
||||
description="배치 / 보안 / 세금 / 메일 등 키-값 설정"
|
||||
>
|
||||
<ProTable<SystemConfigResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="configKey"
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
request={async (params) => {
|
||||
const list = await configApi.list((params as any).configGroup);
|
||||
const list = await configApi.list((params as Record<string, string>).configGroup);
|
||||
return { data: list, total: list.length, success: true };
|
||||
}}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
search={{
|
||||
labelWidth: 'auto',
|
||||
searchText: '검색',
|
||||
resetText: '초기화',
|
||||
collapsed: false,
|
||||
collapseRender: false,
|
||||
}}
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
{/* ========================================================= */}
|
||||
{/* 수정 모달 */}
|
||||
{/* ========================================================= */}
|
||||
<Modal
|
||||
title="설정 값 수정"
|
||||
open={editState.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setEditState({ open: false })}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
width={440}
|
||||
>
|
||||
{editState.open && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, padding: '8px 12px', background: '#fafafa', borderRadius: 6, border: '1px solid #f0f0f0' }}>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>설정 키</div>
|
||||
<code style={{ fontSize: 13 }}>{editState.row.configKey}</code>
|
||||
{editState.row.configDesc && (
|
||||
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>{editState.row.configDesc}</div>
|
||||
)}
|
||||
</div>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="새 값"
|
||||
rules={[{ required: true, message: '값을 입력하세요' }]}
|
||||
>
|
||||
{editState.row.isEncrypted === 'Y' ? (
|
||||
<Input.Password
|
||||
placeholder="새 비밀번호 / 암호화 값 입력"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder="새 값 입력" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{editState.row.isEncrypted === 'Y' && (
|
||||
<div style={{ fontSize: 12, color: '#faad14', marginTop: -8 }}>
|
||||
암호화 항목입니다. 저장 시 서버에서 암호화 처리됩니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user