feat: incar 갭 반영 수수료/운영 14개 도메인 풀스택 + 배치통합 + 신입교육자료
incar CMS(116메뉴) 벤치마킹 갭분석으로 ga-pro에 없던 14개 도메인을 풀스택 추가. P7 수수료 계산 7종 (V88~V96): - 수입수수료+수지차, 소개·이관수수료, 정착지원금, 인정실적+환산율, 시상, 생보운영지원수수료, 지점장수당 - 각 rule/ledger + 공식(보험료×요율, 수입−지급 등) Service 내장 P8 운영/정산 7종 (V97~V105): - 단계마감, 본사정산, 등급평가, 공동계약, 보증보험, 적립금, 수수료시뮬레이터 - close/reopen/apply/simulate/upsert 등 액션 + 손익·잔액·등급 산정 로직 배치 통합 (V106): - settle_master.other_commission_total 컬럼 추가(DEFAULT 0, 백필) - 설계사 지급성 9종 원장 aggregateByAgent → AggregateStep gross 합산 - 빈 원장 0 → 기존 정산결과·무결성 불변(비파괴적) 테스트: P7/P8 Service 공식 단위테스트 21건(ga-api:test 57건 전체 통과) 프론트: 화면 14개 + React.lazy 코드 스플리팅(단일 4MB → 130+ 청크) 문서: DOMAIN_GAP_P7/P8.md, 신입교육_보험과수수료_완전기초.md, DOMAIN_KNOWLEDGE/HANDOFF 갱신 검증: 전체 ./gradlew build(test 포함) SUCCESSFUL, Flyway V88~V106 success, GET+액션POST smoke 5xx 0건, 9개 집계쿼리 SQL 유효성 확인, 공식 런타임 실측. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Space, Tag, Tabs, message } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import dayjs from 'dayjs';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { operationApi, GradeEvalRuleRow, GradeEvalRow } from '@/api/operation';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
const RULE_COLS: ColDef<GradeEvalRuleRow>[] = [
|
||||
{ field: 'ruleId', headerName: 'ID', width: 80, type: 'numericColumn' },
|
||||
{ field: 'gradeName', headerName: '등급', width: 120 },
|
||||
{ field: 'minScore', headerName: '최소점수', width: 120, type: 'numericColumn' },
|
||||
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
|
||||
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
|
||||
];
|
||||
|
||||
const EVAL_COLS: ColDef<GradeEvalRow>[] = [
|
||||
{ field: 'evalId', headerName: 'ID', width: 80, type: 'numericColumn' },
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'evalPeriod', headerName: '평가기준월', width: 120 },
|
||||
{ field: 'performanceScore', headerName: '실적점수', width: 110, type: 'numericColumn' },
|
||||
{ field: 'prevGradeName', headerName: '기존등급', width: 110 },
|
||||
{ field: 'evaluatedGradeName', headerName: '산정등급', width: 110 },
|
||||
{ field: 'status', headerName: '상태', width: 110,
|
||||
cellRenderer: (p: { value: string }) => (
|
||||
<Tag color={p.value === 'APPLIED' ? 'green' : 'blue'}>{p.value === 'APPLIED' ? '반영완료' : '평가완료'}</Tag>
|
||||
) },
|
||||
];
|
||||
|
||||
export default function GradeEvaluation() {
|
||||
const qc = useQueryClient();
|
||||
const [evalParams, setEvalParams] = useState<{ agentName?: string; evalPeriod?: string }>({});
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState(false);
|
||||
const [evalModalOpen, setEvalModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<GradeEvalRuleRow | null>(null);
|
||||
const [ruleForm] = Form.useForm();
|
||||
const [evalForm] = Form.useForm();
|
||||
|
||||
const { data: rulesData, isLoading: rulesLoading, isError } = useQuery({
|
||||
queryKey: ['grade-eval', 'rules'],
|
||||
queryFn: () => operationApi.gradeEvalRules(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data: evalsData, isLoading: evalsLoading } = useQuery({
|
||||
queryKey: ['grade-eval', 'list', evalParams],
|
||||
queryFn: () => operationApi.gradeEvalList(evalParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rules = rulesData ?? [];
|
||||
const evals = evalsData?.list ?? [];
|
||||
|
||||
const openRuleCreate = () => { setEditingRule(null); ruleForm.resetFields(); setRuleModalOpen(true); };
|
||||
const openRuleEdit = (row: GradeEvalRuleRow) => { setEditingRule(row); ruleForm.setFieldsValue(row); setRuleModalOpen(true); };
|
||||
|
||||
const handleRuleSave = async () => {
|
||||
const values = await ruleForm.validateFields();
|
||||
try {
|
||||
if (editingRule) {
|
||||
await operationApi.gradeEvalRuleUpdate(editingRule.ruleId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await operationApi.gradeEvalRuleCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['grade-eval', 'rules'] });
|
||||
setRuleModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleRuleDelete = (id: number) => Modal.confirm({
|
||||
title: '룰 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await operationApi.gradeEvalRuleDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['grade-eval', 'rules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEvalCreate = async () => {
|
||||
const values = await evalForm.validateFields();
|
||||
try {
|
||||
await operationApi.gradeEvalCreate(values);
|
||||
message.success('평가 등록 완료');
|
||||
qc.invalidateQueries({ queryKey: ['grade-eval', 'list'] });
|
||||
setEvalModalOpen(false);
|
||||
evalForm.resetFields();
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleApply = (evalId: number) => Modal.confirm({
|
||||
title: '등급 반영', content: '산정 등급을 설계사에게 반영하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await operationApi.gradeEvalApply(evalId);
|
||||
message.success('반영 완료');
|
||||
qc.invalidateQueries({ queryKey: ['grade-eval', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleEvalDelete = (evalId: number) => Modal.confirm({
|
||||
title: '평가 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await operationApi.gradeEvalDelete(evalId);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['grade-eval', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const ruleActionCol: ColDef<GradeEvalRuleRow> = {
|
||||
headerName: '액션', width: 170, pinned: 'right',
|
||||
cellRenderer: (p: { data: GradeEvalRuleRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="GRADE_EVAL" permCode="UPDATE" size="small" onClick={() => openRuleEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="GRADE_EVAL" permCode="DELETE" size="small" danger onClick={() => handleRuleDelete(p.data.ruleId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
};
|
||||
|
||||
const evalActionCol: ColDef<GradeEvalRow> = {
|
||||
headerName: '액션', width: 190, pinned: 'right',
|
||||
cellRenderer: (p: { data: GradeEvalRow }) => (
|
||||
<Space>
|
||||
{p.data.status === 'EVALUATED' && (
|
||||
<PermissionButton menuCode="GRADE_EVAL" permCode="UPDATE" size="small" type="primary" onClick={() => handleApply(p.data.evalId)}>반영</PermissionButton>
|
||||
)}
|
||||
<PermissionButton menuCode="GRADE_EVAL" permCode="DELETE" size="small" danger onClick={() => handleEvalDelete(p.data.evalId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="등급평가" description="설계사 실적 점수 기반 등급 산정">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/grade-evaluations 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="rules"
|
||||
items={[
|
||||
{
|
||||
key: 'rules',
|
||||
label: '평가 룰',
|
||||
children: (
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="GRADE_EVAL" permCode="CREATE" type="primary" onClick={openRuleCreate}>
|
||||
+ 룰 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<GradeEvalRuleRow>
|
||||
rows={rules}
|
||||
columns={[...RULE_COLS, ruleActionCol]}
|
||||
loading={rulesLoading}
|
||||
height={520}
|
||||
rowKey="ruleId"
|
||||
/>
|
||||
</ProCard>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'evals',
|
||||
label: '평가 목록',
|
||||
children: (
|
||||
<>
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'month', name: 'evalPeriod', label: '평가기준월', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setEvalParams({ agentName: v.agentName as string, evalPeriod: v.evalPeriod as string })}
|
||||
onReset={() => setEvalParams({})}
|
||||
/>
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="GRADE_EVAL" permCode="CREATE" type="primary" onClick={() => { evalForm.resetFields(); setEvalModalOpen(true); }}>
|
||||
+ 평가 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<GradeEvalRow>
|
||||
rows={evals}
|
||||
columns={[...EVAL_COLS, evalActionCol]}
|
||||
loading={evalsLoading}
|
||||
height={520}
|
||||
rowKey="evalId"
|
||||
/>
|
||||
</ProCard>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 룰 등록/수정 모달 */}
|
||||
<Modal title={editingRule ? '룰 수정' : '룰 등록'} open={ruleModalOpen} onOk={handleRuleSave} onCancel={() => setRuleModalOpen(false)} okText="저장" cancelText="취소">
|
||||
<Form form={ruleForm} layout="vertical">
|
||||
<Form.Item name="gradeId" label="등급ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="minScore" label="최소점수" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveFrom" label="적용시작(YYYY-MM-DD)" rules={[{ required: true }]}>
|
||||
<Input placeholder="2025-01-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveTo" label="적용종료(YYYY-MM-DD)">
|
||||
<Input placeholder="2025-12-31" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 평가 등록 모달 */}
|
||||
<Modal title="평가 등록" open={evalModalOpen} onOk={handleEvalCreate} onCancel={() => setEvalModalOpen(false)} okText="저장" cancelText="취소">
|
||||
<Form form={evalForm} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evalPeriod" label="평가기준월(YYYYMM)" rules={[{ required: true }]}>
|
||||
<Input placeholder={dayjs().format('YYYYMM')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="performanceScore" label="실적점수" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} step={0.01} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Space, Tag, message } from 'antd';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ColDef } from '@ag-grid-community/core';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import SearchForm from '@/components/common/SearchForm';
|
||||
import DataGrid from '@/components/common/DataGrid';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import { operationApi, GuaranteeInsuranceRow } from '@/api/operation';
|
||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
|
||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { ACTIVE: 'green', EXPIRED: 'default', CANCELLED: 'red' };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: '유효', EXPIRED: '만료', CANCELLED: '해지' };
|
||||
|
||||
const COLS: ColDef<GuaranteeInsuranceRow>[] = [
|
||||
{ field: 'agentName', headerName: '설계사', width: 130 },
|
||||
{ field: 'policyNo', headerName: '증권번호', width: 160 },
|
||||
{ field: 'carrierName', headerName: '보증보험사', width: 130 },
|
||||
{ field: 'guaranteeAmount', headerName: '보증금액', width: 150, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||
{ field: 'startDate', headerName: '시작일', width: 110 },
|
||||
{ field: 'endDate', headerName: '종료일', width: 110 },
|
||||
{ field: 'status', headerName: '상태', width: 100,
|
||||
cellRenderer: (p: { value: string }) => (
|
||||
<Tag color={STATUS_COLOR[p.value] ?? 'default'}>{STATUS_LABEL[p.value] ?? p.value}</Tag>
|
||||
) },
|
||||
];
|
||||
|
||||
export default function GuaranteeInsurance() {
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useState<{ agentName?: string; status?: string }>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<GuaranteeInsuranceRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['guarantee-insurance', 'list', searchParams],
|
||||
queryFn: () => operationApi.guaranteeInsuranceList(searchParams),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const rows = data?.list ?? [];
|
||||
|
||||
const openCreate = () => { setEditingRow(null); form.resetFields(); setModalOpen(true); };
|
||||
const openEdit = (row: GuaranteeInsuranceRow) => { setEditingRow(row); form.setFieldsValue(row); setModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
if (editingRow) {
|
||||
await operationApi.guaranteeInsuranceUpdate(editingRow.guaranteeId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await operationApi.guaranteeInsuranceCreate(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ['guarantee-insurance', 'list'] });
|
||||
setModalOpen(false);
|
||||
} catch { /* request.ts 처리 */ }
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => Modal.confirm({
|
||||
title: '보증보험 삭제', content: '삭제하시겠습니까?',
|
||||
onOk: async () => {
|
||||
await operationApi.guaranteeInsuranceDelete(id);
|
||||
message.success('삭제 완료');
|
||||
qc.invalidateQueries({ queryKey: ['guarantee-insurance', 'list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const actionCol: ColDef<GuaranteeInsuranceRow> = {
|
||||
headerName: '액션', width: 160, pinned: 'right',
|
||||
cellRenderer: (p: { data: GuaranteeInsuranceRow }) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="GUARANTEE_INS" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}>수정</PermissionButton>
|
||||
<PermissionButton menuCode="GUARANTEE_INS" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.guaranteeId)}>삭제</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer title="보증보험" description="설계사 신원보증보험 가입현황 관리">
|
||||
{isError && (
|
||||
<Alert type="warning" showIcon message="API 미응답" description="/api/guarantee-insurances 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
|
||||
<SearchForm
|
||||
conditions={[
|
||||
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
|
||||
{ type: 'text', name: 'status', label: '상태', span: 6 },
|
||||
]}
|
||||
onSearch={(v) => setSearchParams({ agentName: v.agentName as string, status: v.status as string })}
|
||||
onReset={() => setSearchParams({})}
|
||||
/>
|
||||
|
||||
<ProCard style={cardStyle}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PermissionButton menuCode="GUARANTEE_INS" permCode="CREATE" type="primary" onClick={openCreate}>
|
||||
+ 보증보험 등록
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<DataGrid<GuaranteeInsuranceRow>
|
||||
rows={rows}
|
||||
columns={[...COLS, actionCol]}
|
||||
loading={isLoading}
|
||||
height={520}
|
||||
rowKey="guaranteeId"
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
<Modal
|
||||
title={editingRow ? '보증보험 수정' : '보증보험 등록'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="agentId" label="설계사ID" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="policyNo" label="증권번호" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="carrierId" label="보증보험사ID">
|
||||
<InputNumber style={{ width: '100%' }} min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="guaranteeAmount" label="보증금액" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="보증시작(YYYY-MM-DD)" rules={[{ required: true }]}>
|
||||
<Input placeholder="2025-01-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="endDate" label="보증종료(YYYY-MM-DD)" rules={[{ required: true }]}>
|
||||
<Input placeholder="2026-01-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Input placeholder="ACTIVE / EXPIRED / CANCELLED" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user