feat(frontend): 누락된 메뉴 14개 페이지 일괄 구현

신규 페이지 (모두 ProTable / ProCard 패턴):
- org/OrgTree: 조직 트리 + 선택 시 우측에 소속 설계사 ProTable
- product/CompanyList: 보험사 목록
- product/ProductList: 상품 목록 (보험종 필터)
- rule/CommissionRateList: 수수료율 (보험사 통보)
- rule/PayoutRuleList: 지급율 (직급 × 보험종 × 회차)
- rule/OverrideRuleList: 오버라이드 규정 (직급 페어)
- rule/ChargebackRuleList: 환수 규정 (실효 월수 구간)
- rule/ExceptionCodeList: 예외 코드 마스터
- ledger/MaintainLedger: 유지수수료 원장
- receive/ReceiveData: 보험사 raw 수신 데이터
- receive/ReceiveMapping: 보험사 프로파일 + 필드 매핑 규칙
- system/MenuManage: 메뉴 트리 표시 (Tree)
- system/SystemConfig: 키-값 설정 (그룹별 필터)
- system/SystemLog: 로그인/API/변경 이력 3 탭

신규 API 모듈:
- api/org.ts (조직 트리)
- api/company.ts, api/product.ts
- api/rule.ts (5종 통합)
- api/receive.ts (profile/field/raw/error)
- api/config.ts, api/log.ts

