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:
@@ -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%' }} />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user