From 6356213e86ba827f14f09d7d22a7f9963cb5eb61 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Tue, 12 May 2026 22:16:29 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ga-frontend/src/App.tsx | 17 ++ ga-frontend/src/api/receive.ts | 46 +++++ ga-frontend/src/api/rule.ts | 33 ++++ .../src/components/common/DataTable.tsx | 37 ++++ .../components/common/ExcelImportButton.tsx | 99 ++++++++++ .../src/pages/receive/CodeMappingModal.tsx | 89 +++++++++ ga-frontend/src/pages/receive/ErrorList.tsx | 48 +++++ .../src/pages/receive/FieldMappingEditor.tsx | 183 ++++++++++++++++++ ga-frontend/src/pages/receive/ProfileList.tsx | 69 +++++++ .../src/pages/receive/ProfileModal.tsx | 148 ++++++++++++++ .../src/pages/rule/ChargebackRuleModal.tsx | 117 +++++++++++ .../src/pages/rule/CommissionRateList.tsx | 76 ++++++++ .../src/pages/rule/CommissionRateModal.tsx | 102 ++++++++++ .../src/pages/rule/ExceptionTypeCodeModal.tsx | 100 ++++++++++ .../src/pages/rule/OverrideRuleModal.tsx | 106 ++++++++++ ga-frontend/src/pages/rule/PayoutRuleList.tsx | 74 +++++++ .../src/pages/rule/PayoutRuleModal.tsx | 105 ++++++++++ ga-frontend/src/router/dynamicRoutes.tsx | 70 +++++++ 18 files changed, 1519 insertions(+) create mode 100644 ga-frontend/src/api/receive.ts create mode 100644 ga-frontend/src/api/rule.ts create mode 100644 ga-frontend/src/components/common/DataTable.tsx create mode 100644 ga-frontend/src/components/common/ExcelImportButton.tsx create mode 100644 ga-frontend/src/pages/receive/CodeMappingModal.tsx create mode 100644 ga-frontend/src/pages/receive/ErrorList.tsx create mode 100644 ga-frontend/src/pages/receive/FieldMappingEditor.tsx create mode 100644 ga-frontend/src/pages/receive/ProfileList.tsx create mode 100644 ga-frontend/src/pages/receive/ProfileModal.tsx create mode 100644 ga-frontend/src/pages/rule/ChargebackRuleModal.tsx create mode 100644 ga-frontend/src/pages/rule/CommissionRateList.tsx create mode 100644 ga-frontend/src/pages/rule/CommissionRateModal.tsx create mode 100644 ga-frontend/src/pages/rule/ExceptionTypeCodeModal.tsx create mode 100644 ga-frontend/src/pages/rule/OverrideRuleModal.tsx create mode 100644 ga-frontend/src/pages/rule/PayoutRuleList.tsx create mode 100644 ga-frontend/src/pages/rule/PayoutRuleModal.tsx create mode 100644 ga-frontend/src/router/dynamicRoutes.tsx diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 22848e5..50368a5 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -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} : ; @@ -46,6 +53,16 @@ export default function App() { } /> } /> + {/* 수수료 규정 */} + } /> + } /> + + {/* 데이터수신 */} + } /> + } /> + } /> + } /> + {/* 시스템관리 */} } /> } /> diff --git a/ga-frontend/src/api/receive.ts b/ga-frontend/src/api/receive.ts new file mode 100644 index 0000000..4226a26 --- /dev/null +++ b/ga-frontend/src/api/receive.ts @@ -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' }, + }); + }, +}; diff --git a/ga-frontend/src/api/rule.ts b/ga-frontend/src/api/rule.ts new file mode 100644 index 0000000..d38bb31 --- /dev/null +++ b/ga-frontend/src/api/rule.ts @@ -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), +}; diff --git a/ga-frontend/src/components/common/DataTable.tsx b/ga-frontend/src/components/common/DataTable.tsx new file mode 100644 index 0000000..2e2155b --- /dev/null +++ b/ga-frontend/src/components/common/DataTable.tsx @@ -0,0 +1,37 @@ +import { Table } from 'antd'; +import type { TableProps } from 'antd'; + +export interface PageState { + pageNum: number; + pageSize: number; +} + +interface Props extends Omit, '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({ data, page, onPageChange, ...rest }: Props) { + return ( + + 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} + /> + ); +} diff --git a/ga-frontend/src/components/common/ExcelImportButton.tsx b/ga-frontend/src/components/common/ExcelImportButton.tsx new file mode 100644 index 0000000..1b43509 --- /dev/null +++ b/ga-frontend/src/components/common/ExcelImportButton.tsx @@ -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([]); + const [pendingFile, setPendingFile] = useState(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 ( + <> + + + + setPreviewOpen(false)} + onOk={confirmUpload} + okText="업로드 실행" + confirmLoading={busy} + width={900} + > + {previewRows.length === 0 ? ( +
표시할 데이터가 없습니다.
+ ) : ( + + + + {Object.keys(previewRows[0] ?? {}).map((k) => ( + + ))} + + + + {previewRows.map((row, i) => ( + + {Object.keys(previewRows[0] ?? {}).map((k) => ( + + ))} + + ))} + +
{k}
{String(row[k] ?? '')}
+ )} +
+ + ); +} diff --git a/ga-frontend/src/pages/receive/CodeMappingModal.tsx b/ga-frontend/src/pages/receive/CodeMappingModal.tsx new file mode 100644 index 0000000..f2b161f --- /dev/null +++ b/ga-frontend/src/pages/receive/CodeMappingModal.tsx @@ -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 ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setCell(idx, 'sourceField', e.target.value)} /> + )}, + { title: '컬럼 인덱스', width: 110, render: (_: any, r: MappingRow, idx: number) => ( + + setCell(idx, 'sourceColIndex', e.target.value === '' ? undefined : Number(e.target.value))} /> + )}, + { title: '타겟 필드명', width: 180, render: (_: any, r: MappingRow, idx: number) => ( + setCell(idx, 'targetField', e.target.value)} /> + )}, + { title: '데이터 타입', width: 120, render: (_: any, r: MappingRow, idx: number) => ( + setCell(idx, 'transformRule', v || undefined)} options={TRANSFORM_RULES} /> + )}, + { title: '기본값', width: 140, render: (_: any, r: MappingRow, idx: number) => ( + setCell(idx, 'defaultValue', e.target.value || undefined)} /> + )}, + { title: '필수', width: 70, render: (_: any, r: MappingRow, idx: number) => ( + + + + + + + + + } onClick={save}>저장 + {rows.some((r) => r.__dirty) && ● 변경됨} + + `${r.mappingId ?? 'new'}_${i}`} + dataSource={rows} + columns={columns as any} + pagination={false} + loading={isLoading} + size="small" + bordered + /> + + + ); +} diff --git a/ga-frontend/src/pages/receive/ProfileList.tsx b/ga-frontend/src/pages/receive/ProfileList.tsx new file mode 100644 index 0000000..ec57470 --- /dev/null +++ b/ga-frontend/src/pages/receive/ProfileList.tsx @@ -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({ pageNum: 1, pageSize: 20 }); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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 ( +
+ setParam({ ...param, ...v, pageNum: 1 })} + /> + + 신규 + + 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) => 0 ? 'blue' : 'default'}>{v} }, + { title: '활성', dataIndex: 'isActive', + render: (v: string) => v === 'Y' ? 사용 : 미사용 }, + { title: '', width: 220, render: (_: any, r: any) => ( + + openEdit(r)}>수정 + + + )}, + ]} + /> + setModalOpen(false)} + onSaved={refetch} + /> +
+ ); +} diff --git a/ga-frontend/src/pages/receive/ProfileModal.tsx b/ga-frontend/src/pages/receive/ProfileModal.tsx new file mode 100644 index 0000000..96017c6 --- /dev/null +++ b/ga-frontend/src/pages/receive/ProfileModal.tsx @@ -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 ( + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + {receiveMethod === 'API' && ( + + + + + + )} + + {(receiveMethod === 'FTP' || receiveMethod === 'SFTP') && ( + <> + + + + + + + + + + + + )} + + + + + + + + + + ); +} diff --git a/ga-frontend/src/pages/rule/ChargebackRuleModal.tsx b/ga-frontend/src/pages/rule/ChargebackRuleModal.tsx new file mode 100644 index 0000000..1fb2be7 --- /dev/null +++ b/ga-frontend/src/pages/rule/ChargebackRuleModal.tsx @@ -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 ( + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/ga-frontend/src/pages/rule/CommissionRateList.tsx b/ga-frontend/src/pages/rule/CommissionRateList.tsx new file mode 100644 index 0000000..b2a5393 --- /dev/null +++ b/ga-frontend/src/pages/rule/CommissionRateList.tsx @@ -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({ pageNum: 1, pageSize: 20 }); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(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 ( +
+ setParam({ ...param, ...v, pageNum: 1 })} + /> + + 신규 + + 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 ? {(v * 100).toFixed(2)}% : null, + }, + { title: '최소보험료', dataIndex: 'minPremium', align: 'right' as const, render: (v: number) => }, + { 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) => ( + + openEdit(r)}>수정 + handleDelete(r.rateId)}> + 삭제 + + + )}, + ]} + /> + setModalOpen(false)} + onSaved={refetch} + /> +
+ ); +} diff --git a/ga-frontend/src/pages/rule/CommissionRateModal.tsx b/ga-frontend/src/pages/rule/CommissionRateModal.tsx new file mode 100644 index 0000000..32c6628 --- /dev/null +++ b/ga-frontend/src/pages/rule/CommissionRateModal.tsx @@ -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 ( + +
+ +
+ + + + + + + + + + + + + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} /> + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/ga-frontend/src/pages/rule/ExceptionTypeCodeModal.tsx b/ga-frontend/src/pages/rule/ExceptionTypeCodeModal.tsx new file mode 100644 index 0000000..27fdff0 --- /dev/null +++ b/ga-frontend/src/pages/rule/ExceptionTypeCodeModal.tsx @@ -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 ( + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/ga-frontend/src/router/dynamicRoutes.tsx b/ga-frontend/src/router/dynamicRoutes.tsx new file mode 100644 index 0000000..4004bc6 --- /dev/null +++ b/ga-frontend/src/router/dynamicRoutes.tsx @@ -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>> = { + // 조직/인사 + '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 ( + }> + + + } + /> + ); + }) + .filter(Boolean); +}