App.tsx: 14 라우트 추가 (org/tree, companies, products, rules/*,
receive/*, ledger/maintain, system/menus, system/config, system/logs)

검증:
- tsc -b 통과
- 12개 백엔드 엔드포인트 200 OK 확인
- dev 서버 HMR 자동 반영

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-10 22:36:51 +09:00
parent 020939e563
commit f176b9702c
22 changed files with 1212 additions and 4 deletions
@@ -0,0 +1,79 @@
import { ProTable, 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 ExcelExportButton from '@/components/common/ExcelExportButton';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { ledgerApi, LedgerResp, LedgerSearchParam } from '@/api/ledger';
export default function MaintainLedger() {
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const { data: reconCodes = [] } = useCommonCodes('RECON_STATUS');
const columns: ProColumns<LedgerResp>[] = [
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left',
valueType: 'dateMonth', fieldProps: { format: 'YYYYMM' } },
{ title: '증권번호', dataIndex: 'policyNo', width: 130, fixed: 'left',
fieldProps: { placeholder: '증권번호' } },
{
title: '보험종류', dataIndex: 'insuranceType', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(insuranceCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="INSURANCE_TYPE" value={r.insuranceType} />,
},
{
title: '대사상태', dataIndex: 'reconcileStatus', width: 100, valueType: 'select',
valueEnum: Object.fromEntries(reconCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="RECON_STATUS" value={r.reconcileStatus} />,
},
{ title: '설계사', dataIndex: 'agentName', width: 100, search: false },
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
{ title: '보험사', dataIndex: 'companyName', width: 120, search: false },
{ title: '상품', dataIndex: 'productName', width: 200, search: false },
{
title: '보험료', dataIndex: 'premium', width: 110, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.premium} />,
},
{
title: '회사율', dataIndex: 'companyRate', width: 80, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.companyRate} fraction={2} />,
},
{
title: '회사수수료', dataIndex: 'companyAmount', width: 120, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.companyAmount} />,
},
{
title: '지급율', dataIndex: 'payoutRate', width: 80, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.payoutRate} fraction={2} />,
},
{
title: '설계사수수료', dataIndex: 'agentAmount', width: 120, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.agentAmount} />,
},
{
title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.taxAmount} />,
},
];
return (
<PageContainer title="유지수수료 원장" description="2~5년차 유지 계약에 대한 유지수수료">
<ProTable<LedgerResp, LedgerSearchParam>
rowKey="ledgerId"
columns={columns}
scroll={{ x: 1500 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await ledgerApi.maintain({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
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={() => [
<ExcelExportButton key="export" url="/api/ledger/maintain/export" fileName="유지수수료원장" />,
]}
dateFormatter="string"
/>
</PageContainer>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useMemo, useState } from 'react';
import { Tree, Card, Empty, Tag, Spin } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { ProCard, ProTable, type ProColumns } from '@ant-design/pro-components';
import { IconBuilding } from '@tabler/icons-react';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import { orgApi, OrgNode } from '@/api/org';
import { agentApi, AgentResp } from '@/api/agent';
interface AntdTreeNode { key: string; title: any; children?: AntdTreeNode[]; orgId: number; orgType: string; }
function toTree(nodes: OrgNode[] = []): AntdTreeNode[] {
return nodes.map((n) => ({
key: String(n.orgId),
orgId: n.orgId,
orgType: n.orgType,
title: (
<span>
<Tag color={n.orgType === 'HQ' ? 'blue' : n.orgType === 'BR' ? 'cyan' : 'green'} style={{ marginRight: 6 }}>
{n.orgType}
</Tag>
{n.orgName}
</span>
),
children: n.children ? toTree(n.children) : undefined,
}));
}
export default function OrgTree() {
const [selectedOrgId, setSelectedOrgId] = useState<number | undefined>();
const { data: tree = [], isLoading: treeLoading } = useQuery({
queryKey: ['orgs', 'tree'],
queryFn: orgApi.tree,
});
const treeData = useMemo(() => toTree(tree), [tree]);
const columns: ProColumns<AgentResp>[] = [
{ title: '설계사명', dataIndex: 'agentName', width: 110 },
{ title: '직급', dataIndex: 'gradeName', width: 90 },
{
title: '상태', dataIndex: 'status', width: 90,
render: (_, r) => <CodeBadge groupCode="AGENT_STATUS" value={r.status} />,
},
{ title: '사번', dataIndex: 'licenseNo', width: 110 },
{ title: '전화번호', dataIndex: 'phone', width: 130 },
{ title: '계약수', dataIndex: 'contractCount', width: 80, align: 'right',
render: (_, r) => <MoneyText value={r.contractCount} /> },
{ title: '누적수수료', dataIndex: 'totalCommission', align: 'right',
render: (_, r) => <MoneyText value={r.totalCommission} /> },
];
return (
<PageContainer title="조직 트리" description="본부 / 지점 / 팀 트리 + 소속 설계사">
<ProCard split="vertical" ghost>
<ProCard colSpan="280px" bordered title={<><IconBuilding size={16} style={{ verticalAlign: -2 }} /> </>}>
{treeLoading ? <Spin /> : (
treeData.length ? (
<Tree
treeData={treeData as any}
defaultExpandAll
onSelect={(keys, e) => {
const node = e.node as unknown as AntdTreeNode;
setSelectedOrgId(node.orgId);
}}
/>
) : <Empty description="조직 없음" />
)}
</ProCard>
<ProCard ghost>
{selectedOrgId ? (
<Card bordered={false} title={<span style={{ fontWeight: 600 }}> </span>}>
<ProTable<AgentResp>
rowKey="agentId"
columns={columns}
search={false}
options={false}
request={async (params) => {
const res = await agentApi.list({
orgId: selectedOrgId,
pageNum: params.current ?? 1,
pageSize: params.pageSize ?? 50,
});
return { data: res.list, total: res.total, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
/>
</Card>
) : (
<Empty description="좌측에서 조직을 선택하세요" />
)}
</ProCard>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,42 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { Tag } from 'antd';
import PageContainer from '@/components/common/PageContainer';
import { companyApi, CompanyResp, CompanySearchParam } from '@/api/company';
export default function CompanyList() {
const columns: ProColumns<CompanyResp>[] = [
{ title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '회사명 / 사업자번호' } },
{ title: '회사코드', dataIndex: 'companyCode', width: 100, search: false },
{ title: '회사명', dataIndex: 'companyName', width: 180,
render: (_, r) => <strong>{r.companyName}</strong> },
{ title: '구분', dataIndex: 'companyType', width: 100, search: false },
{ title: '사업자번호', dataIndex: 'bizNo', width: 130, search: false },
{ title: '담당자', dataIndex: 'contactName', width: 100, search: false },
{ title: '연락처', dataIndex: 'contactPhone', width: 130, search: false },
{ title: '이메일', dataIndex: 'contactEmail', width: 200, search: false },
{
title: '활성', dataIndex: 'isActive', width: 80,
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? '활성' : '비활성'}</Tag>,
},
];
return (
<PageContainer title="보험사 관리" description="제휴 보험사 정보 / 데이터 수신 프로파일">
<ProTable<CompanyResp, CompanySearchParam>
rowKey="companyId"
columns={columns}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await companyApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
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 }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,52 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { Tag } from 'antd';
import PageContainer from '@/components/common/PageContainer';
import CodeBadge from '@/components/common/CodeBadge';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { productApi, ProductResp, ProductSearchParam } from '@/api/product';
export default function ProductList() {
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const columns: ProColumns<ProductResp>[] = [
{ title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
fieldProps: { placeholder: '상품명 / 코드' } },
{
title: '보험종류', dataIndex: 'insuranceType', width: 110, valueType: 'select',
valueEnum: Object.fromEntries(insuranceCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="INSURANCE_TYPE" value={r.insuranceType} />,
},
{ title: '상품코드', dataIndex: 'productCode', width: 120, search: false },
{ title: '상품명', dataIndex: 'productName', width: 240, search: false,
render: (_, r) => <strong>{r.productName}</strong> },
{ title: '보험사', dataIndex: 'companyName', width: 120, search: false },
{ title: '상품군', dataIndex: 'productGroup', width: 100, search: false },
{ title: '납입주기', dataIndex: 'payPeriodType', width: 100, search: false },
{ title: '판매개시', dataIndex: 'launchDate', width: 110, search: false },
{ title: '판매종료', dataIndex: 'endDate', width: 110, search: false },
{
title: '활성', dataIndex: 'isActive', width: 80, search: false,
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? '활성' : '비활성'}</Tag>,
},
];
return (
<PageContainer title="상품 관리" description="보험사별 보험 상품 / 보험종류 / 상품군">
<ProTable<ProductResp, ProductSearchParam>
rowKey="productId"
columns={columns}
scroll={{ x: 1300 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await productApi.list({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
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 }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,55 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { Tag } from 'antd';
import PageContainer from '@/components/common/PageContainer';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { receiveApi, RawCommissionResp, ParseErrorResp } from '@/api/receive';
const STATUS_COLOR = { PENDING: 'processing', OK: 'success', ERROR: 'error' } as const;
export default function ReceiveData() {
const { data: companies = [] } = useCommonCodes('COMPANY_CODE');
const rawColumns: ProColumns<RawCommissionResp>[] = [
{
title: '보험사', dataIndex: 'companyCode', width: 110, valueType: 'select',
valueEnum: Object.fromEntries(companies.map((c) => [c.code, { text: c.codeName }])),
},
{ title: '정산월', dataIndex: 'settleMonth', width: 90, valueType: 'dateMonth',
fieldProps: { format: 'YYYYMM' } },
{
title: '파싱상태', dataIndex: 'parseStatus', width: 110, valueType: 'select',
valueEnum: { PENDING: { text: '대기' }, OK: { text: '성공' }, ERROR: { text: '실패' } },
render: (_, r) => <Tag color={STATUS_COLOR[r.parseStatus as 'PENDING' | 'OK' | 'ERROR'] ?? 'default'}>
{r.parseStatus}</Tag>,
},
{ title: '데이터 유형', dataIndex: 'dataType', width: 110, search: false },
{ title: '배치 ID', dataIndex: 'batchId', width: 140, search: false },
{ title: '파일명', dataIndex: 'fileName', width: 200, search: false, ellipsis: true },
{ title: '행 번호', dataIndex: 'rowNumber', width: 80, align: 'right', search: false },
{ title: '연결 원장', dataIndex: 'mappedLedgerId', width: 100, align: 'right', search: false },
{ title: '수신일시', dataIndex: 'receivedAt', width: 160, search: false },
{ title: '파싱일시', dataIndex: 'parsedAt', width: 160, search: false },
];
return (
<PageContainer title="수신 데이터" description="보험사 수수료 raw 데이터 / 파싱 상태">
<ProTable<RawCommissionResp>
rowKey="rawId"
columns={rawColumns}
scroll={{ x: 1300 }}
request={async (params) => {
const list = await receiveApi.raw({
companyCode: (params as any).companyCode,
settleMonth: (params as any).settleMonth,
parseStatus: (params as any).parseStatus,
});
return { data: list, total: list.length, success: true };
}}
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 }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Empty, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { ProCard, ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import { receiveApi, CompanyProfileResp, FieldMappingResp } from '@/api/receive';
export default function ReceiveMapping() {
const [selected, setSelected] = useState<string | undefined>();
const { data: profiles = [] } = useQuery({
queryKey: ['receive', 'profiles'],
queryFn: receiveApi.profiles,
});
const profileColumns: ProColumns<CompanyProfileResp>[] = [
{ title: '보험사', dataIndex: 'companyCode', width: 90,
render: (_, r) => <strong>{r.companyCode}</strong> },
{ title: '데이터 형식', dataIndex: 'dataFormat', width: 110 },
{ title: '수신방법', dataIndex: 'receiveMethod', width: 110 },
{
title: '활성', dataIndex: 'isActive', width: 70, align: 'center',
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? 'Y' : 'N'}</Tag>,
},
];
const fieldColumns: ProColumns<FieldMappingResp>[] = [
{ title: '#', dataIndex: 'sortOrder', width: 50, align: 'center' },
{ title: 'Source 필드', dataIndex: 'sourceField', width: 130 },
{ title: 'Source 컬럼', dataIndex: 'sourceColIndex', width: 90, align: 'right' },
{ title: 'Target 테이블', dataIndex: 'targetTable', width: 120 },
{ title: 'Target 필드', dataIndex: 'targetField', width: 130 },
{ title: 'Type', dataIndex: 'dataType', width: 100 },
{ title: '변환규칙', dataIndex: 'transformRule', width: 110,
render: (_, r) => r.transformRule ? <Tag color="blue">{r.transformRule}</Tag> : '-' },
{ title: '기본값', dataIndex: 'defaultValue', width: 110 },
{ title: '필수', dataIndex: 'isRequired', width: 70, align: 'center',
render: (_, r) => r.isRequired === 'Y' ? <Tag color="error"></Tag> : '' },
{ title: '버전', dataIndex: 'version', width: 60, align: 'center' },
];
return (
<PageContainer title="매핑 관리" description="보험사 raw 데이터 → 표준 원장 매핑 규칙">
<ProCard split="vertical" ghost>
<ProCard colSpan="380px" bordered title={<span style={{ fontWeight: 600 }}> </span>}>
<ProTable<CompanyProfileResp>
rowKey="companyCode"
columns={profileColumns}
dataSource={profiles}
search={false}
options={false}
pagination={false}
size="small"
onRow={(r) => ({
onClick: () => setSelected(r.companyCode),
style: {
cursor: 'pointer',
background: selected === r.companyCode ? '#E6F1FB' : undefined,
},
})}
/>
</ProCard>
<ProCard
ghost
title={selected ? <span style={{ fontWeight: 600 }}> {selected}</span>
: <span> </span>}
>
{selected ? (
<ProTable<FieldMappingResp>
rowKey="mappingId"
columns={fieldColumns}
search={false}
options={false}
pagination={false}
size="small"
request={async () => {
const list = await receiveApi.fields(selected);
return { data: list, total: list.length, success: true };
}}
/>
) : (
<Empty description="좌측에서 보험사를 선택하세요" />
)}
</ProCard>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,53 @@
import { Tag } from 'antd';
import { ProTable, 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 { ruleApi, ChargebackRuleResp } from '@/api/rule';
export default function ChargebackRuleList() {
const columns: ProColumns<ChargebackRuleResp>[] = [
{ title: '보험사', dataIndex: 'companyCode', width: 100, search: false,
render: (_, r) => <strong>{r.companyCode}</strong> },
{
title: '보험종', dataIndex: 'insuranceType', width: 110, search: false,
render: (_, r) => <CodeBadge groupCode="INSURANCE_TYPE" value={r.insuranceType} />,
},
{ title: '실효 시작월', dataIndex: 'lapseMonthFrom', width: 100, align: 'center', search: false },
{ title: '실효 종료월', dataIndex: 'lapseMonthTo', width: 100, align: 'center', search: false },
{
title: '환수율(%)', dataIndex: 'cbRate', width: 110, align: 'right', search: false,
render: (_, r) => <strong><MoneyText value={r.cbRate} fraction={4} /></strong>,
},
{
title: '오버라이드 포함', dataIndex: 'includeOverride', width: 130, align: 'center', search: false,
render: (_, r) => <Tag color={r.includeOverride === 'Y' ? 'success' : 'default'}>
{r.includeOverride === 'Y' ? '포함' : '제외'}</Tag>,
},
{
title: '분할 가능', dataIndex: 'installmentAllowed', width: 100, align: 'center', search: false,
render: (_, r) => <Tag color={r.installmentAllowed === 'Y' ? 'success' : 'default'}>
{r.installmentAllowed === 'Y' ? '허용' : '불가'}</Tag>,
},
{ title: '최대 분할', dataIndex: 'maxInstallments', width: 90, align: 'right', search: false },
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
];
return (
<PageContainer title="환수 규정" description="실효 계약 환수율 / 분할환수 정책">
<ProTable<ChargebackRuleResp>
rowKey="cbRuleId"
columns={columns}
search={false}
request={async () => {
const list = await ruleApi.chargebackRules();
return { data: list, total: list.length, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,41 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
import { ruleApi, CommissionRateResp } from '@/api/rule';
export default function CommissionRateList() {
const columns: ProColumns<CommissionRateResp>[] = [
{ title: '상품ID', dataIndex: 'productId', width: 90 },
{ title: '상품명', dataIndex: 'productName', width: 200, search: false },
{ title: '회차', dataIndex: 'commissionYear', width: 70, align: 'center', search: false },
{
title: '수수료율(%)', dataIndex: 'ratePct', width: 110, align: 'right', search: false,
render: (_, r) => <strong><MoneyText value={r.ratePct} fraction={4} /></strong>,
},
{ title: '납입조건', dataIndex: 'payMethodCond', width: 110, search: false },
{
title: '최소보험료', dataIndex: 'minPremium', width: 130, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.minPremium} />,
},
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
{ title: '버전', dataIndex: 'version', width: 60, align: 'center', search: false },
];
return (
<PageContainer title="수수료율 (보험사 → 시스템)" description="상품·회차별 보험사 통보 수수료율">
<ProTable<CommissionRateResp>
rowKey="rateId"
columns={columns}
search={false}
request={async () => {
const list = await ruleApi.commissionRates();
return { data: list, total: list.length, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,55 @@
import { Tag } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import { ruleApi, ExceptionCodeResp } from '@/api/rule';
const DIRECTION_COLOR = { PLUS: 'success', MINUS: 'error' } as const;
export default function ExceptionCodeList() {
const columns: ProColumns<ExceptionCodeResp>[] = [
{ title: '예외코드', dataIndex: 'exceptionCode', width: 120, search: false,
render: (_, r) => <strong>{r.exceptionCode}</strong> },
{ title: '예외명', dataIndex: 'exceptionName', width: 160, search: false },
{ title: '카테고리', dataIndex: 'category', width: 110, search: false },
{
title: '방향', dataIndex: 'direction', width: 80, align: 'center', search: false,
render: (_, r) => <Tag color={DIRECTION_COLOR[r.direction as 'PLUS' | 'MINUS'] ?? 'default'}>
{r.direction === 'PLUS' ? '가산 (+)' : '차감 (-)'}</Tag>,
},
{
title: '자동계산', dataIndex: 'autoCalcYn', width: 90, align: 'center', search: false,
render: (_, r) => r.autoCalcYn === 'Y' ? <Tag color="processing"></Tag> : <Tag></Tag>,
},
{
title: '승인 필요', dataIndex: 'approvalRequired', width: 90, align: 'center', search: false,
render: (_, r) => r.approvalRequired === 'Y' ? <Tag color="warning"></Tag> : <Tag></Tag>,
},
{
title: '과세', dataIndex: 'taxIncluded', width: 80, align: 'center', search: false,
render: (_, r) => r.taxIncluded === 'Y' ? <Tag color="success"></Tag> : <Tag></Tag>,
},
{ title: '설명', dataIndex: 'description', ellipsis: true, search: false },
{
title: '활성', dataIndex: 'isActive', width: 70, align: 'center', search: false,
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>
{r.isActive === 'Y' ? 'Y' : 'N'}</Tag>,
},
];
return (
<PageContainer title="예외 코드" description="가산/차감/승인/세금 정책 마스터 (예외금액 원장에서 사용)">
<ProTable<ExceptionCodeResp>
rowKey="exceptionCode"
columns={columns}
search={false}
request={async () => {
const list = await ruleApi.exceptionCodes();
return { data: list, total: list.length, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,42 @@
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import MoneyText from '@/components/common/MoneyText';
import { ruleApi, OverrideRuleResp } from '@/api/rule';
export default function OverrideRuleList() {
const columns: ProColumns<OverrideRuleResp>[] = [
{ title: '하위 직급', dataIndex: 'fromGradeName', width: 110, search: false,
render: (_, r) => <strong>{r.fromGradeName ?? `#${r.fromGrade}`}</strong> },
{ title: '상위 직급', dataIndex: 'toGradeName', width: 110, search: false,
render: (_, r) => r.toGradeName ?? `#${r.toGrade}` },
{
title: '오버라이드율(%)', dataIndex: 'overridePct', width: 130, align: 'right', search: false,
render: (_, r) => <strong><MoneyText value={r.overridePct} fraction={4} /></strong>,
},
{ title: '계산방식', dataIndex: 'calcType', width: 110, search: false },
{ title: '보험종조건', dataIndex: 'insuranceTypeCond', width: 120, search: false },
{
title: '월 상한', dataIndex: 'capAmount', width: 130, align: 'right', search: false,
render: (_, r) => <MoneyText value={r.capAmount} />,
},
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
];
return (
<PageContainer title="오버라이드 규정" description="조직 트리 상향 분배율 (하위 → 상위 직급)">
<ProTable<OverrideRuleResp>
rowKey="overrideId"
columns={columns}
search={false}
request={async () => {
const list = await ruleApi.overrideRules();
return { data: list, total: list.length, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,48 @@
import { ProTable, 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 { ruleApi, PayoutRuleResp } from '@/api/rule';
export default function PayoutRuleList() {
const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE');
const columns: ProColumns<PayoutRuleResp>[] = [
{ title: '직급', dataIndex: 'gradeName', width: 100, search: false,
render: (_, r) => <strong>{r.gradeName}</strong> },
{
title: '보험종류', dataIndex: 'insuranceType', width: 110,
valueType: 'select',
valueEnum: Object.fromEntries(insuranceCodes.map((c) => [c.code, { text: c.codeName }])),
render: (_, r) => <CodeBadge groupCode="INSURANCE_TYPE" value={r.insuranceType} />,
},
{ title: '회차', dataIndex: 'commissionYear', width: 70, align: 'center', search: false },
{
title: '지급율(%)', dataIndex: 'payoutPct', width: 100, align: 'right', search: false,
render: (_, r) => <strong><MoneyText value={r.payoutPct} fraction={4} /></strong>,
},
{ title: '납입조건', dataIndex: 'payMethodCond', width: 110, search: false },
{ title: '실적등급', dataIndex: 'performanceGrade', width: 100, search: false },
{ title: '적용시작', dataIndex: 'effectiveFrom', width: 110, search: false },
{ title: '적용종료', dataIndex: 'effectiveTo', width: 110, search: false },
{ title: '버전', dataIndex: 'version', width: 60, align: 'center', search: false },
];
return (
<PageContainer title="지급율 (시스템 → 설계사)" description="직급·보험종·회차별 설계사 지급율">
<ProTable<PayoutRuleResp>
rowKey="ruleId"
columns={columns}
request={async (params) => {
const list = await ruleApi.payoutRules({ insuranceType: (params as any).insuranceType });
return { data: list, total: list.length, success: true };
}}
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 }}
dateFormatter="string"
/>
</PageContainer>
);
}
@@ -0,0 +1,55 @@
import { Tag, Tree } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { ProCard } from '@ant-design/pro-components';
import { IconCategory } from '@tabler/icons-react';
import PageContainer from '@/components/common/PageContainer';
import { menuApi, MenuResp } from '@/api/menu';
interface TreeNode { key: string; title: any; children?: TreeNode[]; }
function toTree(menus: MenuResp[]): TreeNode[] {
return menus.map((m) => ({
key: String(m.menuId),
title: (
<span>
<Tag color={m.menuType === 'DIRECTORY' ? 'blue' : 'default'} style={{ marginRight: 6 }}>
{m.menuType}
</Tag>
<strong>{m.menuName}</strong>
<span style={{ color: '#999', marginLeft: 8, fontSize: 12 }}>
{m.menuPath ?? m.menuCode}
</span>
</span>
),
children: m.children?.length ? toTree(m.children) : undefined,
}));
}
export default function MenuManage() {
const { data: tree = [] } = useQuery({
queryKey: ['menu', 'tree'],
queryFn: menuApi.tree,
});
return (
<PageContainer title="메뉴 관리" description="시스템 메뉴 트리 / 표시 / 권한 매핑">
<ProCard
title={<span><IconCategory size={16} style={{ verticalAlign: -3 }} /> </span>}
headerBordered
bordered={false}
extra={<Tag color="processing"> {countMenus(tree)}</Tag>}
>
<Tree
treeData={toTree(tree) as any}
defaultExpandAll
showLine={{ showLeafIcon: false }}
style={{ minHeight: 400 }}
/>
</ProCard>
</PageContainer>
);
}
function countMenus(menus: MenuResp[]): number {
return menus.reduce((sum, m) => sum + 1 + (m.children ? countMenus(m.children) : 0), 0);
}
@@ -0,0 +1,54 @@
import { Tag } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import { configApi, SystemConfigResp } from '@/api/config';
const GROUP_COLOR: Record<string, string> = {
GENERAL: 'blue', BATCH: 'cyan', SECURITY: 'red', MAIL: 'green', TAX: 'orange',
};
export default function SystemConfig() {
const columns: ProColumns<SystemConfigResp>[] = [
{
title: '그룹', dataIndex: 'configGroup', width: 110, valueType: 'select',
valueEnum: {
GENERAL: { text: '일반' }, BATCH: { text: '배치' }, SECURITY: { text: '보안' },
MAIL: { text: '메일' }, TAX: { text: '세금' },
},
render: (_, r) => <Tag color={GROUP_COLOR[r.configGroup ?? ''] ?? 'default'}>
{r.configGroup}</Tag>,
},
{ title: '설정 키', dataIndex: 'configKey', width: 220, search: false,
render: (_, r) => <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>
{r.configKey}</code> },
{
title: '설정 값', dataIndex: 'configValue', search: false,
render: (_, r) => r.isEncrypted === 'Y' ? <Tag color="red"> (***)</Tag> : <span>{r.configValue}</span>,
},
{ title: '타입', dataIndex: 'valueType', width: 90, search: false },
{ title: '설명', dataIndex: 'configDesc', ellipsis: true, search: false },
{
title: '수정 가능', dataIndex: 'isEditable', width: 100, align: 'center', search: false,
render: (_, r) => r.isEditable === 'Y' ? <Tag color="success"></Tag> : <Tag></Tag>,
},
{ title: '수정일시', dataIndex: 'updatedAt', width: 160, search: false },
];
return (
<PageContainer title="시스템 설정" description="배치 / 보안 / 세금 / 메일 등 키-값 설정">
<ProTable<SystemConfigResp>
rowKey="configKey"
columns={columns}
scroll={{ x: 1200 }}
request={async (params) => {
const list = await configApi.list((params as any).configGroup);
return { data: list, total: list.length, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
</PageContainer>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { Tabs, Tag } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import { logApi, ApiAccessLogResp, DataChangeLogResp, LoginLogResp } from '@/api/log';
export default function SystemLog() {
const [tab, setTab] = useState('login');
return (
<PageContainer title="시스템 로그" description="로그인 / API 호출 / 데이터 변경 이력">
<Tabs
activeKey={tab}
onChange={setTab}
items={[
{ key: 'login', label: '로그인 이력', children: <LoginTable /> },
{ key: 'api', label: 'API 호출', children: <ApiTable /> },
{ key: 'change', label: '변경 이력', children: <ChangeTable /> },
]}
/>
</PageContainer>
);
}
function LoginTable() {
const columns: ProColumns<LoginLogResp>[] = [
{ title: '로그인일시', dataIndex: 'loginAt', width: 170, valueType: 'dateTime' },
{
title: '구분', dataIndex: 'loginType', width: 90, valueType: 'select',
valueEnum: { LOGIN: { text: '로그인' }, LOGOUT: { text: '로그아웃' }, FAIL: { text: '실패' } },
render: (_, r) => {
const c = r.loginType === 'LOGIN' ? 'success' : r.loginType === 'FAIL' ? 'error' : 'default';
return <Tag color={c}>{r.loginType}</Tag>;
},
},
{ title: 'ID', dataIndex: 'username', width: 120 },
{ title: 'IP', dataIndex: 'ipAddress', width: 130, search: false },
{ title: 'User-Agent', dataIndex: 'userAgent', ellipsis: true, search: false },
{ title: '실패사유', dataIndex: 'failReason', width: 200, search: false },
];
return (
<ProTable<LoginLogResp>
rowKey="logId" columns={columns} dateFormatter="string" scroll={{ x: 1100 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await logApi.login({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
/>
);
}
function ApiTable() {
const columns: ProColumns<ApiAccessLogResp>[] = [
{ title: '시각', dataIndex: 'createdAt', width: 170, valueType: 'dateTime' },
{ title: 'Method', dataIndex: 'requestMethod', width: 80, search: false,
render: (_, r) => <Tag color="blue">{r.requestMethod}</Tag> },
{ title: 'URL', dataIndex: 'requestUrl', ellipsis: true, search: false },
{ title: 'Status', dataIndex: 'responseStatus', width: 80, align: 'right', search: false,
render: (_, r) => {
const s = r.responseStatus ?? 0;
const c = s >= 500 ? 'error' : s >= 400 ? 'warning' : s >= 300 ? 'processing' : 'success';
return <Tag color={c}>{s}</Tag>;
} },
{ title: 'Time(ms)', dataIndex: 'responseTime', width: 90, align: 'right', search: false },
{ title: 'User', dataIndex: 'username', width: 110 },
{ title: 'IP', dataIndex: 'ipAddress', width: 130, search: false },
];
return (
<ProTable<ApiAccessLogResp>
rowKey="logId" columns={columns} dateFormatter="string" scroll={{ x: 1200 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await logApi.api({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
/>
);
}
function ChangeTable() {
const columns: ProColumns<DataChangeLogResp>[] = [
{ title: '시각', dataIndex: 'createdAt', width: 170, valueType: 'dateTime' },
{ title: '테이블', dataIndex: 'tableName', width: 150 },
{ title: 'Action', dataIndex: 'actionType', width: 90, search: false,
render: (_, r) => {
const c = r.actionType === 'CREATE' ? 'success' : r.actionType === 'DELETE' ? 'error' : 'processing';
return <Tag color={c}>{r.actionType}</Tag>;
} },
{ title: 'Record ID', dataIndex: 'recordId', width: 100, search: false },
{ title: '메뉴', dataIndex: 'menuCode', width: 130, search: false },
{ title: '사용자', dataIndex: 'username', width: 110 },
{ title: '사유', dataIndex: 'changeReason', ellipsis: true, search: false },
];
return (
<ProTable<DataChangeLogResp>
rowKey="logId" columns={columns} dateFormatter="string" scroll={{ x: 1100 }}
request={async (params) => {
const { current, pageSize, ...rest } = params as any;
const res = await logApi.change({ ...rest, pageNum: current, pageSize });
return { data: res.list, total: res.total, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t.toLocaleString()}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
/>
);
}