From 98eb231950718691d0b1752202108f8c7c0a852e Mon Sep 17 00:00:00 2001 From: GA Pro Date: Sat, 9 May 2026 22:38:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20MappingEngine=20+=20Override=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20+=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=20=EC=A3=BC=EC=9A=94=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - MappingEngine: company_field_mapping 기반 동적 변환 구현 - raw_content(JSON) → 표준 원장 (TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP) - policy_no 로 contract 매칭 → contract_id/agent_id 자동 - 실패 시 parse_error_log 자동 적재 - Recruit/Maintain Mapper.sumByAgentMonth 추가 - OrganizationMapper.selectAncestorIds (CTE 재귀 상향) 추가 - CalcOverrideStep 실제 동작: * lower agent 의 모집+유지 합계를 base 로 사용 * 자기 조직 → 상위 조직 순으로 to_grade 직급 ACTIVE 설계사 탐색 * (lowerOrgId, toGrade) 캐싱 Frontend (주요 화면): - 공통 컴포넌트: * CodeSelect (공통코드 드롭다운, React Query 캐시) * CodeBadge (상태 색상 자동 매핑) * MoneyText (천단위 콤마, 음수 빨강, 부호 옵션) * PermissionButton (권한 없으면 미렌더) * SearchForm (조건 배열 → 자동 폼) * ExcelExportButton (blob 다운로드) * PageContainer - 훅: useCommonCodes, usePermission (메뉴별 boolean) - API 모듈: agent, contract, ledger, settle, code - 페이지: AgentList, ContractList, RecruitLedger, SettleList * SettleList: 월 요약 카드 + 확정/보류 액션 + 권한 체크 - 라우터: /org/agents, /contracts, /ledger/recruit, /settle V16 fix: - PAYMENT 메뉴는 V12 에서 level1 directory 로 생성됨 → V16 에서 PAGE 로 UPDATE 후 권한 매트릭스 적용 (자식으로 또 만들면 unique 충돌) --- .../settlement/step/CalcOverrideStep.java | 88 +++++-- .../settlement/transform/MappingEngine.java | 233 +++++++++++++++++- .../db/migration/V16__관리자_초기데이터.sql | 8 +- .../mapper/ledger/MaintainLedgerMapper.java | 2 + .../mapper/ledger/RecruitLedgerMapper.java | 3 + .../core/mapper/org/OrganizationMapper.java | 2 + .../mapper/ledger/MaintainLedgerMapper.xml | 5 + .../mapper/ledger/RecruitLedgerMapper.xml | 5 + .../mapper/org/OrganizationMapper.xml | 11 + ga-frontend/src/App.tsx | 9 +- ga-frontend/src/api/agent.ts | 50 ++++ ga-frontend/src/api/code.ts | 14 ++ ga-frontend/src/api/contract.ts | 40 +++ ga-frontend/src/api/ledger.ts | 47 ++++ ga-frontend/src/api/settle.ts | 66 +++++ .../src/components/common/CodeBadge.tsx | 23 ++ .../src/components/common/CodeSelect.tsx | 17 ++ .../components/common/ExcelExportButton.tsx | 36 +++ .../src/components/common/MoneyText.tsx | 20 ++ .../src/components/common/PageContainer.tsx | 23 ++ .../components/common/PermissionButton.tsx | 21 ++ .../src/components/common/SearchForm.tsx | 90 +++++++ ga-frontend/src/hooks/useCommonCodes.ts | 15 ++ ga-frontend/src/hooks/usePermission.ts | 45 ++++ .../src/pages/ledger/RecruitLedger.tsx | 74 ++++++ ga-frontend/src/pages/org/AgentList.tsx | 79 ++++++ .../src/pages/product/ContractList.tsx | 66 +++++ ga-frontend/src/pages/settle/SettleList.tsx | 112 +++++++++ 28 files changed, 1165 insertions(+), 39 deletions(-) create mode 100644 ga-frontend/src/api/agent.ts create mode 100644 ga-frontend/src/api/code.ts create mode 100644 ga-frontend/src/api/contract.ts create mode 100644 ga-frontend/src/api/ledger.ts create mode 100644 ga-frontend/src/api/settle.ts create mode 100644 ga-frontend/src/components/common/CodeBadge.tsx create mode 100644 ga-frontend/src/components/common/CodeSelect.tsx create mode 100644 ga-frontend/src/components/common/ExcelExportButton.tsx create mode 100644 ga-frontend/src/components/common/MoneyText.tsx create mode 100644 ga-frontend/src/components/common/PageContainer.tsx create mode 100644 ga-frontend/src/components/common/PermissionButton.tsx create mode 100644 ga-frontend/src/components/common/SearchForm.tsx create mode 100644 ga-frontend/src/hooks/useCommonCodes.ts create mode 100644 ga-frontend/src/hooks/usePermission.ts create mode 100644 ga-frontend/src/pages/ledger/RecruitLedger.tsx create mode 100644 ga-frontend/src/pages/org/AgentList.tsx create mode 100644 ga-frontend/src/pages/product/ContractList.tsx create mode 100644 ga-frontend/src/pages/settle/SettleList.tsx diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java index 313b931..d3745fe 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java @@ -2,11 +2,14 @@ package com.ga.batch.settlement.step; import com.ga.batch.settlement.SettlementContext; import com.ga.common.util.MoneyUtil; +import com.ga.core.mapper.ledger.MaintainLedgerMapper; +import com.ga.core.mapper.ledger.RecruitLedgerMapper; import com.ga.core.mapper.org.AgentMapper; import com.ga.core.mapper.org.OrganizationMapper; import com.ga.core.mapper.rule.RuleMapper; import com.ga.core.mapper.settle.OverrideSettleMapper; -import com.ga.core.vo.org.AgentVO; +import com.ga.core.vo.org.AgentResp; +import com.ga.core.vo.org.AgentSearchParam; import com.ga.core.vo.rule.OverrideRuleVO; import com.ga.core.vo.settle.OverrideSettleVO; import lombok.RequiredArgsConstructor; @@ -21,14 +24,18 @@ import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; /** - * Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산 + * Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산. * - * 단순화: 정산월의 모집+유지 합계 × override_pct → 상위 직급에게 분배. - * 실제로는 from_grade → to_grade 매트릭스로 다단계 분배해야 함. + * 정책: + * - 모든 ACTIVE 설계사를 순회하며 본인의 grade 와 일치하는 from_grade 규칙을 찾음 + * - 본인 조직 + 모든 상위 조직에서 to_grade 직급의 ACTIVE 설계사 1명을 찾아 분배 (가장 가까운 조직 우선) + * - base = 하위 설계사의 정산월 모집 + 유지 합계 + * - amount = base × override_pct (cap 적용) */ @Slf4j @Component @@ -41,6 +48,8 @@ public class CalcOverrideStep implements Tasklet { private final AgentMapper agentMapper; private final OrganizationMapper organizationMapper; private final OverrideSettleMapper overrideMapper; + private final RecruitLedgerMapper recruitMapper; + private final MaintainLedgerMapper maintainMapper; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { @@ -49,24 +58,31 @@ public class CalcOverrideStep implements Tasklet { overrideMapper.deleteBySettleMonth(ctx.getSettleMonth()); List rules = ruleMapper.selectOverrideRules(); - // 정산월의 활성 오버라이드 규칙만 (effective_from 기준) - LocalDate baseDate = LocalDate.parse(ctx.getSettleMonth().substring(0,4) + "-" + ctx.getSettleMonth().substring(4) + "-01"); + LocalDate baseDate = LocalDate.parse( + ctx.getSettleMonth().substring(0, 4) + "-" + + ctx.getSettleMonth().substring(4) + "-01"); rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate)); + rules.removeIf(r -> r.getEffectiveTo() != null && r.getEffectiveTo().isBefore(baseDate)); - List records = new ArrayList<>(); - // 단순화: 모든 ACTIVE 설계사를 조회하고, 본인의 grade 가 from_grade 인 규칙에 대해 상위 조직장 검색 - com.ga.core.vo.org.AgentSearchParam ap = new com.ga.core.vo.org.AgentSearchParam(); + AgentSearchParam ap = new AgentSearchParam(); ap.setStatus("ACTIVE"); ap.setPageSize(Integer.MAX_VALUE); - List agents = agentMapper.selectList(ap); + List agents = agentMapper.selectList(ap); - for (com.ga.core.vo.org.AgentResp lower : agents) { + Map upperCache = new HashMap<>(); + List records = new ArrayList<>(); + + for (AgentResp lower : agents) { for (OverrideRuleVO rule : rules) { - if (!java.util.Objects.equals(rule.getFromGrade(), lower.getGradeId())) continue; - Long upperAgentId = findUpperAgentByGrade(lower.getOrgId(), rule.getToGrade()); + if (!rule.getFromGrade().equals(lower.getGradeId())) continue; + + Long upperAgentId = findUpperAgentByGrade( + lower.getOrgId(), rule.getToGrade(), lower.getAgentId(), upperCache); if (upperAgentId == null) continue; BigDecimal base = sumLowerCommission(lower.getAgentId(), ctx.getSettleMonth()); + if (MoneyUtil.isZero(base)) continue; + BigDecimal amount = MoneyUtil.pct(base, rule.getOverridePct()); if (rule.getCapAmount() != null && amount.compareTo(rule.getCapAmount()) > 0) { amount = rule.getCapAmount(); @@ -89,22 +105,40 @@ public class CalcOverrideStep implements Tasklet { return RepeatStatus.FINISHED; } - /** 하위 설계사의 정산월 모집+유지 합계 (단순화: 모집 원장 합으로 대체) */ + /** 하위 설계사의 정산월 모집 + 유지 합계 */ private BigDecimal sumLowerCommission(Long agentId, String settleMonth) { - // 실제로는 recruit + maintain agent별 합계 필요. 여기서는 0 반환 (실제 운영 시 별도 sumByAgentMonth 추가) - return BigDecimal.ZERO; + BigDecimal r = recruitMapper.sumByAgentMonth(agentId, settleMonth); + BigDecimal m = maintainMapper.sumByAgentMonth(agentId, settleMonth); + return MoneyUtil.add(r, m); } - /** 동일 조직 트리에서 to_grade 직급의 상위 설계사 찾기 (단순화: org 트리 상위 첫 번째) */ - private Long findUpperAgentByGrade(Long orgId, Long toGrade) { - if (orgId == null) return null; - // 조직 트리 상향 탐색은 운영 단계에서 추가. 여기서는 동일 조직 내 toGrade 1명 반환. - com.ga.core.vo.org.AgentSearchParam p = new com.ga.core.vo.org.AgentSearchParam(); - p.setOrgId(orgId); - p.setGradeId(toGrade); - p.setStatus("ACTIVE"); - p.setPageSize(1); - List upper = agentMapper.selectList(p); - return upper.isEmpty() ? null : upper.get(0).getAgentId(); + /** + * 자기 조직부터 상향 탐색하며 to_grade 직급의 ACTIVE 설계사 1명 반환. + * 자기 자신은 제외. 캐시: (lowerOrgId, toGrade) → upperAgentId. + */ + private Long findUpperAgentByGrade(Long lowerOrgId, Long toGrade, Long lowerAgentId, + Map cache) { + if (lowerOrgId == null) return null; + String key = lowerOrgId + ":" + toGrade; + Long cached = cache.get(key); + if (cached != null) return cached.equals(0L) ? null : cached; + + List ancestorIds = organizationMapper.selectAncestorIds(lowerOrgId); + for (Long orgId : ancestorIds) { + AgentSearchParam p = new AgentSearchParam(); + p.setOrgId(orgId); + p.setGradeId(toGrade); + p.setStatus("ACTIVE"); + p.setPageSize(5); + List upper = agentMapper.selectList(p); + for (AgentResp a : upper) { + if (!a.getAgentId().equals(lowerAgentId)) { + cache.put(key, a.getAgentId()); + return a.getAgentId(); + } + } + } + cache.put(key, 0L); + return null; } } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java b/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java index 8a05f92..2f47979 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java @@ -1,28 +1,241 @@ package com.ga.batch.settlement.transform; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ga.common.util.MoneyUtil; +import com.ga.core.mapper.ledger.MaintainLedgerMapper; +import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.mapper.receive.ReceiveMapper; +import com.ga.core.vo.ledger.LedgerVO; +import com.ga.core.vo.product.ContractSearchParam; +import com.ga.core.vo.product.ContractVO; +import com.ga.core.vo.receive.CompanyFieldMappingVO; +import com.ga.core.vo.receive.ParseErrorLogVO; import com.ga.core.vo.receive.RawCommissionDataVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** - * 보험사 raw 데이터 → 표준 원장(recruit_ledger / maintain_ledger / exception_ledger) 으로 변환. + * raw_commission_data → 표준 원장(recruit_ledger / maintain_ledger) 변환. * - * 본 구현은 단순화된 stub. 실제로는: - * - company_field_mapping 으로 raw_content(JSON 등) 에서 필드를 추출 - * - code_mapping 으로 보험사 코드 → 표준 코드 변환 - * - data_type 에 따라 모집/유지/예외 원장 선택 + * 동작: + * 1. company_field_mapping 의 (source_field → target_field) 규칙 로드 + * 2. raw_content (JSON) 에서 source_field 값을 추출 + * 3. transform_rule 적용 (TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP) + * 4. policy_no 로 contract 매칭 → contract_id, agent_id 채움 + * 5. data_type 에 따라 recruit_ledger / maintain_ledger 에 INSERT + * + * 실패 시 parse_error_log 에 기록. */ @Slf4j @Component @RequiredArgsConstructor public class MappingEngine { - /** 변환 후 ledger_id 반환 (없으면 null) */ + private final ReceiveMapper receiveMapper; + private final ContractMapper contractMapper; + private final RecruitLedgerMapper recruitMapper; + private final MaintainLedgerMapper maintainMapper; + private final ObjectMapper objectMapper; + + @Transactional public Long transform(RawCommissionDataVO raw) { - // TODO: 실제 매핑 로직 — company_field_mapping 기반 동적 변환 - // 현재는 ID 미지정으로 처리 (Step4/5 에서 contract 기반으로 재계산) - log.debug("transform raw={}, type={}", raw.getRawId(), raw.getDataType()); - return null; + if (raw.getCompanyCode() == null || raw.getRawContent() == null) { + saveError(raw, "INVALID_INPUT", null, null, "company_code 또는 raw_content 누락"); + return null; + } + + String targetTable = resolveTargetTable(raw.getDataType()); + if (targetTable == null) { + saveError(raw, "UNKNOWN_TYPE", "data_type", raw.getDataType(), + "지원하지 않는 data_type: " + raw.getDataType()); + return null; + } + + List mappings = receiveMapper.selectFieldMappings( + raw.getCompanyCode(), targetTable); + if (mappings.isEmpty()) { + saveError(raw, "NO_MAPPING", null, null, + "매핑 정의 없음: " + raw.getCompanyCode() + "." + targetTable); + return null; + } + + JsonNode root; + try { + root = objectMapper.readTree(raw.getRawContent()); + } catch (Exception e) { + saveError(raw, "JSON_PARSE", null, null, "raw_content JSON 파싱 실패: " + e.getMessage()); + return null; + } + + Map mapped = new HashMap<>(); + for (CompanyFieldMappingVO m : mappings) { + JsonNode v = root.get(m.getSourceField()); + String src = (v == null || v.isNull()) ? null : v.asText(); + try { + Object converted = applyTransform(src, m, raw.getCompanyCode()); + if (converted == null && "Y".equals(m.getIsRequired())) { + saveError(raw, "REQUIRED", m.getTargetField(), src, + "필수 항목 누락: " + m.getSourceField()); + return null; + } + mapped.put(m.getTargetField(), converted); + } catch (Exception e) { + saveError(raw, "TRANSFORM_FAIL", m.getTargetField(), src, e.getMessage()); + return null; + } + } + + String policyNo = (String) mapped.get("policy_no"); + if (policyNo == null) { + saveError(raw, "NO_POLICY", "policy_no", null, "policy_no 누락"); + return null; + } + ContractVO contract = findContractByPolicyNo(policyNo); + if (contract == null) { + saveError(raw, "CONTRACT_NOT_FOUND", "policy_no", policyNo, + "계약을 찾을 수 없습니다: " + policyNo); + return null; + } + + LedgerVO led = buildLedger(mapped, contract, raw); + if ("recruit_ledger".equals(targetTable)) { + recruitMapper.insert(led); + } else { + maintainMapper.insert(led); + } + return led.getLedgerId(); + } + + private String resolveTargetTable(String dataType) { + if (dataType == null) return null; + return switch (dataType.toUpperCase()) { + case "RECRUIT" -> "recruit_ledger"; + case "MAINTAIN" -> "maintain_ledger"; + default -> null; + }; + } + + private Object applyTransform(String value, CompanyFieldMappingVO m, String companyCode) { + if (value == null) { + return convertType(m.getDefaultValue(), m.getDataType()); + } + String trimmed = value.trim(); + String rule = m.getTransformRule(); + if (rule == null || rule.isBlank()) { + return convertType(trimmed, m.getDataType()); + } + return switch (rule.toUpperCase()) { + case "TRIM" -> convertType(trimmed, m.getDataType()); + case "UPPER" -> trimmed.toUpperCase(); + case "LOWER" -> trimmed.toLowerCase(); + case "DATE" -> parseDate(trimmed, m.getDefaultValue()); + case "DIVIDE" -> divideOrMultiply(trimmed, m.getDefaultValue(), true); + case "MULTIPLY" -> divideOrMultiply(trimmed, m.getDefaultValue(), false); + case "CODE_MAP" -> { + String mappedCode = receiveMapper.findTargetCode(companyCode, m.getDefaultValue(), trimmed); + yield mappedCode != null ? mappedCode : trimmed; + } + default -> convertType(trimmed, m.getDataType()); + }; + } + + private Object convertType(String value, String dataType) { + if (value == null) return null; + if (dataType == null) return value; + return switch (dataType.toUpperCase()) { + case "STRING" -> value; + case "NUMBER", "DECIMAL" -> new BigDecimal(value.replace(",", "")); + case "INT", "INTEGER" -> Integer.parseInt(value.replace(",", "")); + case "LONG" -> Long.parseLong(value.replace(",", "")); + case "DATE" -> parseDate(value, null); + default -> value; + }; + } + + private LocalDate parseDate(String s, String pattern) { + if (s == null || s.isBlank()) return null; + try { + DateTimeFormatter fmt = (pattern != null && !pattern.isBlank()) + ? DateTimeFormatter.ofPattern(pattern) + : DateTimeFormatter.ofPattern("yyyyMMdd"); + return LocalDate.parse(s, fmt); + } catch (Exception ignored) { + try { return LocalDate.parse(s); } catch (Exception e) { return null; } + } + } + + private BigDecimal divideOrMultiply(String value, String factor, boolean divide) { + BigDecimal num = new BigDecimal(value.replace(",", "")); + BigDecimal f = new BigDecimal(factor != null && !factor.isBlank() ? factor : "1"); + return divide ? num.divide(f, 2, RoundingMode.HALF_UP) : num.multiply(f); + } + + private ContractVO findContractByPolicyNo(String policyNo) { + ContractSearchParam p = new ContractSearchParam(); + p.setPolicyNo(policyNo); + p.setPageSize(1); + var list = contractMapper.selectList(p); + if (list.isEmpty()) return null; + return contractMapper.selectById(list.get(0).getContractId()); + } + + private LedgerVO buildLedger(Map mapped, ContractVO contract, RawCommissionDataVO raw) { + LedgerVO led = new LedgerVO(); + led.setContractId(contract.getContractId()); + led.setAgentId(contract.getAgentId()); + led.setPolicyNo(contract.getPolicyNo()); + led.setCompanyCode(raw.getCompanyCode()); + led.setProductCode((String) mapped.get("product_code")); + led.setInsuranceType((String) mapped.getOrDefault("insurance_type", "WHOLE_LIFE")); + led.setCommissionYear(toInt(mapped.get("commission_year"), 1)); + led.setPremium(toBd(mapped.get("premium"), contract.getPremium())); + led.setPayMethod((String) mapped.getOrDefault("pay_method", contract.getPayCycle())); + led.setCompanyRate(toBd(mapped.get("company_rate"), BigDecimal.ZERO)); + led.setCompanyAmount(toBd(mapped.get("company_amount"), BigDecimal.ZERO)); + led.setPayoutRate(toBd(mapped.get("payout_rate"), BigDecimal.ZERO)); + led.setAgentAmount(toBd(mapped.get("agent_amount"), BigDecimal.ZERO)); + led.setTaxAmount(MoneyUtil.tax(led.getAgentAmount())); + led.setSettleMonth(raw.getSettleMonth()); + led.setStatus("CALCULATED"); + led.setReconcileStatus("PENDING"); + return led; + } + + private BigDecimal toBd(Object v, BigDecimal fallback) { + if (v == null) return fallback; + if (v instanceof BigDecimal bd) return bd; + try { return new BigDecimal(v.toString()); } catch (Exception e) { return fallback; } + } + + private int toInt(Object v, int fallback) { + if (v == null) return fallback; + if (v instanceof Integer i) return i; + try { return Integer.parseInt(v.toString()); } catch (Exception e) { return fallback; } + } + + private void saveError(RawCommissionDataVO raw, String type, String field, String value, String msg) { + log.warn("MappingEngine error rawId={} type={} field={} value={}: {}", + raw.getRawId(), type, field, value, msg); + ParseErrorLogVO err = new ParseErrorLogVO(); + err.setRawId(raw.getRawId()); + err.setCompanyCode(raw.getCompanyCode()); + err.setErrorType(type); + err.setErrorField(field); + err.setErrorValue(value); + err.setErrorMessage(msg); + err.setResolveStatus("OPEN"); + receiveMapper.insertError(err); } } diff --git a/ga-common/src/main/resources/db/migration/V16__관리자_초기데이터.sql b/ga-common/src/main/resources/db/migration/V16__관리자_초기데이터.sql index 70f4b67..b892149 100644 --- a/ga-common/src/main/resources/db/migration/V16__관리자_초기데이터.sql +++ b/ga-common/src/main/resources/db/migration/V16__관리자_초기데이터.sql @@ -1,6 +1,13 @@ -- V16: 관리자 계정 + 메뉴 상세 + 권한 매트릭스 초기화 -- admin 계정 비밀번호: admin1234! (BCrypt $2b$10) +-- 0. PAYMENT 메뉴는 V12에서 level 1 directory 로 생성되었으나 +-- PaymentController 에서 menu="PAYMENT" 로 직접 권한 체크하므로 PAGE 로 변경. +UPDATE menu SET menu_type = 'PAGE', + menu_path = '/payments', + component_path = 'settle/PaymentList' +WHERE menu_code = 'PAYMENT'; + -- 1. 자식 메뉴 (V10에서 정의된 menu_code 와 컨트롤러 @RequirePermission 매칭) WITH parent AS ( SELECT menu_id, menu_code FROM menu WHERE menu_level = 1 @@ -32,7 +39,6 @@ JOIN (VALUES -- 정산/지급 ('SETTLE', 'SETTLE_LIST', '정산 결과', '/settle', 'settle/SettleList', 10), ('SETTLE', 'BATCH_RUN', '정산 배치', '/settle/batch', 'settle/BatchRun', 20), - ('PAYMENT', 'PAYMENT', '지급 관리', '/payments', 'settle/PaymentList', 10), -- 시스템 ('SYSTEM', 'SYSTEM_USER', '사용자 관리', '/system/users', 'system/UserList', 10), ('SYSTEM', 'SYSTEM_ROLE', '역할 관리', '/system/roles', 'system/RoleList', 20), diff --git a/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java index f71685f..55a1903 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java @@ -24,4 +24,6 @@ public interface MaintainLedgerMapper { int deleteBySettleMonth(@Param("settleMonth") String settleMonth); List> aggregateByAgent(@Param("settleMonth") String settleMonth); BigDecimal sumByMonth(@Param("settleMonth") String settleMonth); + BigDecimal sumByAgentMonth(@Param("agentId") Long agentId, + @Param("settleMonth") String settleMonth); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java index 65b34c4..6906ea8 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java @@ -27,4 +27,7 @@ public interface RecruitLedgerMapper { List> aggregateByAgent(@Param("settleMonth") String settleMonth); /** 정산월 합계 */ BigDecimal sumByMonth(@Param("settleMonth") String settleMonth); + /** 정산월 + 설계사 합계 */ + BigDecimal sumByAgentMonth(@Param("agentId") Long agentId, + @Param("settleMonth") String settleMonth); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/org/OrganizationMapper.java b/ga-core/src/main/java/com/ga/core/mapper/org/OrganizationMapper.java index ec76451..813e3d0 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/org/OrganizationMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/org/OrganizationMapper.java @@ -16,6 +16,8 @@ public interface OrganizationMapper { OrganizationVO selectById(@Param("orgId") Long orgId); /** 자기 자신 + 모든 하위 조직 ID (CTE 재귀) */ List selectDescendantIds(@Param("orgId") Long orgId); + /** 자기 자신 + 모든 상위 조직 ID (root 까지, 가까운 순) */ + List selectAncestorIds(@Param("orgId") Long orgId); int insert(OrganizationVO vo); int update(OrganizationVO vo); int deleteById(@Param("orgId") Long orgId); diff --git a/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml b/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml index 1309bfb..785cb38 100644 --- a/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml +++ b/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml @@ -107,4 +107,9 @@ SELECT COALESCE(SUM(agent_amount), 0) FROM maintain_ledger WHERE settle_month = #{settleMonth} + + diff --git a/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml b/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml index 31fc5ac..9a003bd 100644 --- a/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml +++ b/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml @@ -124,4 +124,9 @@ SELECT COALESCE(SUM(agent_amount), 0) FROM recruit_ledger WHERE settle_month = #{settleMonth} + + diff --git a/ga-core/src/main/resources/mapper/org/OrganizationMapper.xml b/ga-core/src/main/resources/mapper/org/OrganizationMapper.xml index 587a795..7742af8 100644 --- a/ga-core/src/main/resources/mapper/org/OrganizationMapper.xml +++ b/ga-core/src/main/resources/mapper/org/OrganizationMapper.xml @@ -49,6 +49,17 @@ SELECT org_id FROM descendants + + + INSERT INTO organization (parent_org_id, org_name, org_type, org_level, region, effective_from, effective_to, is_active, created_at, created_by) diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 7bb12fa..8b15052 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -2,6 +2,10 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import LoginPage from '@/pages/LoginPage'; import MainLayout from '@/layouts/MainLayout'; import Dashboard from '@/pages/Dashboard'; +import AgentList from '@/pages/org/AgentList'; +import ContractList from '@/pages/product/ContractList'; +import RecruitLedger from '@/pages/ledger/RecruitLedger'; +import SettleList from '@/pages/settle/SettleList'; function RequireAuth({ children }: { children: React.ReactNode }) { const token = localStorage.getItem('accessToken'); @@ -14,7 +18,10 @@ export default function App() { } /> }> } /> - {/* TODO: 추가 페이지는 동적 라우팅으로 확장 */} + } /> + } /> + } /> + } /> ); diff --git a/ga-frontend/src/api/agent.ts b/ga-frontend/src/api/agent.ts new file mode 100644 index 0000000..d3f84af --- /dev/null +++ b/ga-frontend/src/api/agent.ts @@ -0,0 +1,50 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface AgentResp { + agentId: number; + agentName: string; + licenseNo?: string; + phone?: string; + email?: string; + status: string; + statusName?: string; + orgId?: number; + orgName?: string; + gradeId?: number; + gradeName?: string; + joinDate?: string; + contractCount?: number; + totalCommission?: number; +} + +export interface AgentSearchParam { + pageNum?: number; + pageSize?: number; + searchKeyword?: string; + status?: string; + orgId?: number; + gradeId?: number; +} + +export interface AgentSaveReq { + agentName: string; + orgId: number; + gradeId: number; + residentNo?: string; + licenseNo?: string; + phone?: string; + email?: string; + bankCode?: string; + accountNo?: string; + joinDate?: string; +} + +export const agentApi = { + list: (p: AgentSearchParam) => + unwrap>(api.get('/api/agents', { params: p })), + detail: (id: number) => unwrap(api.get(`/api/agents/${id}`)), + create: (req: AgentSaveReq) => unwrap(api.post('/api/agents', req)), + update: (id: number, req: AgentSaveReq) => unwrap(api.put(`/api/agents/${id}`, req)), + changeStatus: (id: number, status: string) => + unwrap(api.put(`/api/agents/${id}/status`, { status })), +}; diff --git a/ga-frontend/src/api/code.ts b/ga-frontend/src/api/code.ts new file mode 100644 index 0000000..018b5f4 --- /dev/null +++ b/ga-frontend/src/api/code.ts @@ -0,0 +1,14 @@ +import api, { unwrap } from './request'; + +export interface CommonCode { + codeId: number; + groupCode: string; + code: string; + codeName: string; + sortOrder: number; +} + +export const codeApi = { + byGroup: (groupCode: string) => + unwrap(api.get(`/api/common/codes/${groupCode}`)), +}; diff --git a/ga-frontend/src/api/contract.ts b/ga-frontend/src/api/contract.ts new file mode 100644 index 0000000..8c10a22 --- /dev/null +++ b/ga-frontend/src/api/contract.ts @@ -0,0 +1,40 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface ContractResp { + contractId: number; + agentId: number; + agentName: string; + orgName?: string; + productCode?: string; + productName?: string; + companyCode?: string; + companyName?: string; + insuranceType?: string; + policyNo: string; + contractorName?: string; + insuredName?: string; + premium?: number; + payCycle?: string; + payCycleName?: string; + status: string; + statusName?: string; + contractDate: string; +} + +export interface ContractSearchParam { + pageNum?: number; + pageSize?: number; + searchKeyword?: string; + agentId?: number; + companyId?: number; + insuranceType?: string; + status?: string; + startDate?: string; + endDate?: string; +} + +export const contractApi = { + list: (p: ContractSearchParam) => + unwrap>(api.get('/api/contracts', { params: p })), + detail: (id: number) => unwrap(api.get(`/api/contracts/${id}`)), +}; diff --git a/ga-frontend/src/api/ledger.ts b/ga-frontend/src/api/ledger.ts new file mode 100644 index 0000000..a954413 --- /dev/null +++ b/ga-frontend/src/api/ledger.ts @@ -0,0 +1,47 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface LedgerResp { + ledgerId: number; + agentId: number; + agentName: string; + orgName?: string; + policyNo?: string; + contractorName?: string; + contractDate?: string; + companyCode?: string; + companyName?: string; + productCode?: string; + productName?: string; + insuranceType?: string; + premium?: number; + companyRate?: number; + companyAmount?: number; + payoutRate?: number; + agentAmount?: number; + taxAmount?: number; + reconcileStatus?: string; + settleMonth: string; + status: string; + statusName?: string; +} + +export interface LedgerSearchParam { + pageNum?: number; + pageSize?: number; + settleMonth?: string; + agentId?: number; + orgId?: number; + companyCode?: string; + insuranceType?: string; + policyNo?: string; + searchKeyword?: string; +} + +export const ledgerApi = { + recruit: (p: LedgerSearchParam) => + unwrap>(api.get('/api/ledger/recruit', { params: p })), + maintain: (p: LedgerSearchParam) => + unwrap>(api.get('/api/ledger/maintain', { params: p })), + exception: (p: LedgerSearchParam) => + unwrap>(api.get('/api/ledger/exception', { params: p })), +}; diff --git a/ga-frontend/src/api/settle.ts b/ga-frontend/src/api/settle.ts new file mode 100644 index 0000000..d9b0ac3 --- /dev/null +++ b/ga-frontend/src/api/settle.ts @@ -0,0 +1,66 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface SettleResp { + settleId: number; + agentId: number; + agentName: string; + orgName?: string; + gradeName?: string; + settleMonth: string; + recruitTotal?: number; + maintainTotal?: number; + exceptionPlus?: number; + exceptionMinus?: number; + overrideTotal?: number; + chargebackTotal?: number; + grossAmount?: number; + taxAmount?: number; + netAmount?: number; + status: string; + statusName?: string; + holdReason?: string; +} + +export interface SettleSearchParam { + pageNum?: number; + pageSize?: number; + settleMonth?: string; + agentId?: number; + orgId?: number; + status?: string; +} + +export const settleApi = { + list: (p: SettleSearchParam) => + unwrap>(api.get('/api/settle', { params: p })), + summary: (settleMonth: string) => + unwrap>(api.get('/api/settle/summary', { params: { settleMonth } })), + orgSummary: (settleMonth: string) => + unwrap>>(api.get('/api/settle/org-summary', { params: { settleMonth } })), + confirm: (settleId: number) => + unwrap(api.put(`/api/settle/${settleId}/confirm`)), + hold: (settleId: number, reason: string) => + unwrap(api.put(`/api/settle/${settleId}/hold`, { reason })), + release: (settleId: number) => + unwrap(api.put(`/api/settle/${settleId}/release`)), +}; + +export interface BatchJobLog { + jobId: number; + jobName: string; + settleMonth?: string; + status: string; + currentStep?: string; + progressPct?: number; + errorMessage?: string; + startedAt?: string; + finishedAt?: string; + durationSec?: number; +} + +export const batchApi = { + run: (settleMonth: string) => + unwrap(api.post('/api/batch/settlement/run', { settleMonth })), + status: () => + unwrap(api.get('/api/batch/status')), +}; diff --git a/ga-frontend/src/components/common/CodeBadge.tsx b/ga-frontend/src/components/common/CodeBadge.tsx new file mode 100644 index 0000000..5df2249 --- /dev/null +++ b/ga-frontend/src/components/common/CodeBadge.tsx @@ -0,0 +1,23 @@ +import { Tag } from 'antd'; +import { useCommonCodes } from '@/hooks/useCommonCodes'; + +interface Props { + groupCode: string; + value?: string; +} + +const COLOR_MAP: Record = { + ACTIVE: 'green', CONFIRMED: 'green', APPROVED: 'green', PAID: 'green', MATCHED: 'green', DONE: 'green', + PENDING: 'blue', CALCULATED: 'blue', SENT: 'blue', + HOLD: 'red', SUSPEND: 'red', REJECTED: 'red', FAIL: 'red', LAPSE: 'red', DIFF: 'red', LOCKED: 'red', + DRAFT: 'default', NEW: 'default', LEAVE: 'default', RETIRED: 'default', +}; + +export default function CodeBadge({ groupCode, value }: Props) { + const { data = [] } = useCommonCodes(groupCode); + if (!value) return null; + const code = data.find((c) => c.code === value); + const label = code?.codeName ?? value; + const color = COLOR_MAP[value] ?? 'default'; + return {label}; +} diff --git a/ga-frontend/src/components/common/CodeSelect.tsx b/ga-frontend/src/components/common/CodeSelect.tsx new file mode 100644 index 0000000..941f03e --- /dev/null +++ b/ga-frontend/src/components/common/CodeSelect.tsx @@ -0,0 +1,17 @@ +import { Select, SelectProps } from 'antd'; +import { useCommonCodes } from '@/hooks/useCommonCodes'; + +interface Props extends Omit { + groupCode: string; + includeAll?: boolean; + allLabel?: string; +} + +export default function CodeSelect({ groupCode, includeAll, allLabel = '전체', ...rest }: Props) { + const { data = [], isLoading } = useCommonCodes(groupCode); + const options = [ + ...(includeAll ? [{ value: '', label: allLabel }] : []), + ...data.map((c) => ({ value: c.code, label: c.codeName })), + ]; + return ; + case 'code': + return ; + case 'dateRange': + return ; + case 'month': + return ; + } +} diff --git a/ga-frontend/src/hooks/useCommonCodes.ts b/ga-frontend/src/hooks/useCommonCodes.ts new file mode 100644 index 0000000..50ad595 --- /dev/null +++ b/ga-frontend/src/hooks/useCommonCodes.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { codeApi } from '@/api/code'; + +/** + * 공통코드 그룹별 목록을 React Query 로 캐싱하여 반환. + * staleTime: 1시간 (서버 변경 후 페이지 새로고침이면 충분) + */ +export function useCommonCodes(groupCode: string) { + return useQuery({ + queryKey: ['commonCode', groupCode], + queryFn: () => codeApi.byGroup(groupCode), + staleTime: 60 * 60 * 1000, + enabled: !!groupCode, + }); +} diff --git a/ga-frontend/src/hooks/usePermission.ts b/ga-frontend/src/hooks/usePermission.ts new file mode 100644 index 0000000..79a1f2a --- /dev/null +++ b/ga-frontend/src/hooks/usePermission.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query'; +import { menuApi, MenuResp } from '@/api/menu'; +import { useMemo } from 'react'; + +export interface MenuPermissions { + canRead: boolean; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; + canApprove: boolean; + canExport: boolean; +} + +/** + * menuCode 별 사용자 권한 보유 여부. + * 메뉴 트리에서 해당 menuCode 의 permissions 배열을 평탄화하여 boolean 으로 반환. + */ +export function usePermission(menuCode: string): MenuPermissions { + const { data: tree = [] } = useQuery({ + queryKey: ['menu', 'my'], + queryFn: menuApi.myMenus, + staleTime: 5 * 60 * 1000, + }); + + return useMemo(() => { + const perms = findPermissions(tree, menuCode); + return { + canRead: perms.includes('READ'), + canCreate: perms.includes('CREATE'), + canUpdate: perms.includes('UPDATE'), + canDelete: perms.includes('DELETE'), + canApprove: perms.includes('APPROVE'), + canExport: perms.includes('EXPORT'), + }; + }, [tree, menuCode]); +} + +function findPermissions(nodes: MenuResp[], code: string): string[] { + for (const n of nodes) { + if (n.menuCode === code) return n.permissions ?? []; + const found = findPermissions(n.children ?? [], code); + if (found.length) return found; + } + return []; +} diff --git a/ga-frontend/src/pages/ledger/RecruitLedger.tsx b/ga-frontend/src/pages/ledger/RecruitLedger.tsx new file mode 100644 index 0000000..5cd1096 --- /dev/null +++ b/ga-frontend/src/pages/ledger/RecruitLedger.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; +import { Table } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import MoneyText from '@/components/common/MoneyText'; +import CodeBadge from '@/components/common/CodeBadge'; +import ExcelExportButton from '@/components/common/ExcelExportButton'; +import { ledgerApi, LedgerResp, LedgerSearchParam } from '@/api/ledger'; + +export default function RecruitLedger() { + const [param, setParam] = useState({ pageNum: 1, pageSize: 50 }); + + const { data, isLoading } = useQuery({ + queryKey: ['ledger', 'recruit', param], + queryFn: () => ledgerApi.recruit(param), + }); + + return ( + + } + > + setParam({ ...param, ...v, pageNum: 1 })} + onReset={() => setParam({ pageNum: 1, pageSize: 50 })} + /> + + + rowKey="ledgerId" + loading={isLoading} + dataSource={data?.list} + size="small" + scroll={{ x: 1500 }} + pagination={{ + current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true, + onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }), + }} + columns={[ + { title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' }, + { title: '증권번호', dataIndex: 'policyNo', width: 130, fixed: 'left' }, + { title: '설계사', dataIndex: 'agentName', width: 100 }, + { title: '소속', dataIndex: 'orgName', width: 160 }, + { title: '보험사', dataIndex: 'companyName', width: 120 }, + { title: '상품', dataIndex: 'productName', width: 200 }, + { title: '보험종류', dataIndex: 'insuranceType', width: 100, + render: (v) => }, + { title: '보험료', dataIndex: 'premium', width: 110, align: 'right', + render: (v) => }, + { title: '회사율', dataIndex: 'companyRate', width: 80, align: 'right', + render: (v) => }, + { title: '회사수수료', dataIndex: 'companyAmount', width: 120, align: 'right', + render: (v) => }, + { title: '지급율', dataIndex: 'payoutRate', width: 80, align: 'right', + render: (v) => }, + { title: '설계사수수료', dataIndex: 'agentAmount', width: 120, align: 'right', + render: (v) => }, + { title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', + render: (v) => }, + { title: '대사상태', dataIndex: 'reconcileStatus', width: 100, + render: (v) => }, + ]} + /> + + ); +} diff --git a/ga-frontend/src/pages/org/AgentList.tsx b/ga-frontend/src/pages/org/AgentList.tsx new file mode 100644 index 0000000..8874168 --- /dev/null +++ b/ga-frontend/src/pages/org/AgentList.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { Table } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import CodeBadge from '@/components/common/CodeBadge'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import ExcelExportButton from '@/components/common/ExcelExportButton'; +import { agentApi, AgentResp, AgentSearchParam } from '@/api/agent'; + +export default function AgentList() { + const navigate = useNavigate(); + const [param, setParam] = useState({ pageNum: 1, pageSize: 20 }); + + const { data, isLoading } = useQuery({ + queryKey: ['agent', 'list', param], + queryFn: () => agentApi.list(param), + }); + + return ( + + navigate('/org/agents/new')} style={{ marginRight: 8 }}> + 등록 + + + + } + > + setParam({ ...param, ...v, pageNum: 1 })} + onReset={() => setParam({ pageNum: 1, pageSize: 20 })} + /> + + + rowKey="agentId" + loading={isLoading} + dataSource={data?.list} + pagination={{ + current: param.pageNum, + pageSize: param.pageSize, + total: data?.total, + showSizeChanger: true, + onChange: (page, size) => setParam({ ...param, pageNum: page, pageSize: size }), + }} + onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })} + columns={[ + { title: '설계사명', dataIndex: 'agentName', width: 120 }, + { title: '사번', dataIndex: 'licenseNo', width: 120 }, + { title: '소속', dataIndex: 'orgName', width: 200 }, + { title: '직급', dataIndex: 'gradeName', width: 100 }, + { title: '전화번호', dataIndex: 'phone', width: 140 }, + { + title: '상태', dataIndex: 'status', width: 100, + render: (v) => , + }, + { + title: '계약수', dataIndex: 'contractCount', align: 'right', width: 90, + render: (v) => , + }, + { + title: '누적수수료', dataIndex: 'totalCommission', align: 'right', width: 130, + render: (v) => , + }, + { title: '입사일', dataIndex: 'joinDate', width: 110 }, + ]} + /> + + ); +} diff --git a/ga-frontend/src/pages/product/ContractList.tsx b/ga-frontend/src/pages/product/ContractList.tsx new file mode 100644 index 0000000..b319980 --- /dev/null +++ b/ga-frontend/src/pages/product/ContractList.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { Table } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import CodeBadge from '@/components/common/CodeBadge'; +import MoneyText from '@/components/common/MoneyText'; +import { contractApi, ContractResp, ContractSearchParam } from '@/api/contract'; + +export default function ContractList() { + const [param, setParam] = useState({ pageNum: 1, pageSize: 20 }); + + const { data, isLoading } = useQuery({ + queryKey: ['contract', 'list', param], + queryFn: () => contractApi.list(param), + }); + + return ( + + setParam({ ...param, ...v, pageNum: 1 })} + onReset={() => setParam({ pageNum: 1, pageSize: 20 })} + /> + + + rowKey="contractId" + loading={isLoading} + dataSource={data?.list} + pagination={{ + current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true, + onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }), + }} + columns={[ + { title: '증권번호', dataIndex: 'policyNo', width: 140 }, + { title: '계약자', dataIndex: 'contractorName', width: 100 }, + { title: '설계사', dataIndex: 'agentName', width: 100 }, + { title: '보험사', dataIndex: 'companyName', width: 120 }, + { title: '상품', dataIndex: 'productName', width: 220 }, + { + title: '보험종류', dataIndex: 'insuranceType', width: 100, + render: (v) => , + }, + { + title: '보험료', dataIndex: 'premium', width: 120, align: 'right', + render: (v) => , + }, + { + title: '납입주기', dataIndex: 'payCycle', width: 90, + render: (v) => , + }, + { + title: '상태', dataIndex: 'status', width: 90, + render: (v) => , + }, + { title: '계약일', dataIndex: 'contractDate', width: 110 }, + ]} + /> + + ); +} diff --git a/ga-frontend/src/pages/settle/SettleList.tsx b/ga-frontend/src/pages/settle/SettleList.tsx new file mode 100644 index 0000000..f373ece --- /dev/null +++ b/ga-frontend/src/pages/settle/SettleList.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { Card, Col, Row, Statistic, Table, message, Modal, Input, Space } from 'antd'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import CodeBadge from '@/components/common/CodeBadge'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import { settleApi, SettleResp, SettleSearchParam } from '@/api/settle'; + +export default function SettleList() { + const qc = useQueryClient(); + const [param, setParam] = useState({ pageNum: 1, pageSize: 30 }); + + const { data, isLoading } = useQuery({ + queryKey: ['settle', 'list', param], + queryFn: () => settleApi.list(param), + }); + + const { data: summary } = useQuery({ + queryKey: ['settle', 'summary', param.settleMonth], + queryFn: () => settleApi.summary(param.settleMonth!), + enabled: !!param.settleMonth, + }); + + const refresh = () => qc.invalidateQueries({ queryKey: ['settle'] }); + + const onConfirm = (id: number) => + Modal.confirm({ + title: '정산 확정', content: '확정 후에는 변경할 수 없습니다. 진행하시겠습니까?', + onOk: async () => { await settleApi.confirm(id); message.success('확정 완료'); refresh(); }, + }); + + const onHold = (id: number) => { + let reason = ''; + Modal.confirm({ + title: '정산 보류', + content: { reason = e.target.value; }} />, + onOk: async () => { await settleApi.hold(id, reason); message.success('보류 처리'); refresh(); }, + }); + }; + + return ( + + setParam({ ...param, ...v, pageNum: 1 })} + onReset={() => setParam({ pageNum: 1, pageSize: 30 })} + /> + + {summary && ( + + + + + + + )} + + + rowKey="settleId" + loading={isLoading} + dataSource={data?.list} + size="small" + scroll={{ x: 1600 }} + pagination={{ + current: param.pageNum, pageSize: param.pageSize, total: data?.total, showSizeChanger: true, + onChange: (p, s) => setParam({ ...param, pageNum: p, pageSize: s }), + }} + columns={[ + { title: '정산월', dataIndex: 'settleMonth', width: 90, fixed: 'left' }, + { title: '설계사', dataIndex: 'agentName', width: 100, fixed: 'left' }, + { title: '소속', dataIndex: 'orgName', width: 160 }, + { title: '직급', dataIndex: 'gradeName', width: 80 }, + { title: '모집', dataIndex: 'recruitTotal', width: 110, align: 'right', render: (v) => }, + { title: '유지', dataIndex: 'maintainTotal', width: 110, align: 'right', render: (v) => }, + { title: '예외(+)', dataIndex: 'exceptionPlus', width: 100, align: 'right', render: (v) => }, + { title: '예외(-)', dataIndex: 'exceptionMinus', width: 100, align: 'right', render: (v) => }, + { title: '오버라이드', dataIndex: 'overrideTotal', width: 110, align: 'right', render: (v) => }, + { title: '환수', dataIndex: 'chargebackTotal', width: 100, align: 'right', + render: (v) => }, + { title: '총액', dataIndex: 'grossAmount', width: 120, align: 'right', render: (v) => }, + { title: '세액', dataIndex: 'taxAmount', width: 100, align: 'right', render: (v) => }, + { title: '실지급', dataIndex: 'netAmount', width: 130, align: 'right', + render: (v) => }, + { title: '상태', dataIndex: 'status', width: 100, fixed: 'right', + render: (v) => }, + { + title: '액션', dataIndex: 'settleId', width: 200, fixed: 'right', + render: (id, r) => ( + + onConfirm(id)} + disabled={r.status === 'CONFIRMED' || r.status === 'PAID'}> + 확정 + + onHold(id)} + disabled={r.status === 'PAID'}> + 보류 + + + ), + }, + ]} + /> + + ); +}