Compare commits
1 Commits
main
...
feat/frontend
| Author | SHA1 | Date | |
|---|---|---|---|
| 6356213e86 |
@@ -16,6 +16,13 @@ import UserList from '@/pages/system/UserList';
|
||||
import RoleList from '@/pages/system/RoleList';
|
||||
import CodeList from '@/pages/system/CodeList';
|
||||
|
||||
// 신규: 수수료규정/데이터수신
|
||||
import CommissionRateList from '@/pages/rule/CommissionRateList';
|
||||
import PayoutRuleList from '@/pages/rule/PayoutRuleList';
|
||||
import ProfileList from '@/pages/receive/ProfileList';
|
||||
import ErrorList from '@/pages/receive/ErrorList';
|
||||
import FieldMappingEditor from '@/pages/receive/FieldMappingEditor';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
return token ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
@@ -46,6 +53,16 @@ export default function App() {
|
||||
<Route path="settle/batch" element={<BatchRun />} />
|
||||
<Route path="payments" element={<PaymentList />} />
|
||||
|
||||
{/* 수수료 규정 */}
|
||||
<Route path="rules/commission-rates" element={<CommissionRateList />} />
|
||||
<Route path="rules/payout" element={<PayoutRuleList />} />
|
||||
|
||||
{/* 데이터수신 */}
|
||||
<Route path="receive/profiles" element={<ProfileList />} />
|
||||
<Route path="receive/errors" element={<ErrorList />} />
|
||||
<Route path="receive/mapping/:companyCode/:targetTable" element={<FieldMappingEditor />} />
|
||||
<Route path="receive/mapping/:companyCode" element={<FieldMappingEditor />} />
|
||||
|
||||
{/* 시스템관리 */}
|
||||
<Route path="system/users" element={<UserList />} />
|
||||
<Route path="system/roles" element={<RoleList />} />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { request } from './request';
|
||||
|
||||
export const receiveApi = {
|
||||
// company_profile
|
||||
listProfiles: (params: any) => request.get('/api/receive/profiles', { params }),
|
||||
profileDetail: (companyCode: string) => request.get(`/api/receive/profiles/${companyCode}`),
|
||||
saveProfile: (data: any) => request.post('/api/receive/profiles', data),
|
||||
|
||||
// field_mapping
|
||||
listFieldMappings: (companyCode: string, targetTable?: string) =>
|
||||
request.get(`/api/mapping/${companyCode}/fields`, { params: { targetTable } }),
|
||||
createFieldMapping: (data: any) => request.post('/api/mapping/fields', data),
|
||||
updateFieldMapping: (id: number, data: any) => request.put(`/api/mapping/fields/${id}`, data),
|
||||
deleteFieldMapping: (id: number) => request.delete(`/api/mapping/fields/${id}`),
|
||||
bulkSaveFieldMappings: (companyCode: string, rows: any[]) =>
|
||||
request.post(`/api/mapping/${companyCode}/fields/bulk`, { rows }),
|
||||
|
||||
// code_mapping
|
||||
listCodeMappings: (params: any) => request.get('/api/mapping/codes', { params }),
|
||||
createCodeMapping: (data: any) => request.post('/api/mapping/codes', data),
|
||||
updateCodeMapping: (id: number, data: any) => request.put(`/api/mapping/codes/${id}`, data),
|
||||
deleteCodeMapping: (id: number) => request.delete(`/api/mapping/codes/${id}`),
|
||||
|
||||
// raw / errors
|
||||
listRaw: (params: any) => request.get('/api/receive/raw', { params }),
|
||||
listErrors: (params: any) => request.get('/api/receive/errors', { params }),
|
||||
resolveError: (id: number, data: { status: string; note: string }) =>
|
||||
request.put(`/api/receive/errors/${id}/resolve`, data),
|
||||
|
||||
// upload
|
||||
uploadFile: (templateCode: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return request.post(`/api/upload-templates/${templateCode}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
previewFile: (templateCode: string, file: File, rows = 5) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('rows', String(rows));
|
||||
return request.post(`/api/upload-templates/${templateCode}/preview`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { request } from './request';
|
||||
|
||||
export const ruleApi = {
|
||||
// commission_rate
|
||||
listCommission: (params: any) => request.get('/api/rules/commission-rates', { params }),
|
||||
createCommission: (data: any) => request.post('/api/rules/commission-rates', data),
|
||||
updateCommission: (id: number, data: any) => request.put(`/api/rules/commission-rates/${id}`, data),
|
||||
deleteCommission: (id: number) => request.delete(`/api/rules/commission-rates/${id}`),
|
||||
|
||||
// payout_rule
|
||||
listPayout: (params: any) => request.get('/api/rules/payout', { params }),
|
||||
createPayout: (data: any) => request.post('/api/rules/payout', data),
|
||||
updatePayout: (id: number, data: any) => request.put(`/api/rules/payout/${id}`, data),
|
||||
deletePayout: (id: number) => request.delete(`/api/rules/payout/${id}`),
|
||||
|
||||
// override_rule
|
||||
listOverride: (params: any) => request.get('/api/rules/override', { params }),
|
||||
createOverride: (data: any) => request.post('/api/rules/override', data),
|
||||
updateOverride: (id: number, data: any) => request.put(`/api/rules/override/${id}`, data),
|
||||
deleteOverride: (id: number) => request.delete(`/api/rules/override/${id}`),
|
||||
|
||||
// chargeback_rule
|
||||
listChargeback: (params: any) => request.get('/api/rules/chargeback', { params }),
|
||||
createChargeback: (data: any) => request.post('/api/rules/chargeback', data),
|
||||
updateChargeback: (id: number, data: any) => request.put(`/api/rules/chargeback/${id}`, data),
|
||||
deleteChargeback: (id: number) => request.delete(`/api/rules/chargeback/${id}`),
|
||||
|
||||
// exception_type_code
|
||||
listExceptionCodes: (params: any) => request.get('/api/rules/exception-codes', { params }),
|
||||
createExceptionCode: (data: any) => request.post('/api/rules/exception-codes', data),
|
||||
updateExceptionCode: (code: string, data: any) =>
|
||||
request.put(`/api/rules/exception-codes/${code}`, data),
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Table } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
|
||||
export interface PageState {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface Props<T> extends Omit<TableProps<T>, 'pagination' | 'dataSource'> {
|
||||
data?: { list: T[]; total: number } | null;
|
||||
page: PageState;
|
||||
onPageChange: (p: PageState) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ant Design Table 래퍼.
|
||||
* 서버사이드 페이징 + total 표시 + 100건 미만 데이터에 사용.
|
||||
* (대량 데이터/셀편집은 DataGrid 사용)
|
||||
*/
|
||||
export default function DataTable<T extends object>({ data, page, onPageChange, ...rest }: Props<T>) {
|
||||
return (
|
||||
<Table<T>
|
||||
rowKey={(row: any) => row.id ?? row.agentId ?? row.userId ?? row.companyId ?? row.productId ?? row.ledgerId ?? row.settleId ?? row.paymentId ?? JSON.stringify(row).slice(0, 30)}
|
||||
dataSource={data?.list ?? []}
|
||||
pagination={{
|
||||
current: page.pageNum,
|
||||
pageSize: page.pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `총 ${t.toLocaleString()}건`,
|
||||
onChange: (pageNum, pageSize) => onPageChange({ pageNum, pageSize }),
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, Modal, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import { request } from '@/api/request';
|
||||
|
||||
interface Props {
|
||||
templateCode: string;
|
||||
label?: string;
|
||||
onComplete?: (result: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 버튼.
|
||||
* 1. 템플릿 코드로 미리보기 (첫 5행) → 사용자가 매핑 확인
|
||||
* 2. 확인 후 본 업로드 실행
|
||||
* 3. 결과 표시 (성공/실패/스킵)
|
||||
*/
|
||||
export default function ExcelImportButton({ templateCode, label = '엑셀 업로드', onComplete }: Props) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewRows, setPreviewRows] = useState<any[]>([]);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const beforeUpload = async (file: File) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('rows', '5');
|
||||
const r = await request.post(`/api/upload-templates/${templateCode}/preview`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
setPreviewRows(r as any);
|
||||
setPendingFile(file);
|
||||
setPreviewOpen(true);
|
||||
} catch (e: any) {
|
||||
message.error('미리보기 실패: ' + (e.message ?? ''));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!pendingFile) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', pendingFile);
|
||||
const r: any = await request.post(`/api/upload-templates/${templateCode}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
message.success(`${r.successCount}건 업로드 완료 (실패 ${r.errorCount}건)`);
|
||||
setPreviewOpen(false);
|
||||
onComplete?.(r);
|
||||
} catch (e: any) {
|
||||
message.error('업로드 실패: ' + (e.message ?? ''));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Upload beforeUpload={beforeUpload} showUploadList={false} accept=".xlsx,.xls,.csv">
|
||||
<Button icon={<UploadOutlined />}>{label}</Button>
|
||||
</Upload>
|
||||
<Modal
|
||||
open={previewOpen}
|
||||
title="업로드 미리보기 (첫 5행)"
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
onOk={confirmUpload}
|
||||
okText="업로드 실행"
|
||||
confirmLoading={busy}
|
||||
width={900}
|
||||
>
|
||||
{previewRows.length === 0 ? (
|
||||
<div>표시할 데이터가 없습니다.</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{Object.keys(previewRows[0] ?? {}).map((k) => (
|
||||
<th key={k} style={{ border: '1px solid #ddd', padding: 4, background: '#fafafa' }}>{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{Object.keys(previewRows[0] ?? {}).map((k) => (
|
||||
<td key={k} style={{ border: '1px solid #ddd', padding: 4 }}>{String(row[k] ?? '')}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, Form, Input, Modal, Row, Select, message } from 'antd';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** code_mapping 등록/수정. */
|
||||
export default function CodeMappingModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.codeId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) form.setFieldsValue(initial);
|
||||
else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ isActive: 'Y' });
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
if (isEdit) await receiveApi.updateCodeMapping(initial.codeId, v);
|
||||
else await receiveApi.createCodeMapping(v);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '코드 매핑 수정' : '코드 매핑 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보험사 코드" name="companyCode" rules={[{ required: true }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="코드 유형" name="codeType" rules={[{ required: true }]}
|
||||
extra="예: INSURANCE_TYPE / PAY_CYCLE / CONTRACT_STATUS">
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="원본 코드" name="sourceCode" rules={[{ required: true }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="원본 명" name="sourceName">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="표준 코드 (ga 시스템)" name="targetCode" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="표준 명" name="targetName">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="활성" name="isActive">
|
||||
<Select options={[{ value: 'Y', label: '사용' }, { value: 'N', label: '미사용' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Tag } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
|
||||
export default function ErrorList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['receive-errors', param],
|
||||
queryFn: () => receiveApi.listErrors(param),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'companyCode', label: '보험사 코드' },
|
||||
{ type: 'code', name: 'errorType', label: '에러 유형', groupCode: 'ERROR_TYPE' },
|
||||
{ type: 'code', name: 'resolveStatus', label: '처리 상태', groupCode: 'RESOLVE_STATUS' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '보험사', dataIndex: 'companyName' },
|
||||
{ title: '에러 유형', dataIndex: 'errorTypeName',
|
||||
render: (v: string) => <Tag color="red">{v}</Tag> },
|
||||
{ title: '필드', dataIndex: 'errorField' },
|
||||
{ title: '값', dataIndex: 'errorValue', ellipsis: true },
|
||||
{ title: '메시지', dataIndex: 'errorMessage', ellipsis: true },
|
||||
{ title: '처리상태', dataIndex: 'resolveStatusName',
|
||||
render: (v: string, r: any) => v === '처리완료'
|
||||
? <Tag color="green">{v}</Tag>
|
||||
: <Tag color="orange">{v}</Tag> },
|
||||
{ title: '발생시각', dataIndex: 'createdAt',
|
||||
render: (v: string) => v && dayjs(v).format('YYYY-MM-DD HH:mm') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Input, Select, Space, Table, message } from 'antd';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
|
||||
interface MappingRow {
|
||||
mappingId?: number;
|
||||
sourceField: string;
|
||||
sourceColIndex?: number;
|
||||
targetTable: string;
|
||||
targetField: string;
|
||||
dataType?: string; // STRING / NUMBER / DECIMAL / DATE / INTEGER
|
||||
transformRule?: string; // TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / CODE_MAP / DEFAULT
|
||||
defaultValue?: string;
|
||||
isRequired?: string;
|
||||
sortOrder: number;
|
||||
__dirty?: boolean;
|
||||
}
|
||||
|
||||
const TARGET_TABLES = [
|
||||
{ value: 'recruit_ledger', label: '모집수수료원장' },
|
||||
{ value: 'maintain_ledger', label: '유지수수료원장' },
|
||||
];
|
||||
|
||||
const DATA_TYPES = ['STRING', 'NUMBER', 'DECIMAL', 'DATE', 'INTEGER'].map((v) => ({ value: v, label: v }));
|
||||
|
||||
const TRANSFORM_RULES = [
|
||||
{ value: '', label: '(없음)' },
|
||||
{ value: 'TRIM', label: '공백제거' },
|
||||
{ value: 'UPPER', label: '대문자' },
|
||||
{ value: 'LOWER', label: '소문자' },
|
||||
{ value: 'DATE', label: '날짜변환' },
|
||||
{ value: 'DIVIDE', label: '나누기' },
|
||||
{ value: 'MULTIPLY', label: '곱하기' },
|
||||
{ value: 'CODE_MAP', label: '코드매핑' },
|
||||
{ value: 'DEFAULT', label: '기본값' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 보험사별 필드 매핑 편집기.
|
||||
* URL: /receive/mapping/:companyCode/:targetTable
|
||||
*/
|
||||
export default function FieldMappingEditor() {
|
||||
const { companyCode = '', targetTable: tt } = useParams<{ companyCode: string; targetTable?: string }>();
|
||||
const [targetTable, setTargetTable] = useState<string>(tt ?? 'recruit_ledger');
|
||||
const [rows, setRows] = useState<MappingRow[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { refetch, isLoading } = useQuery({
|
||||
queryKey: ['fieldMappings', companyCode, targetTable],
|
||||
queryFn: async () => {
|
||||
const r: any = await receiveApi.listFieldMappings(companyCode, targetTable);
|
||||
const list: MappingRow[] = (Array.isArray(r) ? r : (r?.list ?? [])).map((x: any, i: number) => ({
|
||||
...x,
|
||||
sortOrder: x.sortOrder ?? i + 1,
|
||||
}));
|
||||
setRows(list);
|
||||
return list;
|
||||
},
|
||||
enabled: !!companyCode && !!targetTable,
|
||||
});
|
||||
|
||||
const addRow = () => {
|
||||
setRows([
|
||||
...rows,
|
||||
{
|
||||
sourceField: '',
|
||||
targetTable,
|
||||
targetField: '',
|
||||
dataType: 'STRING',
|
||||
sortOrder: rows.length + 1,
|
||||
isRequired: 'N',
|
||||
__dirty: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const next = rows.filter((_, i) => i !== idx).map((r, i) => ({ ...r, sortOrder: i + 1, __dirty: true }));
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const move = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= rows.length) return;
|
||||
const next = [...rows];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
setRows(next.map((r, i) => ({ ...r, sortOrder: i + 1, __dirty: true })));
|
||||
};
|
||||
|
||||
const setCell = (idx: number, field: keyof MappingRow, value: any) => {
|
||||
const next = [...rows];
|
||||
next[idx] = { ...next[idx], [field]: value, __dirty: true };
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
// 검증
|
||||
for (const r of rows) {
|
||||
if (!r.sourceField || !r.targetField) {
|
||||
message.error('원본/타겟 필드는 모두 채워야 합니다');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await receiveApi.bulkSaveFieldMappings(companyCode, rows);
|
||||
message.success('저장 완료');
|
||||
refetch();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '순서', width: 80, render: (_: any, _r: MappingRow, idx: number) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => move(idx, -1)} />
|
||||
<Button size="small" icon={<ArrowDownOutlined />} disabled={idx === rows.length - 1} onClick={() => move(idx, 1)} />
|
||||
<span>{idx + 1}</span>
|
||||
</Space>
|
||||
)},
|
||||
{ title: '원본 필드명', width: 180, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input value={r.sourceField} onChange={(e) => setCell(idx, 'sourceField', e.target.value)} />
|
||||
)},
|
||||
{ title: '컬럼 인덱스', width: 110, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input type="number" value={r.sourceColIndex} onChange={(e) =>
|
||||
setCell(idx, 'sourceColIndex', e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||
)},
|
||||
{ title: '타겟 필드명', width: 180, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input value={r.targetField} onChange={(e) => setCell(idx, 'targetField', e.target.value)} />
|
||||
)},
|
||||
{ title: '데이터 타입', width: 120, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Select size="small" style={{ width: '100%' }} value={r.dataType}
|
||||
onChange={(v) => setCell(idx, 'dataType', v)} options={DATA_TYPES} />
|
||||
)},
|
||||
{ title: '변환 규칙', width: 120, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Select size="small" style={{ width: '100%' }} value={r.transformRule ?? ''}
|
||||
onChange={(v) => setCell(idx, 'transformRule', v || undefined)} options={TRANSFORM_RULES} />
|
||||
)},
|
||||
{ title: '기본값', width: 140, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Input size="small" value={r.defaultValue ?? ''} onChange={(e) => setCell(idx, 'defaultValue', e.target.value || undefined)} />
|
||||
)},
|
||||
{ title: '필수', width: 70, render: (_: any, r: MappingRow, idx: number) => (
|
||||
<Select size="small" style={{ width: '100%' }} value={r.isRequired ?? 'N'}
|
||||
onChange={(v) => setCell(idx, 'isRequired', v)}
|
||||
options={[{ value: 'Y', label: 'Y' }, { value: 'N', label: 'N' }]} />
|
||||
)},
|
||||
{ title: '', width: 60, render: (_: any, _r: MappingRow, idx: number) => (
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => removeRow(idx)} />
|
||||
)},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer title={`필드 매핑 — ${companyCode}`}>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<span>대상 테이블:</span>
|
||||
<Select value={targetTable} onChange={setTargetTable} options={TARGET_TABLES} style={{ width: 220 }} />
|
||||
<Button onClick={() => refetch()}>새로고침</Button>
|
||||
<Button onClick={() => navigate(-1)}>뒤로</Button>
|
||||
</Space>
|
||||
|
||||
<Card>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={addRow}>행 추가</Button>
|
||||
<PermissionButton menu="MAPPING" perm="UPDATE" type="primary"
|
||||
icon={<SaveOutlined />} onClick={save}>저장</PermissionButton>
|
||||
{rows.some((r) => r.__dirty) && <span style={{ color: '#fa8c16' }}>● 변경됨</span>}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey={(r, i) => `${r.mappingId ?? 'new'}_${i}`}
|
||||
dataSource={rows}
|
||||
columns={columns as any}
|
||||
pagination={false}
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Space, Tag } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import ProfileModal from './ProfileModal';
|
||||
|
||||
export default function ProfileList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['receive-profiles', param],
|
||||
queryFn: () => receiveApi.listProfiles(param),
|
||||
});
|
||||
|
||||
const openCreate = () => { setEditing(null); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setEditing(row); setModalOpen(true); };
|
||||
const goMapping = (companyCode: string) => navigate(`/receive/mapping/${companyCode}/recruit_ledger`);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'searchKeyword', label: '보험사 코드/명' },
|
||||
{ type: 'code', name: 'dataFormat', label: '데이터 형식', groupCode: 'DATA_FORMAT' },
|
||||
{ type: 'code', name: 'receiveMethod', label: '수신 방식', groupCode: 'RECEIVE_METHOD' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menu="RECEIVE_PROFILE" perm="CREATE" type="primary" onClick={openCreate}>신규</PermissionButton>
|
||||
</Space>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '보험사 코드', dataIndex: 'companyCode' },
|
||||
{ title: '보험사명', dataIndex: 'companyName' },
|
||||
{ title: '형식', dataIndex: 'dataFormatName' },
|
||||
{ title: '수신방식', dataIndex: 'receiveMethodName' },
|
||||
{ title: '인코딩', dataIndex: 'fileEncoding' },
|
||||
{ title: '필드매핑', dataIndex: 'fieldMappingCount', align: 'right' as const,
|
||||
render: (v: number) => <Tag color={v > 0 ? 'blue' : 'default'}>{v}</Tag> },
|
||||
{ title: '활성', dataIndex: 'isActive',
|
||||
render: (v: string) => v === 'Y' ? <Tag color="green">사용</Tag> : <Tag>미사용</Tag> },
|
||||
{ title: '', width: 220, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<PermissionButton menu="RECEIVE_PROFILE" perm="UPDATE" size="small" onClick={() => openEdit(r)}>수정</PermissionButton>
|
||||
<Button size="small" onClick={() => goMapping(r.companyCode)}>필드매핑</Button>
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<ProfileModal
|
||||
open={modalOpen}
|
||||
initial={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, Form, Input, InputNumber, Modal, Row, Select, message } from 'antd';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { receiveApi } from '@/api/receive';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** company_profile 등록/수정 (UPSERT). companyCode를 PK로 사용. */
|
||||
export default function ProfileModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.companyCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) form.setFieldsValue(initial);
|
||||
else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
dataFormat: 'EXCEL', receiveMethod: 'MANUAL',
|
||||
fileEncoding: 'UTF-8', headerRow: 1, dataStartRow: 2, isActive: 'Y',
|
||||
});
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
await receiveApi.saveProfile(v);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const dataFormat = Form.useWatch('dataFormat', form);
|
||||
const receiveMethod = Form.useWatch('receiveMethod', form);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '보험사 프로파일 수정' : '보험사 프로파일 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '저장' : '등록'}
|
||||
width={760}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보험사 코드" name="companyCode" rules={[{ required: true, max: 20 }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="활성" name="isActive">
|
||||
<Select options={[{ value: 'Y', label: '사용' }, { value: 'N', label: '미사용' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="데이터 형식" name="dataFormat" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="DATA_FORMAT" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수신 방식" name="receiveMethod" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="RECEIVE_METHOD" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{(dataFormat === 'EXCEL' || dataFormat === 'CSV') && (
|
||||
<>
|
||||
<Col span={8}>
|
||||
<Form.Item label="인코딩" name="fileEncoding">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="구분자(CSV)" name="delimiter" extra="예: , 또는 \\t">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="시트명(EXCEL)" name="sheetName">
|
||||
<Input placeholder="비우면 첫 시트" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="헤더 행" name="headerRow">
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="데이터 시작 행" name="dataStartRow">
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="날짜 포맷" name="dateFormat" extra="예: yyyyMMdd / yyyy-MM-dd">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="금액 포맷" name="amountFormat" extra="예: WON(원), JEON(전)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
|
||||
{receiveMethod === 'API' && (
|
||||
<Col span={24}>
|
||||
<Form.Item label="API URL" name="apiUrl" extra="{settleMonth} placeholder 사용 가능">
|
||||
<Input placeholder="https://api.company.com/commission?month={settleMonth}" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{(receiveMethod === 'FTP' || receiveMethod === 'SFTP') && (
|
||||
<>
|
||||
<Col span={12}>
|
||||
<Form.Item label="FTP 호스트" name="ftpHost">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="FTP 경로" name="ftpPath">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Item label="수신 스케줄(cron)" name="receiveSchedule" extra="예: 0 0 2 * * ? = 매일 2시">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, Input, InputNumber, Modal, Row, Select, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function ChargebackRuleModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.cbRuleId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
cbRate: initial.cbRate != null ? initial.cbRate * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ includeOverride: 'N', installmentAllowed: 'N' });
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
cbRate: v.cbRate != null ? v.cbRate / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateChargeback(initial.cbRuleId, body);
|
||||
else await ruleApi.createChargeback(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '환수 규정 수정' : '환수 규정 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보험사 코드" name="companyCode" rules={[{ required: true }]}
|
||||
extra="빈값 허용 X — 공통 적용은 'ALL' 추후 예약">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보종" name="insuranceType" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="INSURANCE_TYPE" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="실효 시작 개월" name="lapseMonthFrom" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="실효 종료 개월" name="lapseMonthTo" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={120} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="환수율 (%)" name="cbRate" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="오버라이드 포함" name="includeOverride">
|
||||
<Select options={[{ value: 'Y', label: '포함' }, { value: 'N', label: '미포함' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="분납 허용" name="installmentAllowed">
|
||||
<Select options={[{ value: 'Y', label: '허용' }, { value: 'N', label: '불허' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="최대 분납 회수" name="maxInstallments">
|
||||
<InputNumber min={1} max={36} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Popconfirm, Space, Tag, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import MoneyText from '@/components/common/MoneyText';
|
||||
import CommissionRateModal from './CommissionRateModal';
|
||||
|
||||
export default function CommissionRateList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['commission-rates', param],
|
||||
queryFn: () => ruleApi.listCommission(param),
|
||||
});
|
||||
|
||||
const openCreate = () => { setEditing(null); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setEditing(row); setModalOpen(true); };
|
||||
const handleDelete = async (id: number) => {
|
||||
try { await ruleApi.deleteCommission(id); message.success('삭제 완료'); refetch(); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'searchKeyword', label: '상품명/코드' },
|
||||
{ type: 'text', name: 'commissionYear', label: '연차' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menu="RULE_COMMISSION" perm="CREATE" type="primary" onClick={openCreate}>신규</PermissionButton>
|
||||
</Space>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '보험사', dataIndex: 'companyName' },
|
||||
{ title: '상품명', dataIndex: 'productName' },
|
||||
{ title: '연차', dataIndex: 'commissionYear', width: 70, align: 'right' as const },
|
||||
{
|
||||
title: '수수료율',
|
||||
dataIndex: 'ratePct',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => v != null ? <Tag color="blue">{(v * 100).toFixed(2)}%</Tag> : null,
|
||||
},
|
||||
{ title: '최소보험료', dataIndex: 'minPremium', align: 'right' as const, render: (v: number) => <MoneyText value={v} /> },
|
||||
{ title: '효력시작', dataIndex: 'effectiveFrom', render: (v: string) => v && dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '효력종료', dataIndex: 'effectiveTo', render: (v: string) => v && dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '버전', dataIndex: 'version', width: 60, align: 'right' as const },
|
||||
{ title: '', width: 140, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<PermissionButton menu="RULE_COMMISSION" perm="UPDATE" size="small" onClick={() => openEdit(r)}>수정</PermissionButton>
|
||||
<Popconfirm title="삭제하시겠습니까?" onConfirm={() => handleDelete(r.rateId)}>
|
||||
<PermissionButton menu="RULE_COMMISSION" perm="DELETE" size="small" danger>삭제</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<CommissionRateModal
|
||||
open={modalOpen}
|
||||
initial={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, InputNumber, Modal, Row, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any; // 수정 시 row 데이터
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** commission_rate 등록/수정 Modal. */
|
||||
export default function CommissionRateModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.rateId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
ratePct: initial.ratePct != null ? initial.ratePct * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
ratePct: v.ratePct != null ? v.ratePct / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateCommission(initial.rateId, body);
|
||||
else await ruleApi.createCommission(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
// request 인터셉터에서 처리
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '수수료율 수정' : '수수료율 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="상품 ID" name="productId" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수수료 연차" name="commissionYear" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수수료율 (%)" name="ratePct" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="최소 보험료" name="minPremium">
|
||||
<InputNumber min={0} step={1000} style={{ width: '100%' }} formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="납입 조건" name="payMethodCond" extra="예: REGULAR / LUMP / 빈값=공통">
|
||||
<input className="ant-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, Form, Input, Modal, Row, Select, message } from 'antd';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function ExceptionTypeCodeModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.exceptionCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) form.setFieldsValue(initial);
|
||||
else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
direction: 'PLUS', autoCalcYn: 'N', approvalRequired: 'Y',
|
||||
taxIncluded: 'Y', isActive: 'Y',
|
||||
});
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateExceptionCode(initial.exceptionCode, v);
|
||||
else await ruleApi.createExceptionCode(v);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '예외코드 수정' : '예외코드 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="예외 코드" name="exceptionCode" rules={[{ required: true, max: 30 }]}>
|
||||
<Input disabled={isEdit} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="예외 명" name="exceptionName" rules={[{ required: true, max: 100 }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="카테고리" name="category" rules={[{ required: true }]}>
|
||||
<Input placeholder="예: BONUS / PENALTY / ADJUST" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="방향" name="direction" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'PLUS', label: '+ (가산)' }, { value: 'MINUS', label: '- (차감)' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="자동 계산" name="autoCalcYn">
|
||||
<Select options={[{ value: 'Y', label: '자동' }, { value: 'N', label: '수동' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="승인 필요" name="approvalRequired">
|
||||
<Select options={[{ value: 'Y', label: '필요' }, { value: 'N', label: '불필요' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="세금 포함" name="taxIncluded">
|
||||
<Select options={[{ value: 'Y', label: '포함' }, { value: 'N', label: '미포함' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="설명" name="description">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="사용" name="isActive">
|
||||
<Select options={[{ value: 'Y', label: '사용' }, { value: 'N', label: '미사용' }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, InputNumber, Modal, Row, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function OverrideRuleModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.overrideId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
overridePct: initial.overridePct != null ? initial.overridePct * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
overridePct: v.overridePct != null ? v.overridePct / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updateOverride(initial.overrideId, body);
|
||||
else await ruleApi.createOverride(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '오버라이드 수정' : '오버라이드 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="하위 직급 (from_grade)" name="fromGrade" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="상위 직급 (to_grade)" name="toGrade" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="오버라이드율 (%)" name="overridePct" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="계산 방식" name="calcType" extra="예: AGENT_AMT / COMPANY_AMT">
|
||||
<CodeSelect groupCode="OVERRIDE_CALC_TYPE" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보종 조건" name="insuranceTypeCond" extra="빈값=공통">
|
||||
<CodeSelect groupCode="INSURANCE_TYPE" includeAll />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="상한 금액" name="capAmount">
|
||||
<InputNumber min={0} step={10000} style={{ width: '100%' }}
|
||||
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Popconfirm, Space, Tag, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataTable from '@/components/common/DataTable';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import PayoutRuleModal from './PayoutRuleModal';
|
||||
|
||||
export default function PayoutRuleList() {
|
||||
const [param, setParam] = useState<any>({ pageNum: 1, pageSize: 20 });
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['payout-rules', param],
|
||||
queryFn: () => ruleApi.listPayout(param),
|
||||
});
|
||||
|
||||
const openCreate = () => { setEditing(null); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setEditing(row); setModalOpen(true); };
|
||||
const handleDelete = async (id: number) => {
|
||||
try { await ruleApi.deletePayout(id); message.success('삭제 완료'); refetch(); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'code', name: 'insuranceType', label: '보종', groupCode: 'INSURANCE_TYPE' },
|
||||
{ type: 'text', name: 'commissionYear', label: '연차' },
|
||||
]}
|
||||
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
|
||||
/>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menu="RULE_PAYOUT" perm="CREATE" type="primary" onClick={openCreate}>신규</PermissionButton>
|
||||
</Space>
|
||||
<DataTable
|
||||
data={data as any}
|
||||
page={{ pageNum: param.pageNum, pageSize: param.pageSize }}
|
||||
onPageChange={(p) => setParam({ ...param, ...p })}
|
||||
loading={isLoading}
|
||||
columns={[
|
||||
{ title: '직급', dataIndex: 'gradeName' },
|
||||
{ title: '보종', dataIndex: 'insuranceTypeName' },
|
||||
{ title: '연차', dataIndex: 'commissionYear', width: 70, align: 'right' as const },
|
||||
{
|
||||
title: '지급률',
|
||||
dataIndex: 'payoutPct',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => v != null ? <Tag color="green">{(v * 100).toFixed(2)}%</Tag> : null,
|
||||
},
|
||||
{ title: '실적등급', dataIndex: 'performanceGrade' },
|
||||
{ title: '효력시작', dataIndex: 'effectiveFrom', render: (v: string) => v && dayjs(v).format('YYYY-MM-DD') },
|
||||
{ title: '버전', dataIndex: 'version', width: 60, align: 'right' as const },
|
||||
{ title: '', width: 140, render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<PermissionButton menu="RULE_PAYOUT" perm="UPDATE" size="small" onClick={() => openEdit(r)}>수정</PermissionButton>
|
||||
<Popconfirm title="삭제하시겠습니까?" onConfirm={() => handleDelete(r.ruleId)}>
|
||||
<PermissionButton menu="RULE_PAYOUT" perm="DELETE" size="small" danger>삭제</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)},
|
||||
]}
|
||||
/>
|
||||
<PayoutRuleModal
|
||||
open={modalOpen}
|
||||
initial={editing}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Col, DatePicker, Form, InputNumber, Modal, Row, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from '@/components/common/CodeSelect';
|
||||
import { ruleApi } from '@/api/rule';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initial?: any;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function PayoutRuleModal({ open, initial, onClose, onSaved }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isEdit = !!initial?.ruleId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (initial) {
|
||||
form.setFieldsValue({
|
||||
...initial,
|
||||
payoutPct: initial.payoutPct != null ? initial.payoutPct * 100 : undefined,
|
||||
effectiveFrom: initial.effectiveFrom ? dayjs(initial.effectiveFrom) : null,
|
||||
effectiveTo: initial.effectiveTo ? dayjs(initial.effectiveTo) : null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, initial, form]);
|
||||
|
||||
const submit = async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
payoutPct: v.payoutPct != null ? v.payoutPct / 100 : undefined,
|
||||
effectiveFrom: v.effectiveFrom ? v.effectiveFrom.format('YYYY-MM-DD') : undefined,
|
||||
effectiveTo: v.effectiveTo ? v.effectiveTo.format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
try {
|
||||
if (isEdit) await ruleApi.updatePayout(initial.ruleId, body);
|
||||
else await ruleApi.createPayout(body);
|
||||
message.success(isEdit ? '수정 완료' : '등록 완료');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={isEdit ? '지급률 수정' : '지급률 등록'}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
okText={isEdit ? '수정' : '등록'}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="직급 ID" name="gradeId" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="보종" name="insuranceType" rules={[{ required: true }]}>
|
||||
<CodeSelect groupCode="INSURANCE_TYPE" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="수수료 연차" name="commissionYear" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="지급률 (%)" name="payoutPct" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} step={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="실적 등급" name="performanceGrade" extra="예: S / A / B / 빈값=공통">
|
||||
<input className="ant-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="납입 조건" name="payMethodCond">
|
||||
<input className="ant-input" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 시작" name="effectiveFrom" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="효력 종료" name="effectiveTo">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* 메뉴 코드 → React 컴포넌트 매핑.
|
||||
* 백엔드 menu 테이블의 component_path 와 1:1 대응.
|
||||
* 새 화면 추가 시 이 맵에만 등록하면 자동 라우팅된다.
|
||||
*/
|
||||
const componentMap: Record<string, React.LazyExoticComponent<React.ComponentType<any>>> = {
|
||||
// 조직/인사
|
||||
'org/AgentList': lazy(() => import('@/pages/org/AgentList')),
|
||||
'org/AgentDetail': lazy(() => import('@/pages/org/AgentDetail')),
|
||||
'org/AgentForm': lazy(() => import('@/pages/org/AgentForm')),
|
||||
|
||||
// 계약
|
||||
'product/ContractList': lazy(() => import('@/pages/product/ContractList')),
|
||||
|
||||
// 수수료규정
|
||||
'rule/CommissionRateList': lazy(() => import('@/pages/rule/CommissionRateList')),
|
||||
'rule/PayoutRuleList': lazy(() => import('@/pages/rule/PayoutRuleList')),
|
||||
|
||||
// 데이터수신
|
||||
'receive/ProfileList': lazy(() => import('@/pages/receive/ProfileList')),
|
||||
'receive/ErrorList': lazy(() => import('@/pages/receive/ErrorList')),
|
||||
|
||||
// 원장
|
||||
'ledger/RecruitLedger': lazy(() => import('@/pages/ledger/RecruitLedger')),
|
||||
'ledger/ExceptionLedger': lazy(() => import('@/pages/ledger/ExceptionLedger')),
|
||||
|
||||
// 정산/지급/배치
|
||||
'settle/SettleList': lazy(() => import('@/pages/settle/SettleList')),
|
||||
'settle/PaymentList': lazy(() => import('@/pages/settle/PaymentList')),
|
||||
'settle/BatchRun': lazy(() => import('@/pages/settle/BatchRun')),
|
||||
|
||||
// 시스템관리
|
||||
'system/UserList': lazy(() => import('@/pages/system/UserList')),
|
||||
'system/RoleList': lazy(() => import('@/pages/system/RoleList')),
|
||||
'system/CodeList': lazy(() => import('@/pages/system/CodeList')),
|
||||
};
|
||||
|
||||
export interface MenuRoute {
|
||||
menuPath: string; // 라우트 경로 (예: 'rule/commission-rates')
|
||||
componentPath: string; // componentMap 키 (예: 'rule/CommissionRateList')
|
||||
}
|
||||
|
||||
/** 메뉴 목록을 받아 React Route 배열로 변환. */
|
||||
export function buildDynamicRoutes(menus: MenuRoute[]) {
|
||||
return menus
|
||||
.map((m) => {
|
||||
const Cmp = componentMap[m.componentPath];
|
||||
if (!Cmp) {
|
||||
console.warn(`[router] component not found: ${m.componentPath}`);
|
||||
return null;
|
||||
}
|
||||
const path = m.menuPath.replace(/^\//, '');
|
||||
return (
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
element={
|
||||
<Suspense fallback={<Spin />}>
|
||||
<Cmp />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
Reference in New Issue
Block a user