feat: 추천 작업 5종 일괄 진행 (4 agents 병렬)

[A] 등록 모달 7페이지 (frontend)
- CompanyList / ProductList / Commission / Payout / Override / Chargeback / ExceptionCode
- ModalForm 580px, Switch Y/N 변환, 날짜 dayjs, PermissionButton
- api/company/product/rule.ts 에 SaveReq + create/update/remove 추가

[B] BatchRun + RoleList 보강 (frontend)
- BatchRun: 정산월/보험사 선택 → 실행, antd Steps 8단계 + 5초 폴링
  (refetchInterval 자동 정지), KPI 4 카드, 최근 이력
- RoleList: 좌(역할 목록) 우(메뉴×6권한 매트릭스 체크박스),
  DIRECTORY 들여쓰기, 전체 토글, Modal.confirm 후 저장

[C] 이체파일 + 은행 어댑터 (api)
- BankTransferFileGenerator 인터페이스 + GenericCsvTransferGenerator
  (UTF-8 BOM, 표준 CSV) + KbBankTransferGenerator (088, 고정폭 + 합계)
- BankTransferFactory (Spring Map 빈)
- TransferFileService: PENDING 조회 → 파일 생성 → file_storage 적재 →
  pay_file_ref 갱신 → status=SENT
- POST /api/payments/transfer-file (DataChangeLog + EXPORT 권한)
- account_no EncryptTypeHandler 자동 복호화 후 파일에 사용
- FileService.saveBytes(byte[]) 오버로드 추가
- PaymentList: 이체파일 생성 ModalForm + 다운로드 컬럼

[D] MockMvc Controller 테스트 (api)
- AbstractControllerTest (SpringBootTest + MockMvc, JwtFilter doAnswer
  로 chain 통과 처리), TestSecurityConfig (filter 빈 override)
- 5 클래스 36 테스트: Agent/Contract/Ledger/Settle/User Controller
- happy path + validation 400 + 권한 403 + 인증 401

