feat: MappingEngine + Override 실제 로직 + 프론트 주요 화면

Backend:
- MappingEngine: company_field_mapping 기반 동적 변환 구현
  - raw_content(JSON) → 표준 원장 (TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP)
  - policy_no 로 contract 매칭 → contract_id/agent_id 자동
  - 실패 시 parse_error_log 자동 적재
- Recruit/Maintain Mapper.sumByAgentMonth 추가
- OrganizationMapper.selectAncestorIds (CTE 재귀 상향) 추가
- CalcOverrideStep 실제 동작:
  * lower agent 의 모집+유지 합계를 base 로 사용
  * 자기 조직 → 상위 조직 순으로 to_grade 직급 ACTIVE 설계사 탐색
  * (lowerOrgId, toGrade) 캐싱

Frontend (주요 화면):
- 공통 컴포넌트:
  * CodeSelect (공통코드 드롭다운, React Query 캐시)
  * CodeBadge (상태 색상 자동 매핑)
  * MoneyText (천단위 콤마, 음수 빨강, 부호 옵션)
  * PermissionButton (권한 없으면 미렌더)
  * SearchForm (조건 배열 → 자동 폼)
  * ExcelExportButton (blob 다운로드)
  * PageContainer
- 훅: useCommonCodes, usePermission (메뉴별 boolean)
- API 모듈: agent, contract, ledger, settle, code
- 페이지: AgentList, ContractList, RecruitLedger, SettleList
  * SettleList: 월 요약 카드 + 확정/보류 액션 + 권한 체크
- 라우터: /org/agents, /contracts, /ledger/recruit, /settle

