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:
GA Pro
2026-05-31 15:45:10 +09:00
parent 70620463b4
commit bfc724669b
231 changed files with 13325 additions and 189 deletions
+230 -172
View File
@@ -1,101 +1,139 @@
import React, { Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Spin } from 'antd';
import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard';
// ── P5: 연말명세서 / 분개장 ──────────────────────────
import AgentAnnualIncomeList from '@/pages/income/AgentAnnualIncomeList';
import AccountingJournalList from '@/pages/accounting/AccountingJournalList';
// ── 페이지 레이지 로딩 ──────────────────────────────────
const Dashboard = React.lazy(() => import('@/pages/Dashboard'));
// ── P5-W2: 원천세 / 부가세 신고 ──────────────────────
import WithholdingTaxReportList from '@/pages/tax/WithholdingTaxReportList';
import VatReportList from '@/pages/tax/VatReportList';
// P5: 연말명세서 / 분개장
const AgentAnnualIncomeList = React.lazy(() => import('@/pages/income/AgentAnnualIncomeList'));
const AccountingJournalList = React.lazy(() => import('@/pages/accounting/AccountingJournalList'));
// ── P5-W3: 결산 / 연말명세서 / 이상치 / 예측 ──────────
import AccountingCloseList from '@/pages/accounting/AccountingCloseList';
import YearEndStatementList from '@/pages/income/YearEndStatementList';
import RetentionAnomalyList from '@/pages/kpi/RetentionAnomalyList';
import ForecastKpiList from '@/pages/kpi/ForecastKpiList';
// P5-W2: 원천세 / 부가세 신고
const WithholdingTaxReportList = React.lazy(() => import('@/pages/tax/WithholdingTaxReportList'));
const VatReportList = React.lazy(() => import('@/pages/tax/VatReportList'));
// ── P4: 수수료 종류 ──────────────────────────────────
import CommissionMaster from '@/pages/commission/CommissionMaster';
import CollectionCommission from '@/pages/commission/CollectionCommission';
import RenewalCommission from '@/pages/commission/RenewalCommission';
import ReinstatementCommission from '@/pages/commission/ReinstatementCommission';
import PersistencyBonus from '@/pages/commission/PersistencyBonus';
import OverrideLedger from '@/pages/commission/OverrideLedger';
// P5-W3: 결산 / 연말명세서 / 이상치 / 예측
const AccountingCloseList = React.lazy(() => import('@/pages/accounting/AccountingCloseList'));
const YearEndStatementList = React.lazy(() => import('@/pages/income/YearEndStatementList'));
const RetentionAnomalyList = React.lazy(() => import('@/pages/kpi/RetentionAnomalyList'));
const ForecastKpiList = React.lazy(() => import('@/pages/kpi/ForecastKpiList'));
// ── P4: 설계사 라이프사이클 ───────────────────────────
import AgentContracts from '@/pages/agent/AgentContracts';
import AgentLicenses from '@/pages/agent/AgentLicenses';
import AgentTrainings from '@/pages/agent/AgentTrainings';
// P8: 운영 7도메인
const StepClose = React.lazy(() => import('@/pages/settle/StepClose'));
const HqSettle = React.lazy(() => import('@/pages/settle/HqSettle'));
const GradeEvaluation = React.lazy(() => import('@/pages/org/GradeEvaluation'));
const JointContract = React.lazy(() => import('@/pages/contracts/JointContract'));
const GuaranteeInsurance = React.lazy(() => import('@/pages/org/GuaranteeInsurance'));
const ReserveFund = React.lazy(() => import('@/pages/commission/ReserveFund'));
const CommissionSimulator = React.lazy(() => import('@/pages/commission/CommissionSimulator'));
// ── P4: 계약 운영 ────────────────────────────────────
import ContractEndorsements from '@/pages/contracts/ContractEndorsements';
import ContractSuccessions from '@/pages/contracts/ContractSuccessions';
import Complaints from '@/pages/contracts/Complaints';
// P7: 수수료 계산 7도메인
const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission'));
const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission'));
const SettlingSupport = React.lazy(() => import('@/pages/commission/SettlingSupport'));
const RecognizedPerformance = React.lazy(() => import('@/pages/commission/RecognizedPerformance'));
const Award = React.lazy(() => import('@/pages/commission/Award'));
const LifeSupport = React.lazy(() => import('@/pages/commission/LifeSupport'));
const BranchAllowance = React.lazy(() => import('@/pages/commission/BranchAllowance'));
// ── P4: 시스템 ──────────────────────────────────────
import Approvals from '@/pages/system/Approvals';
import Notices from '@/pages/system/Notices';
// P4: 수수료 종류
const CommissionMaster = React.lazy(() => import('@/pages/commission/CommissionMaster'));
const CollectionCommission = React.lazy(() => import('@/pages/commission/CollectionCommission'));
const RenewalCommission = React.lazy(() => import('@/pages/commission/RenewalCommission'));
const ReinstatementCommission = React.lazy(() => import('@/pages/commission/ReinstatementCommission'));
const PersistencyBonus = React.lazy(() => import('@/pages/commission/PersistencyBonus'));
const OverrideLedger = React.lazy(() => import('@/pages/commission/OverrideLedger'));
// ── P4: 출금 ────────────────────────────────────────
import WithdrawRequest from '@/pages/payments/WithdrawRequest';
// P4: 설계사 라이프사이클
const AgentContracts = React.lazy(() => import('@/pages/agent/AgentContracts'));
const AgentLicenses = React.lazy(() => import('@/pages/agent/AgentLicenses'));
const AgentTrainings = React.lazy(() => import('@/pages/agent/AgentTrainings'));
import OrgTree from '@/pages/org/OrgTree';
import AgentList from '@/pages/org/AgentList';
import AgentDetail from '@/pages/org/AgentDetail';
import AgentForm from '@/pages/org/AgentForm';
// P4: 계약 운영
const ContractEndorsements = React.lazy(() => import('@/pages/contracts/ContractEndorsements'));
const ContractSuccessions = React.lazy(() => import('@/pages/contracts/ContractSuccessions'));
const Complaints = React.lazy(() => import('@/pages/contracts/Complaints'));
import CompanyList from '@/pages/product/CompanyList';
import ProductList from '@/pages/product/ProductList';
import ContractList from '@/pages/product/ContractList';
// P4: 시스템
const Approvals = React.lazy(() => import('@/pages/system/Approvals'));
const Notices = React.lazy(() => import('@/pages/system/Notices'));
import CommissionRateList from '@/pages/rule/CommissionRateList';
import PayoutRuleList from '@/pages/rule/PayoutRuleList';
import OverrideRuleList from '@/pages/rule/OverrideRuleList';
import ChargebackRuleList from '@/pages/rule/ChargebackRuleList';
import ExceptionCodeList from '@/pages/rule/ExceptionCodeList';
import RegulatoryLimitList from '@/pages/rule/RegulatoryLimitList';
import InstallmentRatioList from '@/pages/rule/InstallmentRatioList';
import ChargebackGradeList from '@/pages/rule/ChargebackGradeList';
// P4: 출금
const WithdrawRequest = React.lazy(() => import('@/pages/payments/WithdrawRequest'));
import ReceiveData from '@/pages/receive/ReceiveData';
import ReceiveMapping from '@/pages/receive/ReceiveMapping';
// 조직 / 인사
const OrgTree = React.lazy(() => import('@/pages/org/OrgTree'));
const AgentList = React.lazy(() => import('@/pages/org/AgentList'));
const AgentDetail = React.lazy(() => import('@/pages/org/AgentDetail'));
const AgentForm = React.lazy(() => import('@/pages/org/AgentForm'));
import RecruitLedger from '@/pages/ledger/RecruitLedger';
import MaintainLedger from '@/pages/ledger/MaintainLedger';
import ExceptionLedger from '@/pages/ledger/ExceptionLedger';
// 상품 / 계약
const CompanyList = React.lazy(() => import('@/pages/product/CompanyList'));
const ProductList = React.lazy(() => import('@/pages/product/ProductList'));
const ContractList = React.lazy(() => import('@/pages/product/ContractList'));
import SettleList from '@/pages/settle/SettleList';
import BatchRun from '@/pages/settle/BatchRun';
import PaymentList from '@/pages/settle/PaymentList';
import DeductionList from '@/pages/settle/DeductionList';
import PeriodClose from '@/pages/settle/PeriodClose';
import SettleCorrection from '@/pages/settle/SettleCorrection';
import TaxInvoiceList from '@/pages/settle/TaxInvoiceList';
import CommissionDispute from '@/pages/settle/CommissionDispute';
import IncentiveProgram from '@/pages/settle/IncentiveProgram';
// 수수료 규정
const CommissionRateList = React.lazy(() => import('@/pages/rule/CommissionRateList'));
const PayoutRuleList = React.lazy(() => import('@/pages/rule/PayoutRuleList'));
const OverrideRuleList = React.lazy(() => import('@/pages/rule/OverrideRuleList'));
const ChargebackRuleList = React.lazy(() => import('@/pages/rule/ChargebackRuleList'));
const ExceptionCodeList = React.lazy(() => import('@/pages/rule/ExceptionCodeList'));
const RegulatoryLimitList = React.lazy(() => import('@/pages/rule/RegulatoryLimitList'));
const InstallmentRatioList = React.lazy(() => import('@/pages/rule/InstallmentRatioList'));
const ChargebackGradeList = React.lazy(() => import('@/pages/rule/ChargebackGradeList'));
import PerformanceReport from '@/pages/report/PerformanceReport';
import OrgReport from '@/pages/report/OrgReport';
import ChargebackRiskReport from '@/pages/report/ChargebackRiskReport';
// 데이터 수신
const ReceiveData = React.lazy(() => import('@/pages/receive/ReceiveData'));
const ReceiveMapping = React.lazy(() => import('@/pages/receive/ReceiveMapping'));
import KpiMonthlySummary from '@/pages/kpi/KpiMonthlySummary';
import KpiOrgSummary from '@/pages/kpi/KpiOrgSummary';
import KpiRetention from '@/pages/kpi/KpiRetention';
import KpiCumulative from '@/pages/kpi/KpiCumulative';
// 원장
const RecruitLedger = React.lazy(() => import('@/pages/ledger/RecruitLedger'));
const MaintainLedger = React.lazy(() => import('@/pages/ledger/MaintainLedger'));
const ExceptionLedger = React.lazy(() => import('@/pages/ledger/ExceptionLedger'));
import UserList from '@/pages/system/UserList';
import RoleList from '@/pages/system/RoleList';
import MenuManage from '@/pages/system/MenuManage';
import CodeList from '@/pages/system/CodeList';
import SystemConfig from '@/pages/system/SystemConfig';
import SystemLog from '@/pages/system/SystemLog';
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
import DataDomainList from '@/pages/system/DataDomainList';
import DataDictionary from '@/pages/system/DataDictionary';
import ExternalCallLogList from '@/pages/system/ExternalCallLogList';
// 정산 / 지급
const SettleList = React.lazy(() => import('@/pages/settle/SettleList'));
const BatchRun = React.lazy(() => import('@/pages/settle/BatchRun'));
const PaymentList = React.lazy(() => import('@/pages/settle/PaymentList'));
const DeductionList = React.lazy(() => import('@/pages/settle/DeductionList'));
const PeriodClose = React.lazy(() => import('@/pages/settle/PeriodClose'));
const SettleCorrection = React.lazy(() => import('@/pages/settle/SettleCorrection'));
const TaxInvoiceList = React.lazy(() => import('@/pages/settle/TaxInvoiceList'));
const CommissionDispute = React.lazy(() => import('@/pages/settle/CommissionDispute'));
const IncentiveProgram = React.lazy(() => import('@/pages/settle/IncentiveProgram'));
// 리포트
const PerformanceReport = React.lazy(() => import('@/pages/report/PerformanceReport'));
const OrgReport = React.lazy(() => import('@/pages/report/OrgReport'));
const ChargebackRiskReport = React.lazy(() => import('@/pages/report/ChargebackRiskReport'));
// KPI 대시보드
const KpiMonthlySummary = React.lazy(() => import('@/pages/kpi/KpiMonthlySummary'));
const KpiOrgSummary = React.lazy(() => import('@/pages/kpi/KpiOrgSummary'));
const KpiRetention = React.lazy(() => import('@/pages/kpi/KpiRetention'));
const KpiCumulative = React.lazy(() => import('@/pages/kpi/KpiCumulative'));
// 시스템관리
const UserList = React.lazy(() => import('@/pages/system/UserList'));
const RoleList = React.lazy(() => import('@/pages/system/RoleList'));
const MenuManage = React.lazy(() => import('@/pages/system/MenuManage'));
const CodeList = React.lazy(() => import('@/pages/system/CodeList'));
const SystemConfig = React.lazy(() => import('@/pages/system/SystemConfig'));
const SystemLog = React.lazy(() => import('@/pages/system/SystemLog'));
const UploadTemplateManager = React.lazy(() => import('@/pages/system/UploadTemplateManager'));
const DataDomainList = React.lazy(() => import('@/pages/system/DataDomainList'));
const DataDictionary = React.lazy(() => import('@/pages/system/DataDictionary'));
const ExternalCallLogList = React.lazy(() => import('@/pages/system/ExternalCallLogList'));
// ── 로딩 폴백 ────────────────────────────────────────────
const PageFallback = (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<Spin size="large" />
</div>
);
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('accessToken');
@@ -104,116 +142,136 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} />
<Suspense fallback={PageFallback}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} />
{/* 조직 / 인사 */}
<Route path="org/tree" element={<OrgTree />} />
<Route path="org/agents" element={<AgentList />} />
<Route path="org/agents/new" element={<AgentForm />} />
<Route path="org/agents/:id" element={<AgentDetail />} />
<Route path="org/agents/:id/edit" element={<AgentForm />} />
{/* 조직 / 인사 */}
<Route path="org/tree" element={<OrgTree />} />
<Route path="org/agents" element={<AgentList />} />
<Route path="org/agents/new" element={<AgentForm />} />
<Route path="org/agents/:id" element={<AgentDetail />} />
<Route path="org/agents/:id/edit" element={<AgentForm />} />
{/* 상품 / 계약 */}
<Route path="companies" element={<CompanyList />} />
<Route path="products" element={<ProductList />} />
<Route path="contracts" element={<ContractList />} />
{/* 상품 / 계약 */}
<Route path="companies" element={<CompanyList />} />
<Route path="products" element={<ProductList />} />
<Route path="contracts" element={<ContractList />} />
{/* 수수료 규정 */}
<Route path="rules/commission" element={<CommissionRateList />} />
<Route path="rules/payout" element={<PayoutRuleList />} />
<Route path="rules/override" element={<OverrideRuleList />} />
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
<Route path="rules/regulatory" element={<RegulatoryLimitList />} />
<Route path="rules/installment-ratios" element={<InstallmentRatioList />} />
<Route path="rules/chargeback-grades" element={<ChargebackGradeList />} />
{/* 수수료 규정 */}
<Route path="rules/commission" element={<CommissionRateList />} />
<Route path="rules/payout" element={<PayoutRuleList />} />
<Route path="rules/override" element={<OverrideRuleList />} />
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
<Route path="rules/regulatory" element={<RegulatoryLimitList />} />
<Route path="rules/installment-ratios" element={<InstallmentRatioList />} />
<Route path="rules/chargeback-grades" element={<ChargebackGradeList />} />
{/* 데이터 수신 */}
<Route path="receive/data" element={<ReceiveData />} />
<Route path="receive/mapping" element={<ReceiveMapping />} />
{/* 데이터 수신 */}
<Route path="receive/data" element={<ReceiveData />} />
<Route path="receive/mapping" element={<ReceiveMapping />} />
{/* 원장 */}
<Route path="ledger/recruit" element={<RecruitLedger />} />
<Route path="ledger/maintain" element={<MaintainLedger />} />
<Route path="ledger/exception" element={<ExceptionLedger />} />
{/* 원장 */}
<Route path="ledger/recruit" element={<RecruitLedger />} />
<Route path="ledger/maintain" element={<MaintainLedger />} />
<Route path="ledger/exception" element={<ExceptionLedger />} />
{/* 정산 / 지급 */}
<Route path="settle" element={<SettleList />} />
<Route path="settle/batch" element={<BatchRun />} />
<Route path="settle/deductions" element={<DeductionList />} />
<Route path="settle/period-close" element={<PeriodClose />} />
<Route path="settle/corrections" element={<SettleCorrection />} />
<Route path="settle/tax-invoices" element={<TaxInvoiceList />} />
<Route path="settle/disputes" element={<CommissionDispute />} />
<Route path="settle/incentives" element={<IncentiveProgram />} />
<Route path="payments" element={<PaymentList />} />
{/* 정산 / 지급 */}
<Route path="settle" element={<SettleList />} />
<Route path="settle/batch" element={<BatchRun />} />
<Route path="settle/deductions" element={<DeductionList />} />
<Route path="settle/period-close" element={<PeriodClose />} />
<Route path="settle/corrections" element={<SettleCorrection />} />
<Route path="settle/tax-invoices" element={<TaxInvoiceList />} />
<Route path="settle/disputes" element={<CommissionDispute />} />
<Route path="settle/incentives" element={<IncentiveProgram />} />
<Route path="payments" element={<PaymentList />} />
{/* 리포트 */}
<Route path="report/performance" element={<PerformanceReport />} />
<Route path="report/org" element={<OrgReport />} />
<Route path="report/chargeback-risk" element={<ChargebackRiskReport />} />
{/* 리포트 */}
<Route path="report/performance" element={<PerformanceReport />} />
<Route path="report/org" element={<OrgReport />} />
<Route path="report/chargeback-risk" element={<ChargebackRiskReport />} />
{/* KPI 대시보드 */}
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
<Route path="kpi/org" element={<KpiOrgSummary />} />
<Route path="kpi/retention" element={<KpiRetention />} />
<Route path="kpi/cumulative" element={<KpiCumulative />} />
<Route path="kpi/retention-anomaly" element={<RetentionAnomalyList />} />
<Route path="kpi/forecast" element={<ForecastKpiList />} />
{/* KPI 대시보드 */}
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
<Route path="kpi/org" element={<KpiOrgSummary />} />
<Route path="kpi/retention" element={<KpiRetention />} />
<Route path="kpi/cumulative" element={<KpiCumulative />} />
<Route path="kpi/retention-anomaly" element={<RetentionAnomalyList />} />
<Route path="kpi/forecast" element={<ForecastKpiList />} />
{/* P5: 연말명세서 / 분개장 */}
<Route path="commission/annual-income" element={<AgentAnnualIncomeList />} />
<Route path="commission/accounting-journal" element={<AccountingJournalList />} />
{/* P5: 연말명세서 / 분개장 */}
<Route path="commission/annual-income" element={<AgentAnnualIncomeList />} />
<Route path="commission/accounting-journal" element={<AccountingJournalList />} />
{/* P5-W2: 원천세 / 부가세 신고 */}
<Route path="commission/withholding-tax" element={<WithholdingTaxReportList />} />
<Route path="commission/vat-report" element={<VatReportList />} />
{/* P5-W2: 원천세 / 부가세 신고 */}
<Route path="commission/withholding-tax" element={<WithholdingTaxReportList />} />
<Route path="commission/vat-report" element={<VatReportList />} />
{/* P5-W3: 회계 결산 / 연말 지급명세서 */}
<Route path="commission/accounting-close" element={<AccountingCloseList />} />
<Route path="commission/year-end-statement" element={<YearEndStatementList />} />
{/* P5-W3: 회계 결산 / 연말 지급명세서 */}
<Route path="commission/accounting-close" element={<AccountingCloseList />} />
<Route path="commission/year-end-statement" element={<YearEndStatementList />} />
{/* P4: 수수료 종류 */}
<Route path="commission/type" element={<CommissionMaster />} />
<Route path="commission/master" element={<CommissionMaster />} />
<Route path="commission/collection" element={<CollectionCommission />} />
<Route path="commission/renewal" element={<RenewalCommission />} />
<Route path="commission/reinstatement" element={<ReinstatementCommission />} />
<Route path="commission/persistency" element={<PersistencyBonus />} />
<Route path="commission/override-ledger" element={<OverrideLedger />} />
{/* P8: 운영 7도메인 */}
<Route path="settle/step-close" element={<StepClose />} />
<Route path="settle/hq-settle" element={<HqSettle />} />
<Route path="org/grade-eval" element={<GradeEvaluation />} />
<Route path="contracts/joint" element={<JointContract />} />
<Route path="org/guarantee" element={<GuaranteeInsurance />} />
<Route path="commission/reserve" element={<ReserveFund />} />
<Route path="commission/simulator" element={<CommissionSimulator />} />
{/* P4: 설계사 라이프사이클 */}
<Route path="agent/contracts" element={<AgentContracts />} />
<Route path="agent/licenses" element={<AgentLicenses />} />
<Route path="agent/trainings" element={<AgentTrainings />} />
{/* P7: 수수료 계산 7도메인 */}
<Route path="commission/income" element={<IncomeCommission />} />
<Route path="commission/referral" element={<ReferralCommission />} />
<Route path="commission/settling-support" element={<SettlingSupport />} />
<Route path="commission/recognized" element={<RecognizedPerformance />} />
<Route path="commission/award" element={<Award />} />
<Route path="commission/life-support" element={<LifeSupport />} />
<Route path="commission/branch-allowance" element={<BranchAllowance />} />
{/* P4: 계약 운영 */}
<Route path="contracts/endorsements" element={<ContractEndorsements />} />
<Route path="contracts/successions" element={<ContractSuccessions />} />
<Route path="contracts/complaints" element={<Complaints />} />
{/* P4: 수수료 종류 */}
<Route path="commission/type" element={<CommissionMaster />} />
<Route path="commission/master" element={<CommissionMaster />} />
<Route path="commission/collection" element={<CollectionCommission />} />
<Route path="commission/renewal" element={<RenewalCommission />} />
<Route path="commission/reinstatement" element={<ReinstatementCommission />} />
<Route path="commission/persistency" element={<PersistencyBonus />} />
<Route path="commission/override-ledger" element={<OverrideLedger />} />
{/* P4: 시스템 */}
<Route path="system/approvals" element={<Approvals />} />
<Route path="system/notices" element={<Notices />} />
{/* P4: 설계사 라이프사이클 */}
<Route path="agent/contracts" element={<AgentContracts />} />
<Route path="agent/licenses" element={<AgentLicenses />} />
<Route path="agent/trainings" element={<AgentTrainings />} />
{/* P4: 출금 */}
<Route path="payments/withdraw" element={<WithdrawRequest />} />
{/* P4: 계약 운영 */}
<Route path="contracts/endorsements" element={<ContractEndorsements />} />
<Route path="contracts/successions" element={<ContractSuccessions />} />
<Route path="contracts/complaints" element={<Complaints />} />
{/* 시스템관리 */}
<Route path="system/users" element={<UserList />} />
<Route path="system/roles" element={<RoleList />} />
<Route path="system/menus" element={<MenuManage />} />
<Route path="system/codes" element={<CodeList />} />
<Route path="system/config" element={<SystemConfig />} />
<Route path="system/logs" element={<SystemLog />} />
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
<Route path="system/data-domains" element={<DataDomainList />} />
<Route path="system/data-dictionary" element={<DataDictionary />} />
<Route path="system/external-call-log" element={<ExternalCallLogList />} />
</Route>
</Routes>
{/* P4: 시스템 */}
<Route path="system/approvals" element={<Approvals />} />
<Route path="system/notices" element={<Notices />} />
{/* P4: 출금 */}
<Route path="payments/withdraw" element={<WithdrawRequest />} />
{/* 시스템관리 */}
<Route path="system/users" element={<UserList />} />
<Route path="system/roles" element={<RoleList />} />
<Route path="system/menus" element={<MenuManage />} />
<Route path="system/codes" element={<CodeList />} />
<Route path="system/config" element={<SystemConfig />} />
<Route path="system/logs" element={<SystemLog />} />
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
<Route path="system/data-domains" element={<DataDomainList />} />
<Route path="system/data-dictionary" element={<DataDictionary />} />
<Route path="system/external-call-log" element={<ExternalCallLogList />} />
</Route>
</Routes>
</Suspense>
);
}
+277
View File
@@ -144,6 +144,173 @@ export interface OverrideLedgerSearchParam {
pageSize?: number;
}
// ── P7: IncomeCommission ──────────────────────────
export interface IncomeLedgerRow extends Record<string, unknown> {
incomeId: number;
contractId: number;
contractNo: string;
carrierId: number;
carrierName: string;
settleMonth: string;
premiumAmount: number;
companyRate: number;
incomeAmount: number;
commissionType: string;
status: string;
}
export interface MarginSummaryRow extends Record<string, unknown> {
marginId: number;
settleMonth: string;
agentId?: number;
agentName?: string;
incomeTotal: number;
payoutTotal: number;
marginAmount: number;
marginRate?: number;
}
// ── P7: ReferralCommission ────────────────────────
export interface ReferralRuleRow extends Record<string, unknown> {
ruleId: number;
referralType: string;
calcType: string;
commissionRate?: number;
fixedAmount?: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface ReferralLedgerRow extends Record<string, unknown> {
ledgerId: number;
contractId: number;
contractNo: string;
referrerAgentId: number;
referrerAgentName: string;
settleMonth: string;
referralType: string;
baseAmount: number;
commissionRate?: number;
commissionAmount: number;
status: string;
}
// ── P7: SettlingSupport ───────────────────────────
export interface SettlingSupportRuleRow extends Record<string, unknown> {
ruleId: number;
agentGrade: string;
supportAmount: number;
durationMonths: number;
clawbackMonths: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface SettlingSupportLedgerRow extends Record<string, unknown> {
ledgerId: number;
agentId: number;
agentName: string;
settleMonth: string;
supportAmount: number;
clawbackAmount: number;
netAmount: number;
status: string;
}
// ── P7: RecognizedPerformance ─────────────────────
export interface ConversionRateRow extends Record<string, unknown> {
rateId: number;
productId: number;
productName: string;
conversionRate: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface RecognizedLedgerRow extends Record<string, unknown> {
ledgerId: number;
agentId: number;
agentName: string;
contractId: number;
contractNo: string;
settleMonth: string;
premiumAmount: number;
conversionRate: number;
convertedPremium: number;
recognitionRate: number;
recognizedAmount: number;
status: string;
}
// ── P7: Award ─────────────────────────────────────
export interface AwardRuleRow extends Record<string, unknown> {
ruleId: number;
awardType: string;
calcType: string;
thresholdAmount?: number;
awardRate?: number;
awardFixed?: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface AwardLedgerRow extends Record<string, unknown> {
ledgerId: number;
agentId: number;
agentName: string;
settleMonth: string;
awardType: string;
baseAmount: number;
awardAmount: number;
status: string;
}
// ── P7: LifeSupport ───────────────────────────────
export interface LifeSupportRuleRow extends Record<string, unknown> {
ruleId: number;
agentGrade: string;
supportRate: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface LifeSupportLedgerRow extends Record<string, unknown> {
ledgerId: number;
agentId: number;
agentName: string;
settleMonth: string;
baseAmount: number;
supportRate: number;
supportAmount: number;
clawbackAmount: number;
netAmount: number;
status: string;
}
// ── P7: BranchAllowance ───────────────────────────
export interface BranchAllowanceRuleRow extends Record<string, unknown> {
ruleId: number;
positionCode: string;
allowanceRate: number;
capAmount?: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface BranchAllowanceLedgerRow extends Record<string, unknown> {
ledgerId: number;
managerAgentId: number;
managerAgentName: string;
orgId: number;
orgName: string;
settleMonth: string;
baseAmount: number;
allowanceRate: number;
allowanceAmount: number;
clawbackAmount: number;
status: string;
}
// ── API ───────────────────────────────────────────
export const commissionApi = {
// CommissionMaster
@@ -227,4 +394,114 @@ export const commissionApi = {
unwrap<PageResponse<OverrideLedgerRow>>(
api.get('/api/override-ledgers', { params: p }),
),
// ── P7: IncomeCommission ──────────────────────────
incomeLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<IncomeLedgerRow>>(
api.get('/api/income-commissions/ledgers', { params: p }),
),
incomeMargins: (p: CommissionSearchParam) =>
unwrap<PageResponse<MarginSummaryRow>>(
api.get('/api/income-commissions/margins', { params: p }),
),
incomeMarginRecalc: (settleMonth: string) =>
unwrap<void>(
api.post('/api/income-commissions/margin/recalc', null, { params: { settleMonth } }),
),
// ── P7: ReferralCommission ────────────────────────
referralRules: (p?: Partial<CommissionSearchParam>) =>
unwrap<PageResponse<ReferralRuleRow>>(
api.get('/api/referral-commissions/rules', { params: p }),
),
referralRuleCreate: (body: Partial<ReferralRuleRow>) =>
unwrap<ReferralRuleRow>(api.post('/api/referral-commissions/rules', body)),
referralRuleUpdate: (id: number, body: Partial<ReferralRuleRow>) =>
unwrap<ReferralRuleRow>(api.put(`/api/referral-commissions/rules/${id}`, body)),
referralRuleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/referral-commissions/rules/${id}`)),
referralLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<ReferralLedgerRow>>(
api.get('/api/referral-commissions/ledgers', { params: p }),
),
// ── P7: SettlingSupport ───────────────────────────
settlingRules: (p?: Partial<CommissionSearchParam>) =>
unwrap<PageResponse<SettlingSupportRuleRow>>(
api.get('/api/settling-supports/rules', { params: p }),
),
settlingRuleCreate: (body: Partial<SettlingSupportRuleRow>) =>
unwrap<SettlingSupportRuleRow>(api.post('/api/settling-supports/rules', body)),
settlingRuleUpdate: (id: number, body: Partial<SettlingSupportRuleRow>) =>
unwrap<SettlingSupportRuleRow>(api.put(`/api/settling-supports/rules/${id}`, body)),
settlingRuleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/settling-supports/rules/${id}`)),
settlingLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<SettlingSupportLedgerRow>>(
api.get('/api/settling-supports/ledgers', { params: p }),
),
// ── P7: RecognizedPerformance ─────────────────────
conversionRates: (p?: Partial<CommissionSearchParam>) =>
unwrap<PageResponse<ConversionRateRow>>(
api.get('/api/recognized-performances/rates', { params: p }),
),
conversionRateCreate: (body: Partial<ConversionRateRow>) =>
unwrap<ConversionRateRow>(api.post('/api/recognized-performances/rates', body)),
conversionRateUpdate: (id: number, body: Partial<ConversionRateRow>) =>
unwrap<ConversionRateRow>(api.put(`/api/recognized-performances/rates/${id}`, body)),
conversionRateDelete: (id: number) =>
unwrap<void>(api.delete(`/api/recognized-performances/rates/${id}`)),
recognizedLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<RecognizedLedgerRow>>(
api.get('/api/recognized-performances/ledgers', { params: p }),
),
// ── P7: Award ─────────────────────────────────────
awardRules: (p?: Partial<CommissionSearchParam>) =>
unwrap<PageResponse<AwardRuleRow>>(
api.get('/api/awards/rules', { params: p }),
),
awardRuleCreate: (body: Partial<AwardRuleRow>) =>
unwrap<AwardRuleRow>(api.post('/api/awards/rules', body)),
awardRuleUpdate: (id: number, body: Partial<AwardRuleRow>) =>
unwrap<AwardRuleRow>(api.put(`/api/awards/rules/${id}`, body)),
awardRuleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/awards/rules/${id}`)),
awardLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<AwardLedgerRow>>(
api.get('/api/awards/ledgers', { params: p }),
),
// ── P7: LifeSupport ───────────────────────────────
lifeSupportRules: (p?: Partial<CommissionSearchParam>) =>
unwrap<PageResponse<LifeSupportRuleRow>>(
api.get('/api/life-supports/rules', { params: p }),
),
lifeSupportRuleCreate: (body: Partial<LifeSupportRuleRow>) =>
unwrap<LifeSupportRuleRow>(api.post('/api/life-supports/rules', body)),
lifeSupportRuleUpdate: (id: number, body: Partial<LifeSupportRuleRow>) =>
unwrap<LifeSupportRuleRow>(api.put(`/api/life-supports/rules/${id}`, body)),
lifeSupportRuleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/life-supports/rules/${id}`)),
lifeSupportLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<LifeSupportLedgerRow>>(
api.get('/api/life-supports/ledgers', { params: p }),
),
// ── P7: BranchAllowance ───────────────────────────
branchAllowanceRules: (p?: Partial<CommissionSearchParam>) =>
unwrap<PageResponse<BranchAllowanceRuleRow>>(
api.get('/api/branch-allowances/rules', { params: p }),
),
branchAllowanceRuleCreate: (body: Partial<BranchAllowanceRuleRow>) =>
unwrap<BranchAllowanceRuleRow>(api.post('/api/branch-allowances/rules', body)),
branchAllowanceRuleUpdate: (id: number, body: Partial<BranchAllowanceRuleRow>) =>
unwrap<BranchAllowanceRuleRow>(api.put(`/api/branch-allowances/rules/${id}`, body)),
branchAllowanceRuleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/branch-allowances/rules/${id}`)),
branchAllowanceLedgers: (p: CommissionSearchParam) =>
unwrap<PageResponse<BranchAllowanceLedgerRow>>(
api.get('/api/branch-allowances/ledgers', { params: p }),
),
};
+229
View File
@@ -0,0 +1,229 @@
import api, { PageResponse, unwrap } from './request';
// ── 단계마감 (StepClose) ─────────────────────────────
export interface StepCloseRow extends Record<string, unknown> {
stepCloseId: number;
settleMonth: string;
stepCode: string;
stepSeq: number;
status: string;
closedAt?: string;
closedBy?: number;
createdAt?: string;
}
// ── 본사정산 (HqSettle) ──────────────────────────────
export interface HqSettleRow extends Record<string, unknown> {
itemId: number;
settleMonth: string;
itemType: string;
itemName: string;
amount: number;
memo?: string;
status: string;
createdAt?: string;
}
export interface HqSettleSummary {
incomeTotal: number;
expenseTotal: number;
profitLoss: number;
}
// ── 등급평가 (GradeEvaluation) ───────────────────────
export interface GradeEvalRuleRow extends Record<string, unknown> {
ruleId: number;
gradeId: number;
gradeName?: string;
minScore: number;
effectiveFrom: string;
effectiveTo?: string;
}
export interface GradeEvalRow extends Record<string, unknown> {
evalId: number;
agentId: number;
agentName?: string;
evalPeriod: string;
performanceScore: number;
prevGradeId?: number;
prevGradeName?: string;
evaluatedGradeId?: number;
evaluatedGradeName?: string;
status: string;
}
// ── 공동계약 (JointContract) ─────────────────────────
export interface JointContractRow extends Record<string, unknown> {
jointId: number;
contractId: number;
contractNo?: string;
agentId: number;
agentName?: string;
role: string;
shareRate: number;
createdAt?: string;
}
// ── 보증보험 (GuaranteeInsurance) ────────────────────
export interface GuaranteeInsuranceRow extends Record<string, unknown> {
guaranteeId: number;
agentId: number;
agentName?: string;
policyNo: string;
carrierId?: number;
carrierName?: string;
guaranteeAmount: number;
startDate: string;
endDate: string;
status: string;
}
// ── 적립금 (ReserveFund) ─────────────────────────────
export interface ReserveFundRow extends Record<string, unknown> {
reserveId: number;
agentId: number;
agentName?: string;
settleMonth: string;
reserveIn: number;
reserveOut: number;
balance: number;
createdAt?: string;
}
// ── 수수료 시뮬레이터 (CommissionSimulation) ──────────
export interface SimulationRow extends Record<string, unknown> {
simId: number;
simName: string;
premiumAmount: number;
companyRate: number;
payoutRate: number;
commissionYear: number;
companyPayment: number;
agentCommission: number;
withholdingTax: number;
netAmount: number;
expectedChargeback?: number;
createdAt?: string;
}
export interface SimulationRequest {
simName: string;
premiumAmount: number;
companyRate: number;
payoutRate: number;
commissionYear: number;
}
// ── API ───────────────────────────────────────────────
export const operationApi = {
// StepClose
stepCloseList: (params?: { settleMonth?: string }) =>
unwrap<PageResponse<StepCloseRow>>(
api.get('/api/step-closes', { params }),
),
stepCloseByMonth: (settleMonth: string) =>
unwrap<StepCloseRow[]>(
api.get('/api/step-closes/by-month', { params: { settleMonth } }),
),
stepCloseGet: (id: number) =>
unwrap<StepCloseRow>(api.get(`/api/step-closes/${id}`)),
stepCloseCreate: (body: Partial<StepCloseRow>) =>
unwrap<StepCloseRow>(api.post('/api/step-closes', body)),
stepCloseClose: (id: number) =>
unwrap<StepCloseRow>(api.post(`/api/step-closes/${id}/close`)),
stepCloseReopen: (id: number) =>
unwrap<StepCloseRow>(api.post(`/api/step-closes/${id}/reopen`)),
// HqSettle
hqSettleList: (params?: { settleMonth?: string }) =>
unwrap<PageResponse<HqSettleRow>>(
api.get('/api/hq-settles', { params }),
),
hqSettleSummary: (settleMonth: string) =>
unwrap<HqSettleSummary>(
api.get('/api/hq-settles/summary', { params: { settleMonth } }),
),
hqSettleGet: (id: number) =>
unwrap<HqSettleRow>(api.get(`/api/hq-settles/${id}`)),
hqSettleCreate: (body: Partial<HqSettleRow>) =>
unwrap<HqSettleRow>(api.post('/api/hq-settles', body)),
hqSettleUpdate: (id: number, body: Partial<HqSettleRow>) =>
unwrap<HqSettleRow>(api.put(`/api/hq-settles/${id}`, body)),
hqSettleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/hq-settles/${id}`)),
// GradeEval Rules
gradeEvalRules: () =>
unwrap<GradeEvalRuleRow[]>(api.get('/api/grade-evaluations/rules')),
gradeEvalRuleGet: (id: number) =>
unwrap<GradeEvalRuleRow>(api.get(`/api/grade-evaluations/rules/${id}`)),
gradeEvalRuleCreate: (body: Partial<GradeEvalRuleRow>) =>
unwrap<GradeEvalRuleRow>(api.post('/api/grade-evaluations/rules', body)),
gradeEvalRuleUpdate: (id: number, body: Partial<GradeEvalRuleRow>) =>
unwrap<GradeEvalRuleRow>(api.put(`/api/grade-evaluations/rules/${id}`, body)),
gradeEvalRuleDelete: (id: number) =>
unwrap<void>(api.delete(`/api/grade-evaluations/rules/${id}`)),
// GradeEval Evaluations
gradeEvalList: (params?: { agentName?: string; evalPeriod?: string }) =>
unwrap<PageResponse<GradeEvalRow>>(
api.get('/api/grade-evaluations', { params }),
),
gradeEvalGet: (evalId: number) =>
unwrap<GradeEvalRow>(api.get(`/api/grade-evaluations/${evalId}`)),
gradeEvalCreate: (body: Partial<GradeEvalRow>) =>
unwrap<GradeEvalRow>(api.post('/api/grade-evaluations', body)),
gradeEvalApply: (evalId: number) =>
unwrap<GradeEvalRow>(api.post(`/api/grade-evaluations/${evalId}/apply`)),
gradeEvalDelete: (evalId: number) =>
unwrap<void>(api.delete(`/api/grade-evaluations/${evalId}`)),
// JointContract
jointContractList: (params?: { contractNo?: string; agentName?: string }) =>
unwrap<PageResponse<JointContractRow>>(
api.get('/api/joint-contracts', { params }),
),
jointContractGet: (id: number) =>
unwrap<JointContractRow>(api.get(`/api/joint-contracts/${id}`)),
jointContractCreate: (body: Partial<JointContractRow>) =>
unwrap<JointContractRow>(api.post('/api/joint-contracts', body)),
jointContractUpdate: (id: number, body: Partial<JointContractRow>) =>
unwrap<JointContractRow>(api.put(`/api/joint-contracts/${id}`, body)),
jointContractDelete: (id: number) =>
unwrap<void>(api.delete(`/api/joint-contracts/${id}`)),
// GuaranteeInsurance
guaranteeInsuranceList: (params?: { agentName?: string; status?: string }) =>
unwrap<PageResponse<GuaranteeInsuranceRow>>(
api.get('/api/guarantee-insurances', { params }),
),
guaranteeInsuranceGet: (id: number) =>
unwrap<GuaranteeInsuranceRow>(api.get(`/api/guarantee-insurances/${id}`)),
guaranteeInsuranceCreate: (body: Partial<GuaranteeInsuranceRow>) =>
unwrap<GuaranteeInsuranceRow>(api.post('/api/guarantee-insurances', body)),
guaranteeInsuranceUpdate: (id: number, body: Partial<GuaranteeInsuranceRow>) =>
unwrap<GuaranteeInsuranceRow>(api.put(`/api/guarantee-insurances/${id}`, body)),
guaranteeInsuranceDelete: (id: number) =>
unwrap<void>(api.delete(`/api/guarantee-insurances/${id}`)),
// ReserveFund
reserveFundList: (params?: { settleMonth?: string; agentName?: string }) =>
unwrap<PageResponse<ReserveFundRow>>(
api.get('/api/reserve-funds', { params }),
),
reserveFundGet: (id: number) =>
unwrap<ReserveFundRow>(api.get(`/api/reserve-funds/${id}`)),
reserveFundUpsert: (body: Partial<ReserveFundRow>) =>
unwrap<ReserveFundRow>(api.post('/api/reserve-funds', body)),
// CommissionSimulation
simulationList: (params?: Record<string, unknown>) =>
unwrap<PageResponse<SimulationRow>>(
api.get('/api/commission-simulations', { params }),
),
simulationGet: (id: number) =>
unwrap<SimulationRow>(api.get(`/api/commission-simulations/${id}`)),
simulate: (body: SimulationRequest) =>
unwrap<SimulationRow>(api.post('/api/commission-simulations/simulate', body)),
};
+209
View File
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Select, Space, 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 {
commissionApi,
AwardRuleRow,
AwardLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const AWARD_TYPE_OPTIONS = [
{ value: 'NEW_RECRUIT', label: '신인도입' },
{ value: 'CORPORATE', label: '법인' },
{ value: 'RETENTION', label: '유지율' },
{ value: 'PERFORMANCE', label: '실적' },
];
const RULE_COLS: ColDef<AwardRuleRow>[] = [
{ field: 'awardType', headerName: '시상유형', width: 130 },
{ field: 'calcType', headerName: '계산방식', width: 110 },
{ field: 'thresholdAmount', headerName: '기준실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'awardRate', headerName: '시상율', width: 110, type: 'numericColumn' },
{ field: 'awardFixed', headerName: '시상정액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
];
const LEDGER_COLS: ColDef<AwardLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'awardType', headerName: '시상유형', width: 120 },
{ field: 'baseAmount', headerName: '기준실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'awardAmount', headerName: '시상금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function Award() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<AwardRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['award', 'rules'],
queryFn: () => commissionApi.awardRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['award', 'ledgers', ledgerParams],
queryFn: () => commissionApi.awardLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: AwardRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.awardRuleUpdate(editingRule.ruleId, values);
message.success('수정 완료');
} else {
await commissionApi.awardRuleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['award', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.awardRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['award', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
awardAmount: ledgers.reduce((s, r) => s + (r.awardAmount ?? 0), 0),
} as Partial<AwardLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="시상" description="시상 룰 관리 및 원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/awards 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '시상 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="AWARD" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<AwardRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: AwardRuleRow }) => (
<Space>
<PermissionButton menuCode="AWARD" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="AWARD" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<AwardLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="awardType" label="시상유형" rules={[{ required: true }]}>
<Select options={AWARD_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="calcType" label="계산방식" rules={[{ required: true }]}>
<Select options={[{ value: 'RATE', label: '정률' }, { value: 'FIXED', label: '정액' }]} />
</Form.Item>
<Form.Item name="thresholdAmount" label="기준실적(이상)">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="awardRate" label="시상율(0~1)">
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
</Form.Item>
<Form.Item name="awardFixed" label="시상정액">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,198 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, 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 {
commissionApi,
BranchAllowanceRuleRow,
BranchAllowanceLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
const RULE_COLS: ColDef<BranchAllowanceRuleRow>[] = [
{ field: 'positionCode', headerName: '직책코드', width: 130 },
{ field: 'allowanceRate', headerName: '수당율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'capAmount', headerName: '상한', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
];
const LEDGER_COLS: ColDef<BranchAllowanceLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'managerAgentName',headerName: '지점장', width: 130 },
{ field: 'orgName', headerName: '조직명', width: 130 },
{ field: 'baseAmount', headerName: '조직실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'allowanceRate', headerName: '수당율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'allowanceAmount', headerName: '수당금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function BranchAllowance() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<BranchAllowanceRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['branchAllowance', 'rules'],
queryFn: () => commissionApi.branchAllowanceRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['branchAllowance', 'ledgers', ledgerParams],
queryFn: () => commissionApi.branchAllowanceLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: BranchAllowanceRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.branchAllowanceRuleUpdate(editingRule.ruleId, values);
message.success('수정 완료');
} else {
await commissionApi.branchAllowanceRuleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['branchAllowance', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.branchAllowanceRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['branchAllowance', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
managerAgentName: '합계',
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
allowanceAmount: ledgers.reduce((s, r) => s + (r.allowanceAmount ?? 0), 0),
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
} as Partial<BranchAllowanceLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="지점장수당" description="지점장수당 룰 관리 및 원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/branch-allowances 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '수당 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="BRANCH_MGR_ALLOWANCE" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<BranchAllowanceRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: BranchAllowanceRuleRow }) => (
<Space>
<PermissionButton menuCode="BRANCH_MGR_ALLOWANCE" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="BRANCH_MGR_ALLOWANCE" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<BranchAllowanceLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="positionCode" label="직책코드" rules={[{ required: true }]}>
<Input placeholder="예: BRANCH_MGR" />
</Form.Item>
<Form.Item name="allowanceRate" label="수당율(0~1)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
</Form.Item>
<Form.Item name="capAmount" label="상한(미입력=무제한)">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Alert, Card, Col, Form, Input, InputNumber, Row, Statistic, 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 DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import { operationApi, SimulationRow, SimulationRequest } from '@/api/operation';
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_ERROR, COLOR_SUCCESS } 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 HIST_COLS: ColDef<SimulationRow>[] = [
{ field: 'simName', headerName: '시나리오명', width: 150 },
{ field: 'premiumAmount', headerName: '보험료', width: 130, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'companyRate', headerName: '보험사요율(%)', width: 130, type: 'numericColumn',
valueFormatter: (p) => `${((p.value as number) * 100).toFixed(2)}%` },
{ field: 'payoutRate', headerName: '지급률(%)', width: 110, type: 'numericColumn',
valueFormatter: (p) => `${((p.value as number) * 100).toFixed(2)}%` },
{ field: 'commissionYear', headerName: '회차', width: 70, type: 'numericColumn' },
{ field: 'companyPayment', headerName: '보험사지급액', width: 140, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'agentCommission', headerName: '설계사수수료', width: 140, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'withholdingTax', headerName: '원천세(3.3%)', width: 130, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'netAmount', headerName: '실지급', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'expectedChargeback', headerName: '예상환수', width: 120, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'createdAt', headerName: '계산일시', flex: 1 },
];
export default function CommissionSimulator() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [result, setResult] = useState<SimulationRow | null>(null);
const { data: listData, isLoading, isError } = useQuery({
queryKey: ['commission-simulation', 'list'],
queryFn: () => operationApi.simulationList(),
retry: false,
});
const rows = listData?.list ?? [];
const handleSimulate = async () => {
const values = await form.validateFields();
// UI: 율은 % 단위 → 백엔드는 0~1
const body: SimulationRequest = {
simName: values.simName as string,
premiumAmount: values.premiumAmount as number,
companyRate: (values.companyRate as number) / 100,
payoutRate: (values.payoutRate as number) / 100,
commissionYear: values.commissionYear as number,
};
try {
const res = await operationApi.simulate(body);
setResult(res);
message.success('계산 완료');
qc.invalidateQueries({ queryKey: ['commission-simulation', 'list'] });
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer title="수수료 시뮬레이터" description="보험료/요율/지급률 입력 후 수수료 계산">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/commission-simulations 를 확인하세요." style={{ marginBottom: 16 }} />
)}
{/* 입력 폼 */}
<ProCard title="시뮬레이션 입력" style={{ ...cardStyle, marginBottom: 16 }}>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={6}>
<Form.Item name="simName" label="시나리오명" rules={[{ required: true }]}>
<Input placeholder="시나리오명" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="premiumAmount" label="보험료(원)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="companyRate" label="보험사요율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} addonAfter="%" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="payoutRate" label="지급률(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} addonAfter="%" />
</Form.Item>
</Col>
<Col span={4}>
<Form.Item name="commissionYear" label="회차" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
</Col>
</Row>
<PermissionButton menuCode="COMM_SIMULATOR" permCode="CREATE" type="primary" onClick={handleSimulate}>
</PermissionButton>
</Form>
</ProCard>
{/* 결과 카드 */}
{result && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={4}>
<Card style={cardStyle}>
<Statistic title="보험사지급액" value={result.companyPayment} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_PRIMARY }} />
</Card>
</Col>
<Col span={4}>
<Card style={cardStyle}>
<Statistic title="설계사수수료" value={result.agentCommission} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_PRIMARY }} />
</Card>
</Col>
<Col span={4}>
<Card style={cardStyle}>
<Statistic title="원천세(3.3%)" value={result.withholdingTax} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_ERROR }} />
</Card>
</Col>
<Col span={4}>
<Card style={cardStyle}>
<Statistic title="실지급" value={result.netAmount} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_SUCCESS }} />
</Card>
</Col>
<Col span={4}>
<Card style={cardStyle}>
<Statistic title="예상환수" value={result.expectedChargeback ?? 0} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_ERROR }} />
</Card>
</Col>
</Row>
)}
{/* 저장된 시뮬 목록 */}
<ProCard title="저장된 시뮬레이션 목록" style={cardStyle}>
<DataGrid<SimulationRow>
rows={rows}
columns={HIST_COLS}
loading={isLoading}
height={400}
rowKey="simId"
/>
</ProCard>
</PageContainer>
);
}
@@ -0,0 +1,168 @@
import { useState } from 'react';
import { Alert, Button, Space, 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 {
commissionApi,
IncomeLedgerRow,
MarginSummaryRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
const LEDGER_COLS: ColDef<IncomeLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'carrierName', headerName: '보험사', width: 130 },
{ field: 'commissionType', headerName: '수수료유형', width: 120 },
{ field: 'premiumAmount', headerName: '보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'companyRate', headerName: '보험사요율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'incomeAmount', headerName: '수입수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
const MARGIN_COLS: ColDef<MarginSummaryRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'incomeTotal', headerName: '수입합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'payoutTotal', headerName: '지급합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'marginAmount', headerName: '수지차', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'marginRate', headerName: '수지차율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
];
export default function IncomeCommission() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [marginParams, setMarginParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [recalcLoading, setRecalcLoading] = useState(false);
const { data: ledgersData, isLoading: ledgersLoading, isError: ledgersError } = useQuery({
queryKey: ['income', 'ledgers', ledgerParams],
queryFn: () => commissionApi.incomeLedgers(ledgerParams),
retry: false,
});
const { data: marginData, isLoading: marginLoading } = useQuery({
queryKey: ['income', 'margin', marginParams],
queryFn: () => commissionApi.incomeMargins(marginParams),
retry: false,
});
const ledgers = ledgersData?.list ?? [];
const margins = marginData?.list ?? [];
const pinnedLedger = ledgers.length > 0 ? {
contractNo: '합계',
premiumAmount: ledgers.reduce((s, r) => s + (r.premiumAmount ?? 0), 0),
incomeAmount: ledgers.reduce((s, r) => s + (r.incomeAmount ?? 0), 0),
} as Partial<IncomeLedgerRow> : undefined;
const pinnedMargin = margins.length > 0 ? {
agentName: '합계',
incomeTotal: margins.reduce((s, r) => s + (r.incomeTotal ?? 0), 0),
payoutTotal: margins.reduce((s, r) => s + (r.payoutTotal ?? 0), 0),
marginAmount: margins.reduce((s, r) => s + (r.marginAmount ?? 0), 0),
} as Partial<MarginSummaryRow> : undefined;
const handleRecalc = async () => {
setRecalcLoading(true);
try {
await commissionApi.incomeMarginRecalc(marginParams.settleMonth ?? dayjs().format('YYYYMM'));
message.success('수지차 재계산 완료');
qc.invalidateQueries({ queryKey: ['income', 'margin'] });
} catch { /* request.ts 처리 */ }
finally { setRecalcLoading(false); }
};
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="수입수수료/수지차" description="수입수수료 원장 조회 및 수지차 집계">
{ledgersError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/income-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="ledgers"
items={[
{
key: 'ledgers',
label: '수입원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<IncomeLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="incomeId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
{
key: 'margin',
label: '수지차(margin)',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setMarginParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setMarginParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton
menuCode="INCOME_COMM"
permCode="UPDATE"
type="primary"
loading={recalcLoading}
onClick={handleRecalc}
>
</PermissionButton>
</div>
<DataGrid<MarginSummaryRow>
rows={margins}
columns={MARGIN_COLS}
loading={marginLoading}
height={520}
rowKey="marginId"
pinnedBottomRow={pinnedMargin}
/>
</ProCard>
</>
),
},
]}
/>
</PageContainer>
);
}
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, 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 {
commissionApi,
LifeSupportRuleRow,
LifeSupportLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
const RULE_COLS: ColDef<LifeSupportRuleRow>[] = [
{ field: 'agentGrade', headerName: '설계사등급', width: 130 },
{ field: 'supportRate', headerName: '지급률', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'effectiveFrom',headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
];
const LEDGER_COLS: ColDef<LifeSupportLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'baseAmount', headerName: '생보실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'supportRate', headerName: '지급률', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'supportAmount', headerName: '지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'netAmount', headerName: '실지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function LifeSupport() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<LifeSupportRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['lifeSupport', 'rules'],
queryFn: () => commissionApi.lifeSupportRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['lifeSupport', 'ledgers', ledgerParams],
queryFn: () => commissionApi.lifeSupportLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: LifeSupportRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.lifeSupportRuleUpdate(editingRule.ruleId, values);
message.success('수정 완료');
} else {
await commissionApi.lifeSupportRuleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['lifeSupport', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.lifeSupportRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['lifeSupport', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
supportAmount: ledgers.reduce((s, r) => s + (r.supportAmount ?? 0), 0),
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
netAmount: ledgers.reduce((s, r) => s + (r.netAmount ?? 0), 0),
} as Partial<LifeSupportLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="생보운영지원수수료" description="생보운영지원수수료 룰 관리 및 원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/life-supports 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '지급률 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="LIFE_OP_SUPPORT" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<LifeSupportRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: LifeSupportRuleRow }) => (
<Space>
<PermissionButton menuCode="LIFE_OP_SUPPORT" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="LIFE_OP_SUPPORT" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<LifeSupportLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="agentGrade" label="설계사등급" rules={[{ required: true }]}>
<Input placeholder="예: ENTRY, SENIOR" />
</Form.Item>
<Form.Item name="supportRate" label="지급률(0~1)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, 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 {
commissionApi,
ConversionRateRow,
RecognizedLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-';
const RULE_COLS: ColDef<ConversionRateRow>[] = [
{ field: 'productName', headerName: '상품명', width: 180 },
{ field: 'conversionRate', headerName: '환산율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
];
const LEDGER_COLS: ColDef<RecognizedLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'premiumAmount', headerName: '보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'conversionRate', headerName: '환산율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'convertedPremium', headerName: '환산보험료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'recognitionRate', headerName: '인정율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) },
{ field: 'recognizedAmount', headerName: '인정실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function RecognizedPerformance() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<ConversionRateRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['recognized', 'rules'],
queryFn: () => commissionApi.conversionRates(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['recognized', 'ledgers', ledgerParams],
queryFn: () => commissionApi.recognizedLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: ConversionRateRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.conversionRateUpdate(editingRule.rateId, values);
message.success('수정 완료');
} else {
await commissionApi.conversionRateCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['recognized', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '환산율 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.conversionRateDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['recognized', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
premiumAmount: ledgers.reduce((s, r) => s + (r.premiumAmount ?? 0), 0),
convertedPremium: ledgers.reduce((s, r) => s + (r.convertedPremium ?? 0), 0),
recognizedAmount: ledgers.reduce((s, r) => s + (r.recognizedAmount ?? 0), 0),
} as Partial<RecognizedLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="인정실적" description="환산율 관리 및 인정실적원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/recognized-performances 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '환산율',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="RECOGNIZED_PERF" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<ConversionRateRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: ConversionRateRow }) => (
<Space>
<PermissionButton menuCode="RECOGNIZED_PERF" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="RECOGNIZED_PERF" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.rateId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="rateId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '인정실적원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<RecognizedLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '환산율 수정' : '환산율 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="productId" label="상품ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
<Form.Item name="conversionRate" label="환산율(0~1)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,200 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Select, Space, 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 {
commissionApi,
ReferralRuleRow,
ReferralLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const RULE_COLS: ColDef<ReferralRuleRow>[] = [
{ field: 'referralType', headerName: '유형', width: 120 },
{ field: 'calcType', headerName: '계산방식', width: 110 },
{ field: 'commissionRate', headerName: '수수료율', width: 110, type: 'numericColumn' },
{ field: 'fixedAmount', headerName: '정액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
];
const LEDGER_COLS: ColDef<ReferralLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'referrerAgentName',headerName: '소개설계사', width: 130 },
{ field: 'contractNo', headerName: '계약번호', width: 140 },
{ field: 'referralType', headerName: '유형', width: 100 },
{ field: 'baseAmount', headerName: '기준금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'commissionRate', headerName: '수수료율', width: 110, type: 'numericColumn' },
{ field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function ReferralCommission() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<ReferralRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['referral', 'rules'],
queryFn: () => commissionApi.referralRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['referral', 'ledgers', ledgerParams],
queryFn: () => commissionApi.referralLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: ReferralRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.referralRuleUpdate(editingRule.ruleId, values);
message.success('수정 완료');
} else {
await commissionApi.referralRuleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['referral', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.referralRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['referral', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
referrerAgentName: '합계',
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0),
} as Partial<ReferralLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="소개·이관수수료" description="소개·이관수수료 룰 관리 및 원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/referral-commissions 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '수수료 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="REFERRAL_COMM" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<ReferralRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: ReferralRuleRow }) => (
<Space>
<PermissionButton menuCode="REFERRAL_COMM" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="REFERRAL_COMM" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<ReferralLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="referralType" label="유형" rules={[{ required: true }]}>
<Select options={[{ value: 'REFERRAL', label: '소개' }, { value: 'TRANSFER', label: '이관' }]} />
</Form.Item>
<Form.Item name="calcType" label="계산방식" rules={[{ required: true }]}>
<Select options={[{ value: 'RATE', label: '정률' }, { value: 'FIXED', label: '정액' }]} />
</Form.Item>
<Form.Item name="commissionRate" label="수수료율(0~1)">
<InputNumber style={{ width: '100%' }} min={0} max={1} step={0.0001} />
</Form.Item>
<Form.Item name="fixedAmount" label="정액">
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, 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, ReserveFundRow } 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 COLS: ColDef<ReserveFundRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'reserveIn', headerName: '적립액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'reserveOut', headerName: '인출액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'balance', headerName: '잔액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'createdAt', headerName: '등록일', width: 130 },
];
export default function ReserveFund() {
const qc = useQueryClient();
const [searchParams, setSearchParams] = useState<{ settleMonth?: string; agentName?: string }>({
settleMonth: dayjs().format('YYYYMM'),
});
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['reserve-fund', 'list', searchParams],
queryFn: () => operationApi.reserveFundList(searchParams),
retry: false,
});
const rows = data?.list ?? [];
const pinnedRow = rows.length > 0 ? {
agentName: '합계',
reserveIn: rows.reduce((s, r) => s + (r.reserveIn ?? 0), 0),
reserveOut: rows.reduce((s, r) => s + (r.reserveOut ?? 0), 0),
balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0),
} as Partial<ReserveFundRow> : undefined;
const handleUpsert = async () => {
const values = await form.validateFields();
try {
await operationApi.reserveFundUpsert(values);
message.success('등록/수정 완료');
qc.invalidateQueries({ queryKey: ['reserve-fund', 'list'] });
setModalOpen(false);
form.resetFields();
} catch { /* request.ts 처리 */ }
};
return (
<PageContainer title="적립금" description="환수 대비 수수료 적립금 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/reserve-funds 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
initialValues={{ settleMonth: dayjs() }}
onSearch={(v) => setSearchParams({ settleMonth: v.settleMonth as string, agentName: v.agentName as string })}
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM') })}
/>
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="RESERVE_FUND" permCode="CREATE" type="primary" onClick={() => { form.resetFields(); setModalOpen(true); }}>
+
</PermissionButton>
</div>
<DataGrid<ReserveFundRow>
rows={rows}
columns={COLS}
loading={isLoading}
height={520}
rowKey="reserveId"
pinnedBottomRow={pinnedRow}
/>
</ProCard>
<Modal
title="적립금 등록(upsert)"
open={modalOpen}
onOk={handleUpsert}
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="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
<Input placeholder={dayjs().format('YYYYMM')} />
</Form.Item>
<Form.Item name="reserveIn" label="적립액" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="reserveOut" label="인출액" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,199 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Space, 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 {
commissionApi,
SettlingSupportRuleRow,
SettlingSupportLedgerRow,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const RULE_COLS: ColDef<SettlingSupportRuleRow>[] = [
{ field: 'agentGrade', headerName: '설계사등급', width: 130 },
{ field: 'supportAmount', headerName: '월지원금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'durationMonths', headerName: '지원기간(월)', width: 120, type: 'numericColumn' },
{ field: 'clawbackMonths', headerName: '환수기준(월)', width: 120, type: 'numericColumn' },
{ field: 'effectiveFrom', headerName: '적용시작', width: 120 },
{ field: 'effectiveTo', headerName: '적용종료', width: 120 },
];
const LEDGER_COLS: ColDef<SettlingSupportLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'supportAmount', headerName: '지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'netAmount', headerName: '실지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'status', headerName: '상태', width: 110 },
];
export default function SettlingSupport() {
const qc = useQueryClient();
const [ledgerParams, setLedgerParams] = useState<CommissionSearchParam>({
settleMonth: dayjs().format('YYYYMM'), pageSize: 200,
});
const [ruleModalOpen, setRuleModalOpen] = useState(false);
const [editingRule, setEditingRule] = useState<SettlingSupportRuleRow | null>(null);
const [form] = Form.useForm();
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
queryKey: ['settling', 'rules'],
queryFn: () => commissionApi.settlingRules(),
retry: false,
});
const { data: ledgersData, isLoading: ledgersLoading } = useQuery({
queryKey: ['settling', 'ledgers', ledgerParams],
queryFn: () => commissionApi.settlingLedgers(ledgerParams),
retry: false,
});
const rules = rulesData?.list ?? [];
const ledgers = ledgersData?.list ?? [];
const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); };
const openEdit = (row: SettlingSupportRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingRule) {
await commissionApi.settlingRuleUpdate(editingRule.ruleId, values);
message.success('수정 완료');
} else {
await commissionApi.settlingRuleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['settling', 'rules'] });
setRuleModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '룰 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await commissionApi.settlingRuleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['settling', 'rules'] });
},
});
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
supportAmount: ledgers.reduce((s, r) => s + (r.supportAmount ?? 0), 0),
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
netAmount: ledgers.reduce((s, r) => s + (r.netAmount ?? 0), 0),
} as Partial<SettlingSupportLedgerRow> : undefined;
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
return (
<PageContainer title="정착지원금" description="정착지원금 룰 관리 및 원장 조회">
{rulesError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/settling-supports 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<Tabs
defaultActiveKey="rules"
items={[
{
key: 'rules',
label: '지원금 룰',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="SETTLING_SUPPORT" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<SettlingSupportRuleRow>
rows={rules}
columns={[
...RULE_COLS,
{
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: SettlingSupportRuleRow }) => (
<Space>
<PermissionButton menuCode="SETTLING_SUPPORT" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="SETTLING_SUPPORT" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.ruleId)}></PermissionButton>
</Space>
),
},
]}
loading={rulesLoading}
height={520}
rowKey="ruleId"
/>
</ProCard>
),
},
{
key: 'ledgers',
label: '원장',
children: (
<>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
onSearch={(v) => setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })}
onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<SettlingSupportLedgerRow>
rows={ledgers}
columns={LEDGER_COLS}
loading={ledgersLoading}
height={520}
rowKey="ledgerId"
pinnedBottomRow={pinnedLedger}
/>
</ProCard>
</>
),
},
]}
/>
<Modal
title={editingRule ? '룰 수정' : '룰 등록'}
open={ruleModalOpen}
onOk={handleSave}
onCancel={() => setRuleModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="agentGrade" label="설계사등급" rules={[{ required: true }]}>
<Input placeholder="예: ENTRY, SENIOR" />
</Form.Item>
<Form.Item name="supportAmount" label="월 지원금" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="durationMonths" label="지원기간(월)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
<Form.Item name="clawbackMonths" label="환수기준(월)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="effectiveFrom" label="적용시작" rules={[{ required: true }]}>
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="effectiveTo" label="적용종료">
<Input placeholder="YYYY-MM-DD" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -0,0 +1,145 @@
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, JointContractRow } 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 ROLE_LABEL: Record<string, string> = { LEAD: '주설계사', SUB: '공동설계사' };
const ROLE_COLOR: Record<string, string> = { LEAD: 'blue', SUB: 'default' };
const COLS: ColDef<JointContractRow>[] = [
{ field: 'contractNo', headerName: '계약번호', width: 160 },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'role', headerName: '역할', width: 120,
cellRenderer: (p: { value: string }) => (
<Tag color={ROLE_COLOR[p.value] ?? 'default'}>{ROLE_LABEL[p.value] ?? p.value}</Tag>
) },
{ field: 'shareRate', headerName: '지분율(%)', width: 120, type: 'numericColumn',
valueFormatter: (p) => `${((p.value as number) * 100).toFixed(2)}%` },
{ field: 'createdAt', headerName: '등록일', flex: 1 },
];
export default function JointContract() {
const qc = useQueryClient();
const [searchParams, setSearchParams] = useState<{ contractNo?: string; agentName?: string }>({});
const [modalOpen, setModalOpen] = useState(false);
const [editingRow, setEditingRow] = useState<JointContractRow | null>(null);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['joint-contract', 'list', searchParams],
queryFn: () => operationApi.jointContractList(searchParams),
retry: false,
});
const rows = data?.list ?? [];
const openCreate = () => { setEditingRow(null); form.resetFields(); setModalOpen(true); };
const openEdit = (row: JointContractRow) => { setEditingRow(row); form.setFieldsValue({ ...row, shareRate: (row.shareRate as number) * 100 }); setModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
// shareRate: UI는 % 단위 → 백엔드는 0~1
const body = { ...values, shareRate: (values.shareRate as number) / 100 };
try {
if (editingRow) {
await operationApi.jointContractUpdate(editingRow.jointId, body);
message.success('수정 완료');
} else {
await operationApi.jointContractCreate(body);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['joint-contract', 'list'] });
setModalOpen(false);
} catch (err: unknown) {
// E411: 지분율 합계 초과
const e = err as { code?: string; message?: string };
if (e?.code === 'E411') {
message.error(e.message ?? '지분율 합계가 1을 초과합니다');
}
}
};
const handleDelete = (id: number) => Modal.confirm({
title: '공동계약 삭제', content: '삭제하시겠습니까?',
onOk: async () => {
await operationApi.jointContractDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['joint-contract', 'list'] });
},
});
const actionCol: ColDef<JointContractRow> = {
headerName: '액션', width: 160, pinned: 'right',
cellRenderer: (p: { data: JointContractRow }) => (
<Space>
<PermissionButton menuCode="JOINT_CONTRACT" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="JOINT_CONTRACT" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.jointId)}></PermissionButton>
</Space>
),
};
return (
<PageContainer title="공동계약" description="공동 모집 계약 지분율 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/joint-contracts 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'text', name: 'contractNo', label: '계약번호', span: 6 },
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
]}
onSearch={(v) => setSearchParams({ contractNo: v.contractNo as string, agentName: v.agentName as string })}
onReset={() => setSearchParams({})}
/>
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="JOINT_CONTRACT" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<JointContractRow>
rows={rows}
columns={[...COLS, actionCol]}
loading={isLoading}
height={520}
rowKey="jointId"
/>
</ProCard>
<Modal
title={editingRow ? '공동계약 수정' : '공동계약 등록'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="contractId" label="계약ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
<Form.Item name="agentId" label="설계사ID" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={1} />
</Form.Item>
<Form.Item name="role" label="역할" rules={[{ required: true }]}>
<Input placeholder="LEAD / SUB" />
</Form.Item>
<Form.Item name="shareRate" label="지분율(%)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.01} addonAfter="%" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -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>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useState } from 'react';
import { Alert, Col, Form, Input, InputNumber, Modal, Row, Space, Statistic, 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, HqSettleRow } from '@/api/operation';
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR } 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 COLS: ColDef<HqSettleRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{ field: 'itemType', headerName: '항목유형', width: 100,
valueFormatter: (p) => p.value === 'INCOME' ? '수입' : '비용' },
{ field: 'itemName', headerName: '항목명', flex: 1 },
{ field: 'amount', headerName: '금액', width: 160, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'memo', headerName: '메모', flex: 1 },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function HqSettle() {
const qc = useQueryClient();
const [settleMonth, setSettleMonth] = useState(dayjs().format('YYYYMM'));
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<HqSettleRow | null>(null);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['hq-settle', 'list', settleMonth],
queryFn: () => operationApi.hqSettleList({ settleMonth }),
retry: false,
});
const { data: summary } = useQuery({
queryKey: ['hq-settle', 'summary', settleMonth],
queryFn: () => operationApi.hqSettleSummary(settleMonth),
retry: false,
enabled: !!settleMonth,
});
const rows = data?.list ?? [];
const openCreate = () => { setEditingItem(null); form.resetFields(); setModalOpen(true); };
const openEdit = (row: HqSettleRow) => { setEditingItem(row); form.setFieldsValue(row); setModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingItem) {
await operationApi.hqSettleUpdate(editingItem.itemId, values);
message.success('수정 완료');
} else {
await operationApi.hqSettleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['hq-settle'] });
setModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '항목 삭제',
content: '삭제하시겠습니까?',
onOk: async () => {
await operationApi.hqSettleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['hq-settle'] });
},
});
const actionCol: ColDef<HqSettleRow> = {
headerName: '액션',
width: 160,
pinned: 'right',
cellRenderer: (p: { data: HqSettleRow }) => (
<Space>
<PermissionButton menuCode="HQ_SETTLE" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="HQ_SETTLE" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.itemId)}></PermissionButton>
</Space>
),
};
return (
<PageContainer title="본사정산" description="GA 본사 수입/비용 정산 항목 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/hq-settles 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
initialValues={{ settleMonth: dayjs() }}
onSearch={(v) => setSettleMonth(v.settleMonth as string ?? dayjs().format('YYYYMM'))}
onReset={() => setSettleMonth(dayjs().format('YYYYMM'))}
/>
{/* 손익 요약 카드 */}
{summary && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<ProCard style={cardStyle}>
<Statistic title="총 수입" value={summary.incomeTotal} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_SUCCESS }} />
</ProCard>
</Col>
<Col span={8}>
<ProCard style={cardStyle}>
<Statistic title="총 비용" value={summary.expenseTotal} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_ERROR }} />
</ProCard>
</Col>
<Col span={8}>
<ProCard style={cardStyle}>
<Statistic
title="손익"
value={summary.profitLoss}
formatter={(v) => (v as number).toLocaleString()}
suffix="원"
valueStyle={{ color: summary.profitLoss >= 0 ? COLOR_SUCCESS : COLOR_ERROR }}
/>
</ProCard>
</Col>
</Row>
)}
<Tabs
defaultActiveKey="items"
items={[
{
key: 'items',
label: '정산 항목',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="HQ_SETTLE" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<HqSettleRow>
rows={rows}
columns={[...COLS, actionCol]}
loading={isLoading}
height={520}
rowKey="itemId"
pinnedBottomRow={rows.length > 0 ? {
itemName: '합계',
amount: rows.reduce((s, r) => s + (r.amount ?? 0), 0),
} as Partial<HqSettleRow> : undefined}
/>
</ProCard>
),
},
]}
/>
<Modal
title={editingItem ? '항목 수정' : '항목 등록'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
<Input placeholder="202506" />
</Form.Item>
<Form.Item name="itemType" label="항목유형" rules={[{ required: true }]}>
<Input placeholder="INCOME / EXPENSE" />
</Form.Item>
<Form.Item name="itemName" label="항목명" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="amount" label="금액" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="memo" label="메모">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Alert, Button, Form, Input, 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 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, StepCloseRow } 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 STATUS_COLOR: Record<string, string> = { OPEN: 'green', CLOSED: 'red' };
const STATUS_LABEL: Record<string, string> = { OPEN: '열림', CLOSED: '마감' };
const STEP_LABEL: Record<string, string> = { RECEIVE: '수신마감', CALC: '계산마감', VERIFY: '검증마감', PAY: '지급마감' };
const COLS: ColDef<StepCloseRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{ field: 'stepSeq', headerName: '순서', width: 70, type: 'numericColumn' },
{ field: 'stepCode', headerName: '단계코드', width: 120,
valueFormatter: (p) => STEP_LABEL[p.value as string] ?? p.value },
{ field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) => (
<Tag color={STATUS_COLOR[p.value] ?? 'default'}>{STATUS_LABEL[p.value] ?? p.value}</Tag>
) },
{ field: 'closedAt', headerName: '마감일시', flex: 1 },
];
export default function StepClose() {
const qc = useQueryClient();
const [searchParams, setSearchParams] = useState<{ settleMonth?: string }>({
settleMonth: dayjs().format('YYYYMM'),
});
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['step-close', 'list', searchParams],
queryFn: () => operationApi.stepCloseList(searchParams),
retry: false,
});
const rows = data?.list ?? [];
const handleClose = (id: number) => Modal.confirm({
title: '단계 마감',
content: '해당 단계를 마감하시겠습니까?',
onOk: async () => {
await operationApi.stepCloseClose(id);
message.success('마감 완료');
qc.invalidateQueries({ queryKey: ['step-close', 'list'] });
},
});
const handleReopen = (id: number) => Modal.confirm({
title: '단계 재오픈',
content: '해당 단계를 재오픈하시겠습니까?',
onOk: async () => {
await operationApi.stepCloseReopen(id);
message.success('재오픈 완료');
qc.invalidateQueries({ queryKey: ['step-close', 'list'] });
},
});
const handleCreate = async () => {
const values = await form.validateFields();
try {
await operationApi.stepCloseCreate(values);
message.success('등록 완료');
qc.invalidateQueries({ queryKey: ['step-close', 'list'] });
setCreateOpen(false);
form.resetFields();
} catch { /* request.ts 처리 */ }
};
const actionCol: ColDef<StepCloseRow> = {
headerName: '액션',
width: 160,
pinned: 'right',
cellRenderer: (p: { data: StepCloseRow }) => (
<Space>
{p.data.status === 'OPEN' ? (
<PermissionButton
menuCode="STEP_CLOSE"
permCode="APPROVE"
size="small"
type="primary"
onClick={() => handleClose(p.data.stepCloseId)}
>
</PermissionButton>
) : (
<PermissionButton
menuCode="STEP_CLOSE"
permCode="APPROVE"
size="small"
onClick={() => handleReopen(p.data.stepCloseId)}
>
</PermissionButton>
)}
</Space>
),
};
return (
<PageContainer title="단계마감" description="월 정산 단계별 마감 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/step-closes 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
initialValues={{ settleMonth: dayjs() }}
onSearch={(v) => setSearchParams({ settleMonth: v.settleMonth as string })}
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM') })}
/>
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="STEP_CLOSE" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
</div>
<DataGrid<StepCloseRow>
rows={rows}
columns={[...COLS, actionCol]}
loading={isLoading}
height={520}
rowKey="stepCloseId"
/>
</ProCard>
<Modal title="단계 등록" open={createOpen} onOk={handleCreate} onCancel={() => { setCreateOpen(false); form.resetFields(); }} okText="저장" cancelText="취소">
<Form form={form} layout="vertical">
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
<Input placeholder="202506" />
</Form.Item>
<Form.Item name="stepCode" label="단계코드" rules={[{ required: true }]}>
<Input placeholder="RECEIVE / CALC / VERIFY / PAY" />
</Form.Item>
<Form.Item name="stepSeq" label="순서" rules={[{ required: true }]}>
<Input type="number" placeholder="1~4" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}