검증:
- ./gradlew clean build 성공
- 백엔드 단위테스트 94건 (common 33 + batch 19 + api 36 + 기타 6) 통과
- 프론트 tsc -b / vite build 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:08:32 +09:00
parent a314a95ecf
commit 87e4e3d1da
35 changed files with 2962 additions and 110 deletions
+115 -4
View File
@@ -1,9 +1,62 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { Tag } from 'antd';
import { useRef, useState } from 'react';
import { Modal, Switch, Tag, message } from 'antd';
import {
ModalForm, ProForm, ProFormSelect, ProFormText,
ProTable, type ActionType, type ProColumns,
} from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import { companyApi, CompanyResp, CompanySearchParam } from '@/api/company';
import PermissionButton from '@/components/common/PermissionButton';
import { companyApi, CompanyResp, CompanySaveReq, CompanySearchParam } from '@/api/company';
const COMPANY_TYPE_OPTIONS = [
{ value: 'LIFE', label: '생명보험' },
{ value: 'NON_LIFE', label: '손해보험' },
{ value: 'OTHER', label: '기타' },
];
export default function CompanyList() {
const actionRef = useRef<ActionType>();
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const { data: editing } = useQuery({
queryKey: ['company', 'detail', modal.editId],
queryFn: async () => {
// 목록에서 선택한 행 데이터를 editing으로 쓰기 위해 단건 조회가 없으면 목록에서 찾음
// 백엔드 단건 엔드포인트가 없으므로 editRecord state로 대체
return null;
},
enabled: false,
});
const [editRecord, setEditRecord] = useState<CompanyResp | null>(null);
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: CompanyResp) => {
setEditRecord(record);
setModal({ open: true, editId: record.companyId });
};
const onSubmit = async (v: any) => {
const req: CompanySaveReq = {
...v,
isActive: v.isActive ? 'Y' : 'N',
};
if (modal.editId) {
await companyApi.update(modal.editId, req);
message.success('수정 완료');
} else {
await companyApi.create(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<CompanyResp>[] = [
{ title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '회사명 / 사업자번호' } },
@@ -16,15 +69,23 @@ export default function CompanyList() {
{ title: '연락처', dataIndex: 'contactPhone', width: 130, search: false },
{ title: '이메일', dataIndex: 'contactEmail', width: 200, search: false },
{
title: '활성', dataIndex: 'isActive', width: 80,
title: '활성', dataIndex: 'isActive', width: 80, search: false,
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? '활성' : '비활성'}</Tag>,
},
{
title: '액션', valueType: 'option', width: 100, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="PRODUCT_COMPANY" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="보험사 관리" description="제휴 보험사 정보 / 데이터 수신 프로파일">
<ProTable<CompanyResp, CompanySearchParam>
actionRef={actionRef}
rowKey="companyId"
columns={columns}
request={async (params) => {
@@ -35,8 +96,58 @@ export default function CompanyList() {
pagination={{ pageSize: 20, showTotal: (t) => `${t.toLocaleString()}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="PRODUCT_COMPANY" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '보험사 수정' : '보험사 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editId ?? 'new'}
initialValues={editRecord ? {
...editRecord,
isActive: editRecord.isActive === 'Y',
} : { isActive: true }}
onFinish={onSubmit}
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProForm.Group>
<ProFormText
name="companyCode" label="회사코드" rules={[{ required: true }]}
width="md" placeholder="SAMSUNG"
disabled={!!modal.editId}
/>
<ProFormText
name="companyName" label="회사명" rules={[{ required: true }]}
width="md" placeholder="삼성생명"
/>
</ProForm.Group>
<ProForm.Group>
<ProFormSelect
name="companyType" label="구분" width="md"
options={COMPANY_TYPE_OPTIONS}
/>
<ProFormText name="bizNo" label="사업자번호" width="md" placeholder="000-00-00000" />
</ProForm.Group>
<ProForm.Group>
<ProFormText name="contactName" label="담당자명" width="md" />
<ProFormText name="contactPhone" label="연락처" width="md" placeholder="02-1234-5678" />
</ProForm.Group>
<ProFormText
name="contactEmail" label="이메일" placeholder="contact@company.com"
rules={[{ type: 'email', message: '이메일 형식이 아닙니다' }]}
/>
<ProForm.Item name="isActive" label="활성" valuePropName="checked">
<Switch checkedChildren="활성" unCheckedChildren="비활성" />
</ProForm.Item>
</ModalForm>
</PageContainer>
);
}
+140 -4
View File
@@ -1,12 +1,80 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { Tag } from 'antd';
import { useRef, useState } from 'react';
import { Modal, Switch, Tag, message } from 'antd';
import {
ModalForm, ProForm, ProFormDatePicker, ProFormSelect, ProFormText,
ProTable, type ActionType, type ProColumns,
} from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import PermissionButton from '@/components/common/PermissionButton';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { productApi, ProductResp, ProductSearchParam } from '@/api/product';
import { productApi, ProductResp, ProductSaveReq, ProductSearchParam } from '@/api/product';
import { companyApi } from '@/api/company';
const PAY_PERIOD_OPTIONS = [
{ value: 'MONTHLY', label: '월납' },
{ value: 'QUARTERLY', label: '분기납' },
{ value: 'ANNUAL', label: '연납' },
{ value: 'SINGLE', label: '일시납' },
];
export default function ProductList() {
const actionRef = useRef<ActionType>();
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const [editRecord, setEditRecord] = useState<ProductResp | null>(null);
const { data: companies } = useQuery({
queryKey: ['companies', 'all'],
queryFn: () => companyApi.list({ pageSize: 999 }),
enabled: modal.open,
});
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: ProductResp) => {
setEditRecord(record);
setModal({ open: true, editId: record.productId });
};
const onDelete = (record: ProductResp) => {
Modal.confirm({
title: '상품 삭제',
content: `"${record.productName}" 상품을 삭제하시겠습니까?`,
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await productApi.remove(record.productId);
message.success('삭제 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: ProductSaveReq = {
...v,
launchDate: v.launchDate ? dayjs(v.launchDate).format('YYYY-MM-DD') : undefined,
endDate: v.endDate ? dayjs(v.endDate).format('YYYY-MM-DD') : undefined,
isActive: v.isActive ? 'Y' : 'N',
};
if (modal.editId) {
await productApi.update(modal.editId, req);
message.success('수정 완료');
} else {
await productApi.create(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<ProductResp>[] = [
{ title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
@@ -29,14 +97,24 @@ export default function ProductList() {
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? '활성' : '비활성'}</Tag>,
},
{
title: '액션', valueType: 'option', width: 130, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="PRODUCT_PRODUCT" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
<PermissionButton key="del" menuCode="PRODUCT_PRODUCT" permCode="DELETE" size="small" type="link" danger
onClick={() => onDelete(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="상품 관리" description="보험사별 보험 상품 / 보험종류 / 상품군">
<ProTable<ProductResp, ProductSearchParam>
actionRef={actionRef}
rowKey="productId"
columns={columns}
scroll={{ x: 1300 }}
scroll={{ x: 1400 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await productApi.list({ ...rest, pageNum: current, pageSize });
@@ -45,8 +123,66 @@ export default function ProductList() {
pagination={{ pageSize: 20, showTotal: (t) => `${t.toLocaleString()}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="PRODUCT_PRODUCT" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '상품 수정' : '상품 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editId ?? 'new'}
initialValues={editRecord ? {
...editRecord,
launchDate: editRecord.launchDate ? dayjs(editRecord.launchDate) : null,
endDate: editRecord.endDate ? dayjs(editRecord.endDate) : null,
isActive: editRecord.isActive === 'Y',
} : { isActive: true }}
onFinish={onSubmit}
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProForm.Group>
<ProFormText
name="productCode" label="상품코드" rules={[{ required: true }]}
width="md" placeholder="PRD001"
disabled={!!modal.editId}
/>
<ProFormText
name="productName" label="상품명" rules={[{ required: true }]}
width="md" placeholder="삼성 종신보험 플러스"
/>
</ProForm.Group>
<ProForm.Group>
<ProFormSelect
name="companyId" label="보험사" rules={[{ required: true }]} width="md" showSearch
options={companies?.list.map((c) => ({ value: c.companyId, label: c.companyName })) ?? []}
/>
<ProFormSelect
name="insuranceType" label="보험종류" rules={[{ required: true }]} width="md"
options={insuranceCodes.map((c) => ({ value: c.code, label: c.codeName }))}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText name="productGroup" label="상품군" width="md" placeholder="종신/정기/건강" />
<ProFormSelect
name="payPeriodType" label="납입주기" width="md"
options={PAY_PERIOD_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker name="launchDate" label="판매개시일" width="md" />
<ProFormDatePicker name="endDate" label="판매종료일" width="md" />
</ProForm.Group>
<ProForm.Item name="isActive" label="활성" valuePropName="checked">
<Switch checkedChildren="활성" unCheckedChildren="비활성" />
</ProForm.Item>
</ModalForm>
</PageContainer>
);
}
@@ -1,11 +1,68 @@
import { Tag } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Modal, Switch, Tag, message } from 'antd';
import {
ModalForm, ProForm, ProFormDatePicker, 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 { ruleApi, ChargebackRuleResp } from '@/api/rule';
import PermissionButton from '@/components/common/PermissionButton';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { ruleApi, ChargebackRuleResp, ChargebackRuleSaveReq } from '@/api/rule';
export default function ChargebackRuleList() {
const actionRef = useRef<ActionType>();
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const [editRecord, setEditRecord] = useState<ChargebackRuleResp | null>(null);
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: ChargebackRuleResp) => {
setEditRecord(record);
setModal({ open: true, editId: record.cbRuleId });
};
const onDelete = (record: ChargebackRuleResp) => {
Modal.confirm({
title: '환수 규정 삭제',
content: '해당 환수 규정을 삭제하시겠습니까?',
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await ruleApi.removeChargebackRule(record.cbRuleId);
message.success('삭제 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: ChargebackRuleSaveReq = {
...v,
includeOverride: v.includeOverride ? 'Y' : 'N',
installmentAllowed: v.installmentAllowed ? 'Y' : 'N',
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 ruleApi.updateChargebackRule(modal.editId, req);
message.success('수정 완료');
} else {
await ruleApi.createChargebackRule(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<ChargebackRuleResp>[] = [
{ title: '보험사', dataIndex: 'companyCode', width: 100, search: false,
render: (_, r) => <strong>{r.companyCode}</strong> },
@@ -32,11 +89,21 @@ export default function ChargebackRuleList() {
{ title: '최대 분할', dataIndex: 'maxInstallments', width: 90, align: 'right', search: false },
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
{
title: '액션', valueType: 'option', width: 130, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="RULE_CHARGEBACK" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
<PermissionButton key="del" menuCode="RULE_CHARGEBACK" permCode="DELETE" size="small" type="link" danger
onClick={() => onDelete(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="환수 규정" description="실효 계약 환수율 / 분할환수 정책">
<ProTable<ChargebackRuleResp>
actionRef={actionRef}
rowKey="cbRuleId"
columns={columns}
search={false}
@@ -46,8 +113,73 @@ export default function ChargebackRuleList() {
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="RULE_CHARGEBACK" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '환수 규정 수정' : '환수 규정 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editId ?? 'new'}
initialValues={editRecord ? {
...editRecord,
includeOverride: editRecord.includeOverride === 'Y',
installmentAllowed: editRecord.installmentAllowed === 'Y',
effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null,
effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null,
} : { includeOverride: false, installmentAllowed: false }}
onFinish={onSubmit}
submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProForm.Group>
<ProFormText
name="companyCode" label="보험사코드" rules={[{ required: true }]}
width="md" placeholder="SAMSUNG"
/>
<ProFormSelect
name="insuranceType" label="보험종류" rules={[{ required: true }]} width="md"
options={insuranceCodes.map((c) => ({ value: c.code, label: c.codeName }))}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="lapseMonthFrom" label="실효 시작월" rules={[{ required: true }]} width="md"
min={1} max={120}
/>
<ProFormDigit
name="lapseMonthTo" label="실효 종료월" rules={[{ required: true }]} width="md"
min={1} max={120}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="cbRate" label="환수율(%)" rules={[{ required: true }]} width="md"
min={0} max={100} fieldProps={{ precision: 4 }}
/>
<ProFormDigit
name="maxInstallments" label="최대 분할횟수" width="md" min={1} max={36}
/>
</ProForm.Group>
<ProForm.Group>
<ProForm.Item name="includeOverride" label="오버라이드 포함" valuePropName="checked">
<Switch checkedChildren="포함" unCheckedChildren="제외" />
</ProForm.Item>
<ProForm.Item name="installmentAllowed" label="분할환수 허용" valuePropName="checked" style={{ marginLeft: 32 }}>
<Switch checkedChildren="허용" unCheckedChildren="불가" />
</ProForm.Item>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
</ProForm.Group>
</ModalForm>
</PageContainer>
);
}
@@ -1,9 +1,73 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Modal, message } from 'antd';
import {
ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText,
ProTable, type ActionType, type ProColumns,
} from '@ant-design/pro-components';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
import { ruleApi, CommissionRateResp } from '@/api/rule';
import PermissionButton from '@/components/common/PermissionButton';
import { ruleApi, CommissionRateResp, CommissionRateSaveReq } from '@/api/rule';
import { productApi } from '@/api/product';
const GRADE_OPTIONS = [1, 2, 3, 4, 5].map((y) => ({ value: y, label: `${y}년차` }));
export default function CommissionRateList() {
const actionRef = useRef<ActionType>();
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const [editRecord, setEditRecord] = useState<CommissionRateResp | null>(null);
const { data: products } = useQuery({
queryKey: ['products', 'all'],
queryFn: () => productApi.list({ pageSize: 999 }),
enabled: modal.open,
});
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: CommissionRateResp) => {
setEditRecord(record);
setModal({ open: true, editId: record.rateId });
};
const onDelete = (record: CommissionRateResp) => {
Modal.confirm({
title: '수수료율 삭제',
content: '해당 수수료율을 삭제하시겠습니까?',
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await ruleApi.removeCommissionRate(record.rateId);
message.success('삭제 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: CommissionRateSaveReq = {
...v,
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 ruleApi.updateCommissionRate(modal.editId, req);
message.success('수정 완료');
} else {
await ruleApi.createCommissionRate(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<CommissionRateResp>[] = [
{ title: '상품ID', dataIndex: 'productId', width: 90 },
{ title: '상품명', dataIndex: 'productName', width: 200, search: false },
@@ -20,11 +84,21 @@ export default function CommissionRateList() {
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
{ title: '버전', dataIndex: 'version', width: 60, align: 'center', search: false },
{
title: '액션', valueType: 'option', width: 130, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="RULE_COMMISSION" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
<PermissionButton key="del" menuCode="RULE_COMMISSION" permCode="DELETE" size="small" type="link" danger
onClick={() => onDelete(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="수수료율 (보험사 → 시스템)" description="상품·회차별 보험사 통보 수수료율">
<ProTable<CommissionRateResp>
actionRef={actionRef}
rowKey="rateId"
columns={columns}
search={false}
@@ -34,8 +108,57 @@ export default function CommissionRateList() {
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="RULE_COMMISSION" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '수수료율 수정' : '수수료율 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editId ?? 'new'}
initialValues={editRecord ? {
...editRecord,
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="productId" label="상품" rules={[{ required: true }]} width="md" showSearch
options={products?.list.map((p) => ({ value: p.productId, label: p.productName })) ?? []}
/>
<ProFormSelect
name="commissionYear" label="회차" rules={[{ required: true }]} width="md"
options={GRADE_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="ratePct" label="수수료율(%)" rules={[{ required: true }]} width="md"
min={0} max={100} fieldProps={{ precision: 4 }}
/>
<ProFormText name="payMethodCond" label="납입조건" width="md" placeholder="MONTHLY/ALL" />
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="minPremium" label="최소보험료" width="md" min={0}
fieldProps={{ formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
</ProForm.Group>
</ModalForm>
</PageContainer>
);
}
@@ -1,11 +1,57 @@
import { Tag } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Radio, Switch, Tag, message } from 'antd';
import {
ModalForm, ProForm, ProFormSelect, ProFormText, ProFormTextArea,
ProTable, type ActionType, type ProColumns,
} from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import { ruleApi, ExceptionCodeResp } from '@/api/rule';
import PermissionButton from '@/components/common/PermissionButton';
import { ruleApi, ExceptionCodeResp, ExceptionCodeSaveReq } from '@/api/rule';
const DIRECTION_COLOR = { PLUS: 'success', MINUS: 'error' } as const;
const CATEGORY_OPTIONS = [
{ value: 'BONUS', label: '보너스' },
{ value: 'PENALTY', label: '패널티' },
{ value: 'ADJUSTMENT', label: '조정' },
{ value: 'OTHER', label: '기타' },
];
export default function ExceptionCodeList() {
const actionRef = useRef<ActionType>();
const [modal, setModal] = useState<{ open: boolean; editCode?: string }>({ open: false });
const [editRecord, setEditRecord] = useState<ExceptionCodeResp | null>(null);
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: ExceptionCodeResp) => {
setEditRecord(record);
setModal({ open: true, editCode: record.exceptionCode });
};
const onSubmit = async (v: any) => {
const req: ExceptionCodeSaveReq = {
...v,
autoCalcYn: v.autoCalcYn ? 'Y' : 'N',
approvalRequired: v.approvalRequired ? 'Y' : 'N',
taxIncluded: v.taxIncluded ? 'Y' : 'N',
isActive: v.isActive ? 'Y' : 'N',
};
if (modal.editCode) {
await ruleApi.updateExceptionCode(modal.editCode, req);
message.success('수정 완료');
} else {
await ruleApi.createExceptionCode(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<ExceptionCodeResp>[] = [
{ title: '예외코드', dataIndex: 'exceptionCode', width: 120, search: false,
render: (_, r) => <strong>{r.exceptionCode}</strong> },
@@ -34,11 +80,19 @@ export default function ExceptionCodeList() {
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? 'Y' : 'N'}</Tag>,
},
{
title: '액션', valueType: 'option', width: 100, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="RULE_EXCEPTION_CODE" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="예외 코드" description="가산/차감/승인/세금 정책 마스터 (예외금액 원장에서 사용)">
<ProTable<ExceptionCodeResp>
actionRef={actionRef}
rowKey="exceptionCode"
columns={columns}
search={false}
@@ -48,8 +102,70 @@ export default function ExceptionCodeList() {
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="RULE_EXCEPTION_CODE" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editCode ? '예외코드 수정' : '예외코드 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editCode ?? 'new'}
initialValues={editRecord ? {
...editRecord,
autoCalcYn: editRecord.autoCalcYn === 'Y',
approvalRequired: editRecord.approvalRequired === 'Y',
taxIncluded: editRecord.taxIncluded === 'Y',
isActive: editRecord.isActive === 'Y',
} : { direction: 'PLUS', autoCalcYn: false, approvalRequired: false, taxIncluded: false, isActive: true }}
onFinish={onSubmit}
submitter={{ searchConfig: { submitText: modal.editCode ? '수정' : '등록', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProForm.Group>
<ProFormText
name="exceptionCode" label="예외코드" rules={[{ required: true }]}
width="md" placeholder="EX001"
disabled={!!modal.editCode}
/>
<ProFormText
name="exceptionName" label="예외명" rules={[{ required: true }]}
width="md" placeholder="특별 가산금"
/>
</ProForm.Group>
<ProForm.Group>
<ProFormSelect
name="category" label="카테고리" width="md"
options={CATEGORY_OPTIONS}
/>
<ProForm.Item name="direction" label="방향" rules={[{ required: true }]}>
<Radio.Group>
<Radio value="PLUS"> (+)</Radio>
<Radio value="MINUS"> (-)</Radio>
</Radio.Group>
</ProForm.Item>
</ProForm.Group>
<ProForm.Group>
<ProForm.Item name="autoCalcYn" label="자동계산" valuePropName="checked">
<Switch checkedChildren="자동" unCheckedChildren="수동" />
</ProForm.Item>
<ProForm.Item name="approvalRequired" label="승인 필요" valuePropName="checked" style={{ marginLeft: 32 }}>
<Switch checkedChildren="필요" unCheckedChildren="불요" />
</ProForm.Item>
<ProForm.Item name="taxIncluded" label="과세" valuePropName="checked" style={{ marginLeft: 32 }}>
<Switch checkedChildren="과세" unCheckedChildren="비과세" />
</ProForm.Item>
<ProForm.Item name="isActive" label="활성" valuePropName="checked" style={{ marginLeft: 32 }}>
<Switch checkedChildren="활성" unCheckedChildren="비활성" />
</ProForm.Item>
</ProForm.Group>
<ProFormTextArea name="description" label="설명" placeholder="예외 코드 상세 설명" />
</ModalForm>
</PageContainer>
);
}
+125 -2
View File
@@ -1,9 +1,69 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Modal, message } from 'antd';
import {
ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, ProFormText,
ProTable, type ActionType, type ProColumns,
} from '@ant-design/pro-components';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
import { ruleApi, OverrideRuleResp } from '@/api/rule';
import PermissionButton from '@/components/common/PermissionButton';
import { ruleApi, OverrideRuleResp, OverrideRuleSaveReq } from '@/api/rule';
const GRADE_OPTIONS = [1, 2, 3, 4, 5, 6].map((id) => ({ value: id, label: `직급 #${id}` }));
const CALC_TYPE_OPTIONS = [
{ value: 'FLAT', label: 'FLAT (정률)' },
{ value: 'PROGRESSIVE', label: 'PROGRESSIVE (누진)' },
];
export default function OverrideRuleList() {
const actionRef = useRef<ActionType>();
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const [editRecord, setEditRecord] = useState<OverrideRuleResp | null>(null);
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: OverrideRuleResp) => {
setEditRecord(record);
setModal({ open: true, editId: record.overrideId });
};
const onDelete = (record: OverrideRuleResp) => {
Modal.confirm({
title: '오버라이드 규정 삭제',
content: '해당 오버라이드 규정을 삭제하시겠습니까?',
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await ruleApi.removeOverrideRule(record.overrideId);
message.success('삭제 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: OverrideRuleSaveReq = {
...v,
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 ruleApi.updateOverrideRule(modal.editId, req);
message.success('수정 완료');
} else {
await ruleApi.createOverrideRule(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<OverrideRuleResp>[] = [
{ title: '하위 직급', dataIndex: 'fromGradeName', width: 110, search: false,
render: (_, r) => <strong>{r.fromGradeName ?? `#${r.fromGrade}`}</strong> },
@@ -21,11 +81,21 @@ export default function OverrideRuleList() {
},
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
{
title: '액션', valueType: 'option', width: 130, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="RULE_OVERRIDE" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
<PermissionButton key="del" menuCode="RULE_OVERRIDE" permCode="DELETE" size="small" type="link" danger
onClick={() => onDelete(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="오버라이드 규정" description="조직 트리 상향 분배율 (하위 → 상위 직급)">
<ProTable<OverrideRuleResp>
actionRef={actionRef}
rowKey="overrideId"
columns={columns}
search={false}
@@ -35,8 +105,61 @@ export default function OverrideRuleList() {
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="RULE_OVERRIDE" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '오버라이드 수정' : '오버라이드 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editId ?? 'new'}
initialValues={editRecord ? {
...editRecord,
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="fromGrade" label="하위 직급" rules={[{ required: true }]} width="md"
options={GRADE_OPTIONS}
/>
<ProFormSelect
name="toGrade" label="상위 직급" rules={[{ required: true }]} width="md"
options={GRADE_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="overridePct" label="오버라이드율(%)" rules={[{ required: true }]} width="md"
min={0} max={100} fieldProps={{ precision: 4 }}
/>
<ProFormSelect
name="calcType" label="계산방식" width="md"
options={CALC_TYPE_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText name="insuranceTypeCond" label="보험종조건" width="md" placeholder="LIFE/ALL" />
<ProFormDigit
name="capAmount" label="월 상한금액" width="md" min={0}
fieldProps={{ formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',') }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
</ProForm.Group>
</ModalForm>
</PageContainer>
);
}
+118 -2
View File
@@ -1,12 +1,68 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Modal, message } from 'antd';
import {
ModalForm, ProForm, ProFormDatePicker, 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 { ruleApi, PayoutRuleResp } from '@/api/rule';
import { ruleApi, PayoutRuleResp, PayoutRuleSaveReq } from '@/api/rule';
const GRADE_OPTIONS = [1, 2, 3, 4, 5, 6].map((id) => ({ value: id, label: `직급 #${id}` }));
const YEAR_OPTIONS = [1, 2, 3, 4, 5].map((y) => ({ value: y, label: `${y}년차` }));
export default function PayoutRuleList() {
const actionRef = useRef<ActionType>();
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const [editRecord, setEditRecord] = useState<PayoutRuleResp | null>(null);
const openCreate = () => {
setEditRecord(null);
setModal({ open: true });
};
const openEdit = (record: PayoutRuleResp) => {
setEditRecord(record);
setModal({ open: true, editId: record.ruleId });
};
const onDelete = (record: PayoutRuleResp) => {
Modal.confirm({
title: '지급율 삭제',
content: '해당 지급율 규정을 삭제하시겠습니까?',
okType: 'danger',
okText: '삭제',
cancelText: '취소',
onOk: async () => {
await ruleApi.removePayoutRule(record.ruleId);
message.success('삭제 완료');
actionRef.current?.reload();
},
});
};
const onSubmit = async (v: any) => {
const req: PayoutRuleSaveReq = {
...v,
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 ruleApi.updatePayoutRule(modal.editId, req);
message.success('수정 완료');
} else {
await ruleApi.createPayoutRule(req);
message.success('등록 완료');
}
setModal({ open: false });
actionRef.current?.reload();
return true;
};
const columns: ProColumns<PayoutRuleResp>[] = [
{ title: '직급', dataIndex: 'gradeName', width: 100, search: false,
@@ -27,11 +83,21 @@ export default function PayoutRuleList() {
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
{ title: '버전', dataIndex: 'version', width: 60, align: 'center', search: false },
{
title: '액션', valueType: 'option', width: 130, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="RULE_PAYOUT" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
<PermissionButton key="del" menuCode="RULE_PAYOUT" permCode="DELETE" size="small" type="link" danger
onClick={() => onDelete(r)}></PermissionButton>,
],
},
];
return (
<PageContainer title="지급율 (시스템 → 설계사)" description="직급·보험종·회차별 설계사 지급율">
<ProTable<PayoutRuleResp>
actionRef={actionRef}
rowKey="ruleId"
columns={columns}
request={async (params) => {
@@ -41,8 +107,58 @@ export default function PayoutRuleList() {
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
toolBarRender={() => [
<PermissionButton key="create" menuCode="RULE_PAYOUT" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '지급율 수정' : '지급율 등록'}
open={modal.open}
onOpenChange={(o) => !o && setModal({ open: false })}
width={580}
layout="vertical"
key={modal.editId ?? 'new'}
initialValues={editRecord ? {
...editRecord,
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="gradeId" label="직급" rules={[{ required: true }]} width="md"
options={GRADE_OPTIONS}
/>
<ProFormSelect
name="insuranceType" label="보험종류" rules={[{ required: true }]} width="md"
options={insuranceCodes.map((c) => ({ value: c.code, label: c.codeName }))}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormSelect
name="commissionYear" label="회차" rules={[{ required: true }]} width="md"
options={YEAR_OPTIONS}
/>
<ProFormDigit
name="payoutPct" label="지급율(%)" rules={[{ required: true }]} width="md"
min={0} max={100} fieldProps={{ precision: 4 }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText name="payMethodCond" label="납입조건" width="md" placeholder="MONTHLY/ALL" />
<ProFormText name="performanceGrade" label="실적등급" width="md" placeholder="A/B/C" />
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker name="effectiveFrom" label="적용시작" width="md" />
<ProFormDatePicker name="effectiveTo" label="적용종료" width="md" />
</ProForm.Group>
</ModalForm>
</PageContainer>
);
}
+281 -37
View File
@@ -1,63 +1,307 @@
import { useState } from 'react';
import { Button, Card, DatePicker, Descriptions, message, Progress, Space } from 'antd';
import {
Alert,
Button,
Card,
Col,
DatePicker,
message,
Row,
Select,
Space,
Statistic,
Steps,
Table,
Tag,
Typography,
} from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
PlayCircleOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import dayjs, { Dayjs } from 'dayjs';
import { useNavigate } from 'react-router-dom';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import { batchApi } from '@/api/settle';
import PermissionButton from '@/components/common/PermissionButton';
import { batchRunApi, BatchHistoryResp, BatchJobLogResp } from '@/api/batch';
const { Text } = Typography;
const STEP_NAMES = [
{ key: 'receive', label: '데이터 수신' },
{ key: 'transform', label: '데이터 변환' },
{ key: 'reconcile', label: '대사 검증' },
{ key: 'calcRecruit', label: '모집수수료' },
{ key: 'calcMaintain', label: '유지수수료' },
{ key: 'chargebackException', label: '환수/예외' },
{ key: 'calcOverride', label: '오버라이드' },
{ key: 'aggregate', label: '집계' },
] as const;
function currentStepIndex(stepName?: string): number {
if (!stepName) return -1;
return STEP_NAMES.findIndex((s) => s.key === stepName);
}
function formatMs(ms?: number): string {
if (ms == null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function stepStatus(
idx: number,
currentIdx: number,
jobStatus: BatchJobLogResp['status'],
): 'finish' | 'process' | 'wait' | 'error' {
if (jobStatus === 'FAILED' && idx === currentIdx) return 'error';
if (idx < currentIdx) return 'finish';
if (idx === currentIdx) return 'process';
return 'wait';
}
export default function BatchRun() {
const [settleMonth, setSettleMonth] = useState<Dayjs | null>(dayjs());
const navigate = useNavigate();
const [settleMonth, setSettleMonth] = useState<Dayjs>(dayjs());
const [companyCode, setCompanyCode] = useState<string | undefined>(undefined);
const [jobLogId, setJobLogId] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const { data: status, refetch } = useQuery({
queryKey: ['batch', 'status'],
queryFn: batchApi.status,
refetchInterval: (q) => {
const s = (q.state.data as any)?.status;
return s === 'STARTED' || s === 'RUNNING' ? 3000 : false;
const { data: jobStatus } = useQuery<BatchJobLogResp>({
queryKey: ['batch', 'status', jobLogId],
queryFn: () => batchRunApi.status(jobLogId!),
enabled: jobLogId != null,
refetchInterval: (query) => {
const s = query.state.data?.status;
return s === 'COMPLETED' || s === 'FAILED' ? false : 5000;
},
});
const { data: history = [] } = useQuery<BatchHistoryResp[]>({
queryKey: ['batch', 'history'],
queryFn: () => batchRunApi.history(10),
});
const isFinished =
jobStatus?.status === 'COMPLETED' || jobStatus?.status === 'FAILED';
const onRun = async () => {
if (!settleMonth) { message.warning('정산월을 선택하세요'); return; }
if (!settleMonth) {
message.warning('정산월을 선택하세요');
return;
}
setRunning(true);
try {
await batchApi.run(settleMonth.format('YYYYMM'));
message.success('배치 실행 요청됨');
refetch();
} catch {
// 인터셉터에서 처리
const id = await batchRunApi.run(
settleMonth.format('YYYYMM'),
companyCode,
);
setJobLogId(id);
message.success('정산 배치를 실행했습니다');
} catch (err: unknown) {
const e = err as { code?: string; message?: string };
if (e?.code === 'BATCH_RUNNING') {
message.error('이미 실행 중인 배치가 있습니다');
}
} finally {
setRunning(false);
}
};
const currentIdx = currentStepIndex(jobStatus?.stepName);
return (
<PageContainer title="정산 배치 실행">
{/* 컨트롤 영역 */}
<Card style={{ marginBottom: 16 }}>
<Space>
<DatePicker picker="month" value={settleMonth} onChange={setSettleMonth} />
<Button type="primary" onClick={onRun}> </Button>
<Button onClick={() => refetch()}> </Button>
<Space wrap>
<DatePicker
picker="month"
value={settleMonth}
onChange={(v) => v && setSettleMonth(v)}
format="YYYY-MM"
/>
<Select
placeholder="보험사 선택 (전체)"
allowClear
style={{ width: 180 }}
value={companyCode}
onChange={(v) => setCompanyCode(v)}
options={[]}
/>
<PermissionButton
menuCode="BATCH_RUN"
permCode="CREATE"
type="primary"
icon={<PlayCircleOutlined />}
loading={running}
onClick={onRun}
>
</PermissionButton>
{jobLogId && (
<Button
icon={<ReloadOutlined />}
onClick={() => {
setJobLogId(null);
setRunning(false);
}}
>
</Button>
)}
</Space>
</Card>
{status && (
<Card title="최근 배치 상태">
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="작업명">{status.jobName}</Descriptions.Item>
<Descriptions.Item label="정산월">{status.settleMonth}</Descriptions.Item>
<Descriptions.Item label="상태"><CodeBadge groupCode="SETTLE_STATUS" value={status.status} /></Descriptions.Item>
<Descriptions.Item label="현재 Step">{status.currentStep ?? '-'}</Descriptions.Item>
<Descriptions.Item label="시작 시각">{status.startedAt ?? '-'}</Descriptions.Item>
<Descriptions.Item label="종료 시각">{status.finishedAt ?? '-'}</Descriptions.Item>
<Descriptions.Item label="소요 시간">{status.durationSec ? `${status.durationSec}` : '-'}</Descriptions.Item>
<Descriptions.Item label="에러">{status.errorMessage ?? '-'}</Descriptions.Item>
</Descriptions>
{(status.progressPct ?? 0) > 0 && (
<Progress
percent={status.progressPct}
status={status.status === 'FAILED' ? 'exception' : status.status === 'COMPLETED' ? 'success' : 'active'}
style={{ marginTop: 16 }}
{/* 진행 상태 */}
{jobLogId && jobStatus && (
<>
{jobStatus.status === 'FAILED' && jobStatus.errorMessage && (
<Alert
type="error"
message="배치 실패"
description={jobStatus.errorMessage}
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Card
title={
<Space>
<Text strong> </Text>
<Tag
color={
jobStatus.status === 'COMPLETED'
? 'green'
: jobStatus.status === 'FAILED'
? 'red'
: 'blue'
}
>
{jobStatus.status}
</Tag>
</Space>
}
style={{ marginBottom: 16 }}
>
<Steps
current={currentIdx}
status={
jobStatus.status === 'FAILED'
? 'error'
: jobStatus.status === 'COMPLETED'
? 'finish'
: 'process'
}
items={STEP_NAMES.map((s, idx) => ({
title: s.label,
status: stepStatus(idx, currentIdx, jobStatus.status),
description:
idx === currentIdx
? `처리 ${jobStatus.successCount ?? 0}건 / ${formatMs(jobStatus.durationMs)}`
: undefined,
}))}
/>
</Card>
{/* 완료 KPI */}
{isFinished && (
<Card style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Statistic
title="총 건수"
value={jobStatus.totalCount ?? 0}
suffix="건"
/>
</Col>
<Col span={6}>
<Statistic
title="성공"
value={jobStatus.successCount ?? 0}
suffix="건"
valueStyle={{ color: '#52c41a' }}
prefix={<CheckCircleOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="실패"
value={jobStatus.errorCount ?? 0}
suffix="건"
valueStyle={{ color: '#ff4d4f' }}
prefix={<CloseCircleOutlined />}
/>
</Col>
<Col span={6}>
<Statistic
title="소요 시간"
value={formatMs(jobStatus.durationMs)}
/>
</Col>
</Row>
<Space style={{ marginTop: 16 }}>
{jobStatus.status === 'COMPLETED' && (
<Button
type="primary"
onClick={() => navigate('/settle')}
>
</Button>
)}
<Button icon={<ReloadOutlined />} onClick={onRun} loading={running}>
</Button>
</Space>
</Card>
)}
</>
)}
{/* 최근 배치 이력 */}
{history.length > 0 && (
<Card title="최근 배치 이력" size="small">
<Table<BatchHistoryResp>
rowKey="jobLogId"
size="small"
pagination={false}
dataSource={history}
columns={[
{ title: 'ID', dataIndex: 'jobLogId', width: 70 },
{ title: '작업명', dataIndex: 'jobName' },
{
title: '상태',
dataIndex: 'status',
width: 110,
render: (v: string) => (
<Tag
color={
v === 'COMPLETED'
? 'green'
: v === 'FAILED'
? 'red'
: 'blue'
}
>
{v}
</Tag>
),
},
{ title: '마지막 Step', dataIndex: 'stepName', width: 160 },
{ title: '시작', dataIndex: 'startedAt', width: 160 },
{ title: '종료', dataIndex: 'endedAt', width: 160 },
{
title: '소요',
dataIndex: 'durationMs',
width: 80,
render: (v: number) => formatMs(v),
},
]}
/>
</Card>
)}
</PageContainer>
+132 -3
View File
@@ -1,12 +1,52 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Button, Modal, Form, Select, message } from 'antd';
import { DownloadOutlined, FileExcelOutlined } from '@ant-design/icons';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { paymentApi, PaymentResp } from '@/api/payment';
import { paymentApi, PaymentResp, TransferFileResp } from '@/api/payment';
import DatePicker from 'antd/es/date-picker';
import dayjs from 'dayjs';
const { MonthPicker } = DatePicker;
export default function PaymentList() {
const actionRef = useRef<ActionType>();
const { data: statusCodes = [] } = useCommonCodes('PAYMENT_STATUS');
const { data: bankCodes = [] } = useCommonCodes('BANK_CODE');
const [transferModalOpen, setTransferModalOpen] = useState(false);
const [resultModalOpen, setResultModalOpen] = useState(false);
const [transferResult, setTransferResult] = useState<TransferFileResp | null>(null);
const [generating, setGenerating] = useState(false);
const [form] = Form.useForm();
const handleGenerateTransferFile = async () => {
const values = await form.validateFields();
const settleMonth = values.settleMonth ? dayjs(values.settleMonth).format('YYYYMM') : undefined;
if (!settleMonth) return;
setGenerating(true);
try {
const result = await paymentApi.generateTransferFile(settleMonth, values.bankCode);
setTransferResult(result);
setTransferModalOpen(false);
setResultModalOpen(true);
actionRef.current?.reload();
message.success('이체파일이 생성되었습니다');
} catch {
// 에러는 request.ts 인터셉터에서 처리
} finally {
setGenerating(false);
}
};
const handleDownload = () => {
if (!transferResult) return;
window.open(`/api/files/${transferResult.fileId}`, '_blank');
};
const columns: ProColumns<PaymentResp>[] = [
{
@@ -27,14 +67,26 @@ export default function PaymentList() {
},
{ title: '지급일', dataIndex: 'payDate', width: 110, search: false },
{ title: '실패사유', dataIndex: 'failReason', ellipsis: true, search: false },
{
title: '이체파일', dataIndex: 'transferFileId', width: 120, search: false,
render: (_, r) =>
r.transferFileId ? (
<a href={`/api/files/${r.transferFileId}`} target="_blank" rel="noreferrer">
<DownloadOutlined /> #{r.transferFileId}
</a>
) : (
'-'
),
},
];
return (
<PageContainer title="지급 관리" description="확정 정산 → 이체 파일 생성 / 결과 추적">
<ProTable<PaymentResp>
actionRef={actionRef}
rowKey="paymentId"
columns={columns}
scroll={{ x: 1100 }}
scroll={{ x: 1200 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await paymentApi.list({ ...rest, pageNum: current, pageSize });
@@ -47,7 +99,84 @@ export default function PaymentList() {
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
toolBarRender={() => [
<Button
key="transfer-file"
type="primary"
icon={<FileExcelOutlined />}
onClick={() => { form.resetFields(); setTransferModalOpen(true); }}
>
</Button>,
]}
/>
{/* 이체파일 생성 입력 모달 */}
<Modal
title="이체파일 생성"
open={transferModalOpen}
onOk={handleGenerateTransferFile}
onCancel={() => setTransferModalOpen(false)}
confirmLoading={generating}
okText="생성"
cancelText="취소"
destroyOnClose
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="settleMonth"
label="정산월"
rules={[{ required: true, message: '정산월을 선택해주세요' }]}
>
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="정산월 선택" />
</Form.Item>
<Form.Item
name="bankCode"
label="은행"
rules={[{ required: true, message: '은행을 선택해주세요' }]}
>
<Select placeholder="은행 선택" allowClear>
{bankCodes.map((c) => (
<Select.Option key={c.code} value={c.code}>
{c.codeName}
</Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
{/* 이체파일 생성 결과 모달 */}
<Modal
title="이체파일 생성 완료"
open={resultModalOpen}
onOk={() => setResultModalOpen(false)}
onCancel={() => setResultModalOpen(false)}
okText="확인"
cancelButtonProps={{ style: { display: 'none' } }}
footer={[
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={handleDownload}>
</Button>,
<Button key="close" onClick={() => setResultModalOpen(false)}>
</Button>,
]}
>
{transferResult && (
<div style={{ lineHeight: 2 }}>
<div>: <strong>{transferResult.fileName}</strong></div>
<div> ID: <strong>{transferResult.fileId}</strong></div>
<div> : <strong>{transferResult.paymentCount.toLocaleString()}</strong></div>
<div>
:{' '}
<strong>
{Number(transferResult.totalAmount).toLocaleString()}
</strong>
</div>
</div>
)}
</Modal>
</PageContainer>
);
}
+179 -50
View File
@@ -1,30 +1,84 @@
import { useState } from 'react';
import { Card, Checkbox, Col, Empty, message, Row, Space, Table, Typography, Button } from 'antd';
import {
Card,
Checkbox,
Col,
Empty,
message,
Modal,
Row,
Space,
Table,
Tag,
Button,
} from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import { roleApi, RoleVO } from '@/api/user';
import { menuApi, MenuResp } from '@/api/menu';
const { Title } = Typography;
const PERMS = ['READ', 'CREATE', 'UPDATE', 'DELETE', 'APPROVE', 'EXPORT'] as const;
const PERM_LABELS: Record<string, string> = {
READ: '조회',
CREATE: '등록',
UPDATE: '수정',
DELETE: '삭제',
APPROVE: '승인',
EXPORT: '엑셀',
};
type PermKey = string;
type PermKey = string; // "${menuId}:${permCode}"
interface FlatMenuItem {
menuId: number;
menuCode: string;
menuName: string;
menuType: 'DIRECTORY' | 'PAGE' | 'LINK';
indent: number; // 0 = top-level directory, 1 = child page
}
function flattenMenu(nodes: MenuResp[], indent = 0): FlatMenuItem[] {
const out: FlatMenuItem[] = [];
for (const n of nodes) {
out.push({
menuId: n.menuId,
menuCode: n.menuCode,
menuName: n.menuName,
menuType: n.menuType,
indent,
});
if (n.children?.length) {
out.push(...flattenMenu(n.children, indent + 1));
}
}
return out;
}
export default function RoleList() {
const qc = useQueryClient();
const [selected, setSelected] = useState<RoleVO | null>(null);
const [granted, setGranted] = useState<Set<PermKey>>(new Set());
const [saving, setSaving] = useState(false);
const { data: roles = [] } = useQuery({ queryKey: ['role', 'list'], queryFn: roleApi.list });
const { data: tree = [] } = useQuery({ queryKey: ['menu', 'tree'], queryFn: menuApi.tree });
const { data: roles = [] } = useQuery({
queryKey: ['role', 'list'],
queryFn: roleApi.list,
});
const flatPages = flattenPages(tree);
const { data: tree = [] } = useQuery({
queryKey: ['menu', 'tree'],
queryFn: menuApi.tree,
});
const flatMenu = flattenMenu(tree);
const selectRole = async (r: RoleVO) => {
setSelected(r);
const perms = await roleApi.permissions(r.roleId);
const set = new Set<PermKey>();
perms.forEach((p) => p.isGranted === 'Y' && set.add(`${p.menuId}:${p.permCode}`));
perms.forEach((p) => {
if (p.isGranted === 'Y') set.add(`${p.menuId}:${p.permCode}`);
});
setGranted(set);
};
@@ -35,63 +89,150 @@ export default function RoleList() {
setGranted(next);
};
const save = async () => {
if (!selected) return;
const list = Array.from(granted).map((k) => {
const [menuId, permCode] = k.split(':');
return { menuId: Number(menuId), permCode, isGranted: 'Y' };
});
await roleApi.savePermissions(selected.roleId, list);
message.success('권한 매트릭스 저장 완료');
qc.invalidateQueries({ queryKey: ['menu'] });
const toggleAll = () => {
const pages = flatMenu.filter((m) => m.menuType !== 'DIRECTORY');
const allKeys = pages.flatMap((m) => PERMS.map((p) => `${m.menuId}:${p}`));
const allGranted = allKeys.every((k) => granted.has(k));
if (allGranted) {
setGranted(new Set());
} else {
setGranted(new Set(allKeys));
}
};
const save = () => {
if (!selected) return;
Modal.confirm({
title: '권한 저장',
content: `[${selected.roleName}] 역할의 권한 매트릭스를 저장하시겠습니까?`,
okText: '저장',
cancelText: '취소',
onOk: async () => {
setSaving(true);
try {
const list = Array.from(granted).map((k) => {
const [menuId, permCode] = k.split(':');
return { menuId: Number(menuId), permCode, isGranted: 'Y' as const };
});
await roleApi.savePermissions(selected.roleId, list);
message.success('권한 매트릭스 저장 완료');
qc.invalidateQueries({ queryKey: ['menu'] });
} finally {
setSaving(false);
}
},
});
};
const allPages = flatMenu.filter((m) => m.menuType !== 'DIRECTORY');
const allKeys = allPages.flatMap((m) => PERMS.map((p) => `${m.menuId}:${p}`));
const allChecked = allKeys.length > 0 && allKeys.every((k) => granted.has(k));
return (
<PageContainer title="역할 / 권한">
<Row gutter={16}>
<Col span={8}>
{/* 역할 목록 */}
<Col flex="300px">
<Card title="역할 목록" size="small">
<Table<RoleVO>
rowKey="roleId"
dataSource={roles}
size="small"
pagination={false}
onRow={(r) => ({ onClick: () => selectRole(r), style: { cursor: 'pointer' } })}
rowClassName={(r) => r.roleId === selected?.roleId ? 'ant-table-row-selected' : ''}
scroll={{ y: 600 }}
onRow={(r) => ({
onClick: () => selectRole(r),
style: { cursor: 'pointer' },
})}
rowClassName={(r) =>
r.roleId === selected?.roleId ? 'ant-table-row-selected' : ''
}
columns={[
{ title: '코드', dataIndex: 'roleCode', width: 120 },
{ title: '이름', dataIndex: 'roleName' },
{ title: '레벨', dataIndex: 'roleLevel', width: 60, align: 'right' },
{ title: '코드', dataIndex: 'roleCode', width: 110, ellipsis: true },
{ title: '이름', dataIndex: 'roleName', ellipsis: true },
{
title: '활성',
dataIndex: 'isActive',
width: 55,
align: 'center',
render: (v: string) => (
<Tag color={v === 'Y' ? 'green' : 'default'}>
{v === 'Y' ? 'Y' : 'N'}
</Tag>
),
},
]}
/>
</Card>
</Col>
<Col span={16}>
{/* 권한 매트릭스 */}
<Col flex="1">
<Card
title={selected ? `권한 매트릭스 — ${selected.roleName}` : '권한 매트릭스'}
title={
selected
? `권한 매트릭스 — ${selected.roleName}`
: '권한 매트릭스'
}
size="small"
extra={selected && <Button type="primary" onClick={save}></Button>}
extra={
selected && (
<Space>
<Button size="small" onClick={toggleAll}>
{allChecked ? '전체 해제' : '전체 선택'}
</Button>
<Button
type="primary"
size="small"
loading={saving}
onClick={save}
>
</Button>
</Space>
)
}
>
{!selected ? (
<Empty description="역할을 선택하세요" />
<Empty description="좌측 역할을 클릭하세요" />
) : (
<Table
<Table<FlatMenuItem>
size="small"
pagination={false}
rowKey="menuId"
dataSource={flatPages}
scroll={{ y: 500 }}
dataSource={flatMenu}
scroll={{ y: 560 }}
columns={[
{ title: '메뉴', dataIndex: 'menuName', width: 200 },
{ title: '코드', dataIndex: 'menuCode', width: 160 },
...PERMS.map((p) => ({
title: p, key: p, width: 80, align: 'center' as const,
render: (_: unknown, m: MenuResp) => (
<Checkbox
checked={granted.has(`${m.menuId}:${p}`)}
onChange={() => toggle(m.menuId, p)}
/>
{
title: '메뉴',
dataIndex: 'menuName',
width: 220,
render: (name: string, row: FlatMenuItem) => (
<span
style={{
paddingLeft: row.indent * 12,
fontWeight: row.menuType === 'DIRECTORY' ? 600 : 400,
color: row.menuType === 'DIRECTORY' ? '#1677ff' : undefined,
}}
>
{name}
</span>
),
},
...PERMS.map((p) => ({
title: PERM_LABELS[p],
key: p,
width: 64,
align: 'center' as const,
render: (_: unknown, m: FlatMenuItem) => {
if (m.menuType === 'DIRECTORY') return null;
return (
<Checkbox
checked={granted.has(`${m.menuId}:${p}`)}
onChange={() => toggle(m.menuId, p)}
/>
);
},
})),
]}
/>
@@ -102,15 +243,3 @@ export default function RoleList() {
</PageContainer>
);
}
function flattenPages(nodes: MenuResp[]): MenuResp[] {
const out: MenuResp[] = [];
const visit = (ns: MenuResp[]) => {
for (const n of ns) {
if (n.menuType === 'PAGE') out.push(n);
if (n.children?.length) visit(n.children);
}
};
visit(nodes);
return out;
}