V16 fix:
- PAYMENT 메뉴는 V12 에서 level1 directory 로 생성됨 → V16 에서 PAGE 로 UPDATE 후 권한 매트릭스 적용 (자식으로 또 만들면 unique 충돌)
This commit is contained in:
GA Pro
2026-05-09 22:38:10 +09:00
parent d4433a8e46
commit 98eb231950
28 changed files with 1165 additions and 39 deletions
+8 -1
View File
@@ -2,6 +2,10 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard';
import AgentList from '@/pages/org/AgentList';
import ContractList from '@/pages/product/ContractList';
import RecruitLedger from '@/pages/ledger/RecruitLedger';
import SettleList from '@/pages/settle/SettleList';
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('accessToken');
@@ -14,7 +18,10 @@ export default function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} />
{/* TODO: 추가 페이지는 동적 라우팅으로 확장 */}
<Route path="org/agents" element={<AgentList />} />
<Route path="contracts" element={<ContractList />} />
<Route path="ledger/recruit" element={<RecruitLedger />} />
<Route path="settle" element={<SettleList />} />
</Route>
</Routes>
);
+50
View File
@@ -0,0 +1,50 @@
import api, { PageResponse, unwrap } from './request';
export interface AgentResp {
agentId: number;
agentName: string;
licenseNo?: string;
phone?: string;
email?: string;
status: string;
statusName?: string;
orgId?: number;
orgName?: string;
gradeId?: number;
gradeName?: string;
joinDate?: string;
contractCount?: number;
totalCommission?: number;
}
export interface AgentSearchParam {
pageNum?: number;
pageSize?: number;
searchKeyword?: string;
status?: string;
orgId?: number;
gradeId?: number;
}
export interface AgentSaveReq {
agentName: string;
orgId: number;
gradeId: number;
residentNo?: string;
licenseNo?: string;
phone?: string;
email?: string;
bankCode?: string;
accountNo?: string;
joinDate?: string;
}
export const agentApi = {
list: (p: AgentSearchParam) =>
unwrap<PageResponse<AgentResp>>(api.get('/api/agents', { params: p })),
detail: (id: number) => unwrap<AgentResp>(api.get(`/api/agents/${id}`)),
create: (req: AgentSaveReq) => unwrap<number>(api.post('/api/agents', req)),
update: (id: number, req: AgentSaveReq) => unwrap<void>(api.put(`/api/agents/${id}`, req)),
changeStatus: (id: number, status: string) =>
unwrap<void>(api.put(`/api/agents/${id}/status`, { status })),
};
+14
View File
@@ -0,0 +1,14 @@
import api, { unwrap } from './request';
export interface CommonCode {
codeId: number;
groupCode: string;
code: string;
codeName: string;
sortOrder: number;
}
export const codeApi = {
byGroup: (groupCode: string) =>
unwrap<CommonCode[]>(api.get(`/api/common/codes/${groupCode}`)),
};
+40
View File
@@ -0,0 +1,40 @@
import api, { PageResponse, unwrap } from './request';
export interface ContractResp {
contractId: number;
agentId: number;
agentName: string;
orgName?: string;
productCode?: string;
productName?: string;
companyCode?: string;
companyName?: string;
insuranceType?: string;
policyNo: string;
contractorName?: string;
insuredName?: string;
premium?: number;
payCycle?: string;
payCycleName?: string;
status: string;
statusName?: string;
contractDate: string;
}
export interface ContractSearchParam {
pageNum?: number;
pageSize?: number;
searchKeyword?: string;
agentId?: number;
companyId?: number;
insuranceType?: string;
status?: string;
startDate?: string;
endDate?: string;
}
export const contractApi = {
list: (p: ContractSearchParam) =>
unwrap<PageResponse<ContractResp>>(api.get('/api/contracts', { params: p })),
detail: (id: number) => unwrap<ContractResp>(api.get(`/api/contracts/${id}`)),
};
+47
View File
@@ -0,0 +1,47 @@
import api, { PageResponse, unwrap } from './request';
export interface LedgerResp {
ledgerId: number;
agentId: number;
agentName: string;
orgName?: string;
policyNo?: string;
contractorName?: string;
contractDate?: string;
companyCode?: string;
companyName?: string;
productCode?: string;
productName?: string;
insuranceType?: string;
premium?: number;
companyRate?: number;
companyAmount?: number;
payoutRate?: number;
agentAmount?: number;
taxAmount?: number;
reconcileStatus?: string;
settleMonth: string;
status: string;
statusName?: string;
}
export interface LedgerSearchParam {
pageNum?: number;
pageSize?: number;
settleMonth?: string;
agentId?: number;
orgId?: number;
companyCode?: string;
insuranceType?: string;
policyNo?: string;
searchKeyword?: string;
}
export const ledgerApi = {
recruit: (p: LedgerSearchParam) =>
unwrap<PageResponse<LedgerResp>>(api.get('/api/ledger/recruit', { params: p })),
maintain: (p: LedgerSearchParam) =>
unwrap<PageResponse<LedgerResp>>(api.get('/api/ledger/maintain', { params: p })),
exception: (p: LedgerSearchParam) =>
unwrap<PageResponse<unknown>>(api.get('/api/ledger/exception', { params: p })),
};
+66
View File
@@ -0,0 +1,66 @@
import api, { PageResponse, unwrap } from './request';
export interface SettleResp {
settleId: number;
agentId: number;
agentName: string;
orgName?: string;
gradeName?: string;
settleMonth: string;
recruitTotal?: number;
maintainTotal?: number;
exceptionPlus?: number;
exceptionMinus?: number;
overrideTotal?: number;
chargebackTotal?: number;
grossAmount?: number;
taxAmount?: number;
netAmount?: number;
status: string;
statusName?: string;
holdReason?: string;
}
export interface SettleSearchParam {
pageNum?: number;
pageSize?: number;
settleMonth?: string;
agentId?: number;
orgId?: number;
status?: string;
}
export const settleApi = {
list: (p: SettleSearchParam) =>
unwrap<PageResponse<SettleResp>>(api.get('/api/settle', { params: p })),
summary: (settleMonth: string) =>
unwrap<Record<string, number>>(api.get('/api/settle/summary', { params: { settleMonth } })),
orgSummary: (settleMonth: string) =>
unwrap<Array<Record<string, unknown>>>(api.get('/api/settle/org-summary', { params: { settleMonth } })),
confirm: (settleId: number) =>
unwrap<void>(api.put(`/api/settle/${settleId}/confirm`)),
hold: (settleId: number, reason: string) =>
unwrap<void>(api.put(`/api/settle/${settleId}/hold`, { reason })),
release: (settleId: number) =>
unwrap<void>(api.put(`/api/settle/${settleId}/release`)),
};
export interface BatchJobLog {
jobId: number;
jobName: string;
settleMonth?: string;
status: string;
currentStep?: string;
progressPct?: number;
errorMessage?: string;
startedAt?: string;
finishedAt?: string;
durationSec?: number;
}
export const batchApi = {
run: (settleMonth: string) =>
unwrap<number>(api.post('/api/batch/settlement/run', { settleMonth })),
status: () =>
unwrap<BatchJobLog>(api.get('/api/batch/status')),
};
@@ -0,0 +1,23 @@
import { Tag } from 'antd';
import { useCommonCodes } from '@/hooks/useCommonCodes';
interface Props {
groupCode: string;
value?: string;
}
const COLOR_MAP: Record<string, string> = {
ACTIVE: 'green', CONFIRMED: 'green', APPROVED: 'green', PAID: 'green', MATCHED: 'green', DONE: 'green',
PENDING: 'blue', CALCULATED: 'blue', SENT: 'blue',
HOLD: 'red', SUSPEND: 'red', REJECTED: 'red', FAIL: 'red', LAPSE: 'red', DIFF: 'red', LOCKED: 'red',
DRAFT: 'default', NEW: 'default', LEAVE: 'default', RETIRED: 'default',
};
export default function CodeBadge({ groupCode, value }: Props) {
const { data = [] } = useCommonCodes(groupCode);
if (!value) return null;
const code = data.find((c) => c.code === value);
const label = code?.codeName ?? value;
const color = COLOR_MAP[value] ?? 'default';
return <Tag color={color}>{label}</Tag>;
}
@@ -0,0 +1,17 @@
import { Select, SelectProps } from 'antd';
import { useCommonCodes } from '@/hooks/useCommonCodes';
interface Props extends Omit<SelectProps, 'options'> {
groupCode: string;
includeAll?: boolean;
allLabel?: string;
}
export default function CodeSelect({ groupCode, includeAll, allLabel = '전체', ...rest }: Props) {
const { data = [], isLoading } = useCommonCodes(groupCode);
const options = [
...(includeAll ? [{ value: '', label: allLabel }] : []),
...data.map((c) => ({ value: c.code, label: c.codeName })),
];
return <Select loading={isLoading} options={options} allowClear {...rest} />;
}
@@ -0,0 +1,36 @@
import { Button, ButtonProps, message } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import api from '@/api/request';
interface Props extends Omit<ButtonProps, 'onClick'> {
url: string;
params?: Record<string, unknown>;
fileName: string;
}
/**
* 대용량 엑셀 다운로드. axios responseType=blob, content-disposition 무시하고 fileName 강제.
*/
export default function ExcelExportButton({ url, params, fileName, ...rest }: Props) {
const handleClick = async () => {
try {
const res = await api.get(url, { params, responseType: 'blob' });
const blob = new Blob([res.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${fileName}.xlsx`;
link.click();
URL.revokeObjectURL(link.href);
} catch {
message.error('엑셀 다운로드 실패');
}
};
return (
<Button icon={<DownloadOutlined />} onClick={handleClick} {...rest}>
{rest.children ?? '엑셀'}
</Button>
);
}
@@ -0,0 +1,20 @@
interface Props {
value?: number | string | null;
/** 양음 부호 표시 (true: +1,234 / -1,234) */
showSign?: boolean;
/** 소수점 자리 (default 0) */
fraction?: number;
}
export default function MoneyText({ value, showSign, fraction = 0 }: Props) {
if (value === null || value === undefined || value === '') return <span>-</span>;
const n = typeof value === 'string' ? Number(value) : value;
if (Number.isNaN(n)) return <span>-</span>;
const formatted = n.toLocaleString(undefined, {
minimumFractionDigits: fraction,
maximumFractionDigits: fraction,
});
const sign = n < 0 ? '' : showSign ? '+' : '';
const color = n < 0 ? '#cf1322' : n > 0 && showSign ? '#1677ff' : 'inherit';
return <span style={{ color, fontVariantNumeric: 'tabular-nums' }}>{sign}{formatted}</span>;
}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { Typography } from 'antd';
const { Title } = Typography;
interface Props {
title: string;
extra?: ReactNode;
children: ReactNode;
}
export default function PageContainer({ title, extra, children }: Props) {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>{title}</Title>
{extra}
</div>
{children}
</div>
);
}
@@ -0,0 +1,21 @@
import { Button, ButtonProps } from 'antd';
import { usePermission } from '@/hooks/usePermission';
interface Props extends ButtonProps {
menuCode: string;
permCode: 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'APPROVE' | 'EXPORT';
}
/** 권한 없으면 렌더링 자체를 안 함 */
export default function PermissionButton({ menuCode, permCode, children, ...rest }: Props) {
const perms = usePermission(menuCode);
const allowed =
(permCode === 'READ' && perms.canRead) ||
(permCode === 'CREATE' && perms.canCreate) ||
(permCode === 'UPDATE' && perms.canUpdate) ||
(permCode === 'DELETE' && perms.canDelete) ||
(permCode === 'APPROVE' && perms.canApprove) ||
(permCode === 'EXPORT' && perms.canExport);
if (!allowed) return null;
return <Button {...rest}>{children}</Button>;
}
@@ -0,0 +1,90 @@
import { Button, Col, DatePicker, Form, Input, Row, Space } from 'antd';
import dayjs from 'dayjs';
import CodeSelect from './CodeSelect';
const { RangePicker } = DatePicker;
export interface SearchCondition {
type: 'text' | 'code' | 'dateRange' | 'month';
name: string;
label: string;
groupCode?: string; // type=code 일 때
placeholder?: string;
span?: number; // 24그리드 기준 너비 (default 6)
}
interface Props {
conditions: SearchCondition[];
initialValues?: Record<string, unknown>;
onSearch: (values: Record<string, unknown>) => void;
onReset?: () => void;
}
/**
* 조건 배열만 넘기면 자동 렌더링되는 검색 폼.
* dateRange → startDate/endDate 로 분해, month → settleMonth(yyyyMM)
*/
export default function SearchForm({ conditions, initialValues, onSearch, onReset }: Props) {
const [form] = Form.useForm();
const handleSearch = (raw: Record<string, unknown>) => {
const out: Record<string, unknown> = {};
for (const c of conditions) {
const v = raw[c.name];
if (v === undefined || v === null || v === '') continue;
if (c.type === 'dateRange' && Array.isArray(v) && v.length === 2) {
out.startDate = (v[0] as dayjs.Dayjs).format('YYYY-MM-DD');
out.endDate = (v[1] as dayjs.Dayjs).format('YYYY-MM-DD');
} else if (c.type === 'month' && v) {
out[c.name] = (v as dayjs.Dayjs).format('YYYYMM');
} else {
out[c.name] = v;
}
}
onSearch(out);
};
const handleReset = () => {
form.resetFields();
onReset?.();
};
return (
<Form
form={form}
layout="vertical"
initialValues={initialValues}
onFinish={handleSearch}
style={{ background: '#fff', padding: 16, marginBottom: 16, borderRadius: 4 }}
>
<Row gutter={16}>
{conditions.map((c) => (
<Col span={c.span ?? 6} key={c.name}>
<Form.Item name={c.name} label={c.label}>
{renderField(c)}
</Form.Item>
</Col>
))}
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 24 }}>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Form>
);
}
function renderField(c: SearchCondition) {
switch (c.type) {
case 'text':
return <Input placeholder={c.placeholder} allowClear />;
case 'code':
return <CodeSelect groupCode={c.groupCode!} placeholder={c.placeholder} includeAll />;
case 'dateRange':
return <RangePicker style={{ width: '100%' }} />;
case 'month':
return <DatePicker picker="month" style={{ width: '100%' }} />;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { codeApi } from '@/api/code';
/**
* 공통코드 그룹별 목록을 React Query 로 캐싱하여 반환.
* staleTime: 1시간 (서버 변경 후 페이지 새로고침이면 충분)
*/
export function useCommonCodes(groupCode: string) {
return useQuery({
queryKey: ['commonCode', groupCode],
queryFn: () => codeApi.byGroup(groupCode),
staleTime: 60 * 60 * 1000,
enabled: !!groupCode,
});
}
+45
View File
@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { menuApi, MenuResp } from '@/api/menu';
import { useMemo } from 'react';
export interface MenuPermissions {
canRead: boolean;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
canApprove: boolean;
canExport: boolean;
}
/**
* menuCode 별 사용자 권한 보유 여부.
* 메뉴 트리에서 해당 menuCode 의 permissions 배열을 평탄화하여 boolean 으로 반환.
*/
export function usePermission(menuCode: string): MenuPermissions {
const { data: tree = [] } = useQuery({
queryKey: ['menu', 'my'],
queryFn: menuApi.myMenus,
staleTime: 5 * 60 * 1000,
});
return useMemo(() => {
const perms = findPermissions(tree, menuCode);
return {
canRead: perms.includes('READ'),
canCreate: perms.includes('CREATE'),
canUpdate: perms.includes('UPDATE'),
canDelete: perms.includes('DELETE'),
canApprove: perms.includes('APPROVE'),
canExport: perms.includes('EXPORT'),
};
}, [tree, menuCode]);
}
function findPermissions(nodes: MenuResp[], code: string): string[] {
for (const n of nodes) {
if (n.menuCode === code) return n.permissions ?? [];
const found = findPermissions(n.children ?? [], code);
if (found.length) return found;
}
return [];
}
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import MoneyText from '@/components/common/MoneyText';
import CodeBadge from '@/components/common/CodeBadge';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import { ledgerApi, LedgerResp, LedgerSearchParam } from '@/api/ledger';
export default function RecruitLedger() {
const [param, setParam] = useState<LedgerSearchParam>({ pageNum: 1, pageSize: 50 });
const { data, isLoading } = useQuery({
queryKey: ['ledger', 'recruit', param],
queryFn: () => ledgerApi.recruit(param),
});
return (
<PageContainer
title="모집수수료 원장"
extra={
<ExcelExportButton url="/api/ledger/recruit/export" params={param} fileName="모집수수료원장" />
}
>
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'text', name: 'policyNo', label: '증권번호' },
{ type: 'code', name: 'insuranceType', label: '보험종류', groupCode: 'INSURANCE_TYPE' },
{ type: 'code', name: 'reconcileStatus', label: '대사상태', groupCode: 'RECON_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 50 })}
/>
<Table<LedgerResp>
rowKey="ledgerId"
loading={isLoading}
dataSource={data?.list}
size="small"
scroll={{ x: 1500 }}
pagination={{
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' },
{ title: '증권번호', dataIndex: 'policyNo', width: 130, fixed: 'left' },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '보험사', dataIndex: 'companyName', width: 120 },
{ title: '상품', dataIndex: 'productName', width: 200 },
{ title: '보험종류', dataIndex: 'insuranceType', width: 100,
render: (v) => <CodeBadge groupCode="INSURANCE_TYPE" value={v} /> },
{ title: '보험료', dataIndex: 'premium', width: 110, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '회사율', dataIndex: 'companyRate', width: 80, align: 'right',
render: (v) => <MoneyText value={v} fraction={2} /> },
{ title: '회사수수료', dataIndex: 'companyAmount', width: 120, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '지급율', dataIndex: 'payoutRate', width: 80, align: 'right',
render: (v) => <MoneyText value={v} fraction={2} /> },
{ title: '설계사수수료', dataIndex: 'agentAmount', width: 120, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right',
render: (v) => <MoneyText value={v} /> },
{ title: '대사상태', dataIndex: 'reconcileStatus', width: 100,
render: (v) => <CodeBadge groupCode="RECON_STATUS" value={v} /> },
]}
/>
</PageContainer>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import { agentApi, AgentResp, AgentSearchParam } from '@/api/agent';
export default function AgentList() {
const navigate = useNavigate();
const [param, setParam] = useState<AgentSearchParam>({ pageNum: 1, pageSize: 20 });
const { data, isLoading } = useQuery({
queryKey: ['agent', 'list', param],
queryFn: () => agentApi.list(param),
});
return (
<PageContainer
title="설계사 관리"
extra={
<div>
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('/org/agents/new')} style={{ marginRight: 8 }}>
</PermissionButton>
<ExcelExportButton url="/api/agents/export" params={param} fileName="설계사목록" />
</div>
}
>
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 20 })}
/>
<Table<AgentResp>
rowKey="agentId"
loading={isLoading}
dataSource={data?.list}
pagination={{
current: param.pageNum,
pageSize: param.pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (page, size) => setParam({ ...param, pageNum: page, pageSize: size }),
}}
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
columns={[
{ title: '설계사명', dataIndex: 'agentName', width: 120 },
{ title: '사번', dataIndex: 'licenseNo', width: 120 },
{ title: '소속', dataIndex: 'orgName', width: 200 },
{ title: '직급', dataIndex: 'gradeName', width: 100 },
{ title: '전화번호', dataIndex: 'phone', width: 140 },
{
title: '상태', dataIndex: 'status', width: 100,
render: (v) => <CodeBadge groupCode="AGENT_STATUS" value={v} />,
},
{
title: '계약수', dataIndex: 'contractCount', align: 'right', width: 90,
render: (v) => <MoneyText value={v} />,
},
{
title: '누적수수료', dataIndex: 'totalCommission', align: 'right', width: 130,
render: (v) => <MoneyText value={v} />,
},
{ title: '입사일', dataIndex: 'joinDate', width: 110 },
]}
/>
</PageContainer>
);
}
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import { contractApi, ContractResp, ContractSearchParam } from '@/api/contract';
export default function ContractList() {
const [param, setParam] = useState<ContractSearchParam>({ pageNum: 1, pageSize: 20 });
const { data, isLoading } = useQuery({
queryKey: ['contract', 'list', param],
queryFn: () => contractApi.list(param),
});
return (
<PageContainer title="계약 관리">
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '계약자/증권번호' },
{ type: 'code', name: 'insuranceType', label: '보험종류', groupCode: 'INSURANCE_TYPE' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'CONTRACT_STATUS' },
{ type: 'dateRange', name: 'dateRange', label: '계약일' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 20 })}
/>
<Table<ContractResp>
rowKey="contractId"
loading={isLoading}
dataSource={data?.list}
pagination={{
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
}}
columns={[
{ title: '증권번호', dataIndex: 'policyNo', width: 140 },
{ title: '계약자', dataIndex: 'contractorName', width: 100 },
{ title: '설계사', dataIndex: 'agentName', width: 100 },
{ title: '보험사', dataIndex: 'companyName', width: 120 },
{ title: '상품', dataIndex: 'productName', width: 220 },
{
title: '보험종류', dataIndex: 'insuranceType', width: 100,
render: (v) => <CodeBadge groupCode="INSURANCE_TYPE" value={v} />,
},
{
title: '보험료', dataIndex: 'premium', width: 120, align: 'right',
render: (v) => <MoneyText value={v} />,
},
{
title: '납입주기', dataIndex: 'payCycle', width: 90,
render: (v) => <CodeBadge groupCode="PAY_CYCLE" value={v} />,
},
{
title: '상태', dataIndex: 'status', width: 90,
render: (v) => <CodeBadge groupCode="CONTRACT_STATUS" value={v} />,
},
{ title: '계약일', dataIndex: 'contractDate', width: 110 },
]}
/>
</PageContainer>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Card, Col, Row, Statistic, Table, message, Modal, Input, Space } from 'antd';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import CodeBadge from '@/components/common/CodeBadge';
import MoneyText from '@/components/common/MoneyText';
import PermissionButton from '@/components/common/PermissionButton';
import { settleApi, SettleResp, SettleSearchParam } from '@/api/settle';
export default function SettleList() {
const qc = useQueryClient();
const [param, setParam] = useState<SettleSearchParam>({ pageNum: 1, pageSize: 30 });
const { data, isLoading } = useQuery({
queryKey: ['settle', 'list', param],
queryFn: () => settleApi.list(param),
});
const { data: summary } = useQuery({
queryKey: ['settle', 'summary', param.settleMonth],
queryFn: () => settleApi.summary(param.settleMonth!),
enabled: !!param.settleMonth,
});
const refresh = () => qc.invalidateQueries({ queryKey: ['settle'] });
const onConfirm = (id: number) =>
Modal.confirm({
title: '정산 확정', content: '확정 후에는 변경할 수 없습니다. 진행하시겠습니까?',
onOk: async () => { await settleApi.confirm(id); message.success('확정 완료'); refresh(); },
});
const onHold = (id: number) => {
let reason = '';
Modal.confirm({
title: '정산 보류',
content: <Input placeholder="보류 사유" onChange={(e) => { reason = e.target.value; }} />,
onOk: async () => { await settleApi.hold(id, reason); message.success('보류 처리'); refresh(); },
});
};
return (
<PageContainer title="정산 결과">
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'SETTLE_STATUS' },
]}
onSearch={(v) => setParam({ ...param, ...v, pageNum: 1 })}
onReset={() => setParam({ pageNum: 1, pageSize: 30 })}
/>
{summary && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}><Card><Statistic title="대상 설계사" value={Number(summary.agentCount ?? 0)} suffix="명" /></Card></Col>
<Col span={6}><Card><Statistic title="총 지급액(net)" value={Number(summary.netAmount ?? 0)} /></Card></Col>
<Col span={6}><Card><Statistic title="총 세액" value={Number(summary.taxAmount ?? 0)} valueStyle={{ color: '#cf1322' }} /></Card></Col>
<Col span={6}><Card><Statistic title="확정 건수" value={Number(summary.confirmedCount ?? 0)} suffix="건" /></Card></Col>
</Row>
)}
<Table<SettleResp>
rowKey="settleId"
loading={isLoading}
dataSource={data?.list}
size="small"
scroll={{ x: 1600 }}
pagination={{
current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true,
onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }),
}}
columns={[
{ title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' },
{ title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left' },
{ title: '소속', dataIndex: 'orgName', width: 160 },
{ title: '직급', dataIndex: 'gradeName', width: 80 },
{ title: '모집', dataIndex: 'recruitTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '유지', dataIndex: 'maintainTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '예외(+)', dataIndex: 'exceptionPlus', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '예외(-)', dataIndex: 'exceptionMinus', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '오버라이드', dataIndex: 'overrideTotal', width: 110, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '환수', dataIndex: 'chargebackTotal', width: 100, align: 'right',
render: (v) => <MoneyText value={v ? -Math.abs(Number(v)) : 0} /> },
{ title: '총액', dataIndex: 'grossAmount', width: 120, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', render: (v) => <MoneyText value={v} /> },
{ title: '실지급', dataIndex: 'netAmount', width: 130, align: 'right',
render: (v) => <strong><MoneyText value={v} /></strong> },
{ title: '상태', dataIndex: 'status', width: 100, fixed: 'right',
render: (v) => <CodeBadge groupCode="SETTLE_STATUS" value={v} /> },
{
title: '액션', dataIndex: 'settleId', width: 200, fixed: 'right',
render: (id, r) => (
<Space>
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small"
type="primary" onClick={() => onConfirm(id)}
disabled={r.status === 'CONFIRMED' || r.status === 'PAID'}>
</PermissionButton>
<PermissionButton menuCode="SETTLE_LIST" permCode="APPROVE" size="small"
danger onClick={() => onHold(id)}
disabled={r.status === 'PAID'}>
</PermissionButton>
</Space>
),
},
]}
/>
</PageContainer>
);
}