From 88e14edffde6af8aa2fa0634d9d6549da1043ef6 Mon Sep 17 00:00:00 2001 From: ysukkyu Date: Thu, 14 May 2026 18:14:49 +0900 Subject: [PATCH] 1 --- docker-compose.yml | 2 +- .../java/com/ga/api/GaApiApplication.java | 2 + .../installment/InstallmentController.java | 22 +- .../InstallmentRatioController.java | 6 +- .../settle/SettleCorrectionController.java | 18 +- .../src/main/resources/application-local.yml | 15 + .../main/resources/application-trading_ai.yml | 3 +- ga-api/src/main/resources/application.yml | 4 +- .../java/com/ga/batch/GaBatchApplication.java | 2 + .../src/main/resources/application-local.yml | 11 + .../main/resources/application-trading_ai.yml | 16 +- .../java/com/ga/common/model/SearchParam.java | 5 + .../db/migration/V57__admin_계정초기화.sql | 23 + .../db/migration/V58__1200룰_메뉴.sql | 40 ++ .../resources/db/migration/V59__신규메뉴.sql | 97 ++++ .../db/migration/V60__statement_메뉴권한.sql | 41 ++ .../V61__installment_plan_메뉴권한.sql | 41 ++ .../db/migration/V62__추가메뉴권한.sql | 56 ++ .../com/ga/core/vo/kpi/KpiRetentionResp.java | 4 +- .../chargeback/ChargebackGradeMapper.xml | 3 - .../installment/InstallmentPlanMapper.xml | 3 - .../installment/InstallmentRatioMapper.xml | 3 - .../regulatory/RegulatoryLimitMapper.xml | 3 - .../mapper/settle/AgentDeductionMapper.xml | 3 - .../settle/CommissionStatementMapper.xml | 3 - ga-frontend/package-lock.json | 39 -- ga-frontend/src/App.tsx | 50 +- ga-frontend/src/api/chargeback-grade.ts | 49 ++ ga-frontend/src/api/deduction.ts | 53 ++ ga-frontend/src/api/dispute.ts | 66 +++ ga-frontend/src/api/incentive.ts | 69 +++ ga-frontend/src/api/installment.ts | 36 ++ ga-frontend/src/api/period-close.ts | 48 ++ ga-frontend/src/api/regulatory.ts | 43 ++ ga-frontend/src/api/settle-correction.ts | 64 +++ ga-frontend/src/api/tax-invoice.ts | 66 +++ ga-frontend/src/api/user.ts | 8 +- .../src/pages/rule/ChargebackGradeList.tsx | 395 +++++++++++++ .../src/pages/rule/InstallmentRatioList.tsx | 273 +++++++++ .../src/pages/rule/RegulatoryLimitList.tsx | 192 +++++++ .../src/pages/settle/CommissionDispute.tsx | 467 +++++++++++++++ .../src/pages/settle/DeductionList.tsx | 331 +++++++++++ .../src/pages/settle/IncentiveProgram.tsx | 481 ++++++++++++++++ ga-frontend/src/pages/settle/PeriodClose.tsx | 305 ++++++++++ .../src/pages/settle/SettleCorrection.tsx | 381 ++++++++++++ .../src/pages/settle/TaxInvoiceList.tsx | 424 ++++++++++++++ ga-frontend/src/pages/system/CodeList.tsx | 541 ++++++++++++++++-- ga-frontend/src/pages/system/SystemConfig.tsx | 229 +++++++- ga-frontend/vite.config.ts | 2 +- settings.gradle | 4 + 50 files changed, 4870 insertions(+), 172 deletions(-) create mode 100644 ga-api/src/main/resources/application-local.yml create mode 100644 ga-batch/src/main/resources/application-local.yml create mode 100644 ga-common/src/main/resources/db/migration/V57__admin_계정초기화.sql create mode 100644 ga-common/src/main/resources/db/migration/V58__1200룰_메뉴.sql create mode 100644 ga-common/src/main/resources/db/migration/V59__신규메뉴.sql create mode 100644 ga-common/src/main/resources/db/migration/V60__statement_메뉴권한.sql create mode 100644 ga-common/src/main/resources/db/migration/V61__installment_plan_메뉴권한.sql create mode 100644 ga-common/src/main/resources/db/migration/V62__추가메뉴권한.sql create mode 100644 ga-frontend/src/api/chargeback-grade.ts create mode 100644 ga-frontend/src/api/deduction.ts create mode 100644 ga-frontend/src/api/dispute.ts create mode 100644 ga-frontend/src/api/incentive.ts create mode 100644 ga-frontend/src/api/installment.ts create mode 100644 ga-frontend/src/api/period-close.ts create mode 100644 ga-frontend/src/api/regulatory.ts create mode 100644 ga-frontend/src/api/settle-correction.ts create mode 100644 ga-frontend/src/api/tax-invoice.ts create mode 100644 ga-frontend/src/pages/rule/ChargebackGradeList.tsx create mode 100644 ga-frontend/src/pages/rule/InstallmentRatioList.tsx create mode 100644 ga-frontend/src/pages/rule/RegulatoryLimitList.tsx create mode 100644 ga-frontend/src/pages/settle/CommissionDispute.tsx create mode 100644 ga-frontend/src/pages/settle/DeductionList.tsx create mode 100644 ga-frontend/src/pages/settle/IncentiveProgram.tsx create mode 100644 ga-frontend/src/pages/settle/PeriodClose.tsx create mode 100644 ga-frontend/src/pages/settle/SettleCorrection.tsx create mode 100644 ga-frontend/src/pages/settle/TaxInvoiceList.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 89b1bf2..345f66e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,7 +46,7 @@ services: JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits} ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!} ports: - - "8080:8080" + - "8082:8082" batch: build: diff --git a/ga-api/src/main/java/com/ga/api/GaApiApplication.java b/ga-api/src/main/java/com/ga/api/GaApiApplication.java index 395e0ed..4490a7c 100644 --- a/ga-api/src/main/java/com/ga/api/GaApiApplication.java +++ b/ga-api/src/main/java/com/ga/api/GaApiApplication.java @@ -2,12 +2,14 @@ package com.ga.api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; /** * GA 수수료 정산 API 서버. * 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함) */ @SpringBootApplication(scanBasePackages = {"com.ga"}) +@EnableCaching public class GaApiApplication { public static void main(String[] args) { SpringApplication.run(GaApiApplication.class, args); diff --git a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java index 021f953..759b16a 100644 --- a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java @@ -24,20 +24,20 @@ public class InstallmentController { private final InstallmentService installmentService; @GetMapping - @RequirePermission(menu = "INSTALLMENT", perm = "READ") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ") public ApiResponse> list(InstallmentPlanSearchParam param) { return ApiResponse.ok(installmentService.list(param)); } @GetMapping("/{planId}") - @RequirePermission(menu = "INSTALLMENT", perm = "READ") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ") public ApiResponse detail(@PathVariable Long planId) { return ApiResponse.ok(installmentService.detail(planId)); } @PostMapping("/generate") - @RequirePermission(menu = "INSTALLMENT", perm = "CREATE") - @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "CREATE") + @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") public ApiResponse generate(@Valid @RequestBody GeneratePlanReq req) { installmentService.createPlan( req.getContractId(), @@ -49,8 +49,8 @@ public class InstallmentController { } @PutMapping("/{planId}/pay") - @RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") - @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE") + @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") public ApiResponse pay(@PathVariable Long planId, @Valid @RequestBody PayPlanReq req) { installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount()); @@ -58,23 +58,23 @@ public class InstallmentController { } @PutMapping("/{planId}/defer") - @RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") - @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE") + @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") public ApiResponse defer(@PathVariable Long planId) { installmentService.deferPlan(planId); return ApiResponse.ok(); } @PutMapping("/{planId}/cancel") - @RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") - @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE") + @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") public ApiResponse cancel(@PathVariable Long planId) { installmentService.cancelPlan(planId); return ApiResponse.ok(); } @GetMapping("/scheduled") - @RequirePermission(menu = "INSTALLMENT", perm = "READ") + @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ") public ApiResponse> scheduled(@RequestParam String settleMonth) { return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth)); } diff --git a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java index 65c4a5d..4bbd0dd 100644 --- a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java +++ b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java @@ -22,14 +22,14 @@ public class InstallmentRatioController { private final InstallmentService installmentService; @GetMapping - @RequirePermission(menu = "INSTALLMENT", perm = "READ") + @RequirePermission(menu = "INSTALLMENT_RATIO", perm = "READ") public ApiResponse> list(InstallmentRatioSearchParam param) { return ApiResponse.ok(installmentService.listRatios(param)); } @PostMapping - @RequirePermission(menu = "INSTALLMENT", perm = "CREATE") - @DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio") + @RequirePermission(menu = "INSTALLMENT_RATIO", perm = "CREATE") + @DataChangeLog(menu = "INSTALLMENT_RATIO", table = "installment_ratio") public ApiResponse create(@Valid @RequestBody InstallmentRatioSaveReq req) { installmentService.createRatio(req); return ApiResponse.ok(); diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java b/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java index 9bd9e79..aa295d4 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java @@ -25,41 +25,41 @@ public class SettleCorrectionController { private final SettleCorrectionService service; @GetMapping - @RequirePermission(menu = "SETTLE_CORR", perm = "READ") + @RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ") public ApiResponse> list(@ModelAttribute SettleCorrectionSearchParam param) { return ApiResponse.ok(service.list(param)); } @GetMapping("/{correctionId}") - @RequirePermission(menu = "SETTLE_CORR", perm = "READ") + @RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ") public ApiResponse detail(@PathVariable Long correctionId) { return ApiResponse.ok(service.detail(correctionId)); } @GetMapping("/by-settle/{originalSettleId}") - @RequirePermission(menu = "SETTLE_CORR", perm = "READ") + @RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ") public ApiResponse> listByOriginalSettle(@PathVariable Long originalSettleId) { return ApiResponse.ok(service.listByOriginalSettle(originalSettleId)); } @PostMapping - @RequirePermission(menu = "SETTLE_CORR", perm = "CREATE") - @DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction") + @RequirePermission(menu = "SETTLE_CORRECTION", perm = "CREATE") + @DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction") public ApiResponse create(@Valid @RequestBody SettleCorrectionSaveReq req) { return ApiResponse.ok(service.create(req)); } @PostMapping("/{correctionId}/approve") - @RequirePermission(menu = "SETTLE_CORR", perm = "APPROVE") - @DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction") + @RequirePermission(menu = "SETTLE_CORRECTION", perm = "APPROVE") + @DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction") public ApiResponse approve(@PathVariable Long correctionId) { service.approve(correctionId); return ApiResponse.ok(); } @PostMapping("/{correctionId}/cancel") - @RequirePermission(menu = "SETTLE_CORR", perm = "UPDATE") - @DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction") + @RequirePermission(menu = "SETTLE_CORRECTION", perm = "UPDATE") + @DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction") public ApiResponse cancel(@PathVariable Long correctionId, @RequestBody Map body) { service.cancel(correctionId, body.get("cancelReason")); return ApiResponse.ok(); diff --git a/ga-api/src/main/resources/application-local.yml b/ga-api/src/main/resources/application-local.yml new file mode 100644 index 0000000..bb49076 --- /dev/null +++ b/ga-api/src/main/resources/application-local.yml @@ -0,0 +1,15 @@ +ga: + redis: + enabled: false + +spring: + datasource: + url: jdbc:postgresql://125.241.236.203:55432/ga + username: ga + password: ga + cache: + type: simple + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration diff --git a/ga-api/src/main/resources/application-trading_ai.yml b/ga-api/src/main/resources/application-trading_ai.yml index 91e7bf5..5a3756f 100644 --- a/ga-api/src/main/resources/application-trading_ai.yml +++ b/ga-api/src/main/resources/application-trading_ai.yml @@ -3,7 +3,7 @@ spring: datasource: - url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga + url: jdbc:postgresql://125.241.236.203:55432/trading_ai?currentSchema=ga username: ${DB_USER:kyu} password: ${DB_PASSWORD:7895123} driver-class-name: org.postgresql.Driver @@ -20,6 +20,7 @@ spring: baseline-on-migrate: true baseline-version: 17 # 현재 적용된 최신 버전. 새 V18+ 만 적용 baseline-description: "Initial baseline (V1-V17 applied manually on 2026-05-09)" + validate-on-migrate: false data: redis: diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml index b0e69be..09dfc6c 100644 --- a/ga-api/src/main/resources/application.yml +++ b/ga-api/src/main/resources/application.yml @@ -2,7 +2,7 @@ spring: application: name: ga-api profiles: - active: local + active: trading_ai datasource: url: jdbc:postgresql://localhost:5432/ga username: ga @@ -22,7 +22,7 @@ spring: max-request-size: 100MB server: - port: 8080 + port: 8082 servlet: encoding: charset: UTF-8 diff --git a/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java b/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java index 82cd0f5..2ba164b 100644 --- a/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java +++ b/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java @@ -2,8 +2,10 @@ package com.ga.batch; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication(scanBasePackages = {"com.ga"}) +@EnableCaching public class GaBatchApplication { public static void main(String[] args) { SpringApplication.run(GaBatchApplication.class, args); diff --git a/ga-batch/src/main/resources/application-local.yml b/ga-batch/src/main/resources/application-local.yml new file mode 100644 index 0000000..651df7e --- /dev/null +++ b/ga-batch/src/main/resources/application-local.yml @@ -0,0 +1,11 @@ +ga: + redis: + enabled: false + +spring: + cache: + type: simple + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration diff --git a/ga-batch/src/main/resources/application-trading_ai.yml b/ga-batch/src/main/resources/application-trading_ai.yml index ed54483..3262704 100644 --- a/ga-batch/src/main/resources/application-trading_ai.yml +++ b/ga-batch/src/main/resources/application-trading_ai.yml @@ -1,14 +1,20 @@ # 실제 운영 PostgreSQL 연결용 프로파일 (ga-batch) spring: datasource: - url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga + url: jdbc:postgresql://125.241.236.203:55432/trading_ai?currentSchema=ga username: ${DB_USER:kyu} password: ${DB_PASSWORD:7895123} driver-class-name: org.postgresql.Driver hikari: maximum-pool-size: 5 - data: - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} + cache: + type: simple + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration + +ga: + redis: + enabled: false diff --git a/ga-common/src/main/java/com/ga/common/model/SearchParam.java b/ga-common/src/main/java/com/ga/common/model/SearchParam.java index fdbe77b..7fb5701 100644 --- a/ga-common/src/main/java/com/ga/common/model/SearchParam.java +++ b/ga-common/src/main/java/com/ga/common/model/SearchParam.java @@ -29,6 +29,11 @@ public class SearchParam { private String sortField; private String sortOrder = "DESC"; + /** MyBatis XML에서 #{offset} 참조 시 사용되는 계산값 */ + public int getOffset() { + return Math.max(0, pageNum - 1) * pageSize; + } + /** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */ public void startPage() { PageHelper.startPage(pageNum, pageSize); diff --git a/ga-common/src/main/resources/db/migration/V57__admin_계정초기화.sql b/ga-common/src/main/resources/db/migration/V57__admin_계정초기화.sql new file mode 100644 index 0000000..aa8a4b5 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V57__admin_계정초기화.sql @@ -0,0 +1,23 @@ +-- admin 계정 비밀번호 초기화: admin1234! +INSERT INTO users (login_id, password, user_name, email, status, fail_count, pwd_changed_at, created_at) +VALUES ( + 'admin', + '$2b$10$NClaIICfNMTGWyto9X6gV.yrnYEyiQeakgirWfxRJh.vYfDtubKdu', + '시스템 관리자', + 'admin@example.com', + 'ACTIVE', + 0, + NOW(), + NOW() +) +ON CONFLICT (login_id) DO UPDATE + SET password = '$2b$10$NClaIICfNMTGWyto9X6gV.yrnYEyiQeakgirWfxRJh.vYfDtubKdu', + status = 'ACTIVE', + fail_count = 0; + +-- admin → SUPER_ADMIN 권한 매핑 +INSERT INTO user_role (user_id, role_id) +SELECT u.user_id, r.role_id +FROM users u, role r +WHERE u.login_id = 'admin' AND r.role_code = 'SUPER_ADMIN' +ON CONFLICT (user_id, role_id) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V58__1200룰_메뉴.sql b/ga-common/src/main/resources/db/migration/V58__1200룰_메뉴.sql new file mode 100644 index 0000000..6d6d970 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V58__1200룰_메뉴.sql @@ -0,0 +1,40 @@ +-- V58: 1200%룰 규제 한도 관리 메뉴 등록 (GRP_RULE 하위) + +-- 1. 메뉴 PAGE 등록 +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, 'REGULATORY_LIMIT', '1200%룰 한도', 'PAGE', '/rules/regulatory', 'rule/RegulatoryLimitList', 2, 6, 'Y', 'Y' +FROM menu p +WHERE p.menu_code = 'GRP_RULE' + AND NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = 'REGULATORY_LIMIT'); + +-- 2. 권한 항목 등록 +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES + ('READ', '조회', 10), + ('CREATE', '등록', 20), + ('UPDATE', '수정', 30) +) AS p(code, name, sort) +WHERE m.menu_code = 'REGULATORY_LIMIT' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- 3. 역할별 권한 부여 (SUPER_ADMIN/ADMIN: 전체, MANAGER: READ만) +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND m.menu_code = 'REGULATORY_LIMIT' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'MANAGER' + AND mp.perm_code = 'READ' + AND m.menu_code = 'REGULATORY_LIMIT' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V59__신규메뉴.sql b/ga-common/src/main/resources/db/migration/V59__신규메뉴.sql new file mode 100644 index 0000000..dda02fb --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V59__신규메뉴.sql @@ -0,0 +1,97 @@ +-- V59: 신규 화면 메뉴 등록 (에이전트팀 개발분) +-- GRP_RULE 하위: 분급비율, 환수등급 +-- GRP_SETTLE 하위: 공제차감, 마감관리, 정산정정, 세금계산서, 이의신청, 시책프로그램 + +-- ============================================================ +-- 1. GRP_RULE 하위 신규 PAGE 2개 +-- ============================================================ +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y' +FROM menu p +JOIN (VALUES + ('INSTALLMENT_RATIO', '분급비율 설정', '/rules/installment-ratios', 'rule/InstallmentRatioList', 7), + ('CHARGEBACK_GRADE', '환수등급 관리', '/rules/chargeback-grades', 'rule/ChargebackGradeList', 8) +) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_RULE' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- ============================================================ +-- 2. GRP_SETTLE 하위 신규 PAGE 6개 +-- ============================================================ +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y' +FROM menu p +JOIN (VALUES + ('DEDUCTION', '공제차감 관리', '/settle/deductions', 'settle/DeductionList', 10), + ('PERIOD_CLOSE', '정산 마감', '/settle/period-close', 'settle/PeriodClose', 20), + ('SETTLE_CORRECTION', '정산 정정', '/settle/corrections', 'settle/SettleCorrection', 30), + ('TAX_INVOICE', '세금계산서', '/settle/tax-invoices', 'settle/TaxInvoiceList', 40), + ('DISPUTE', '이의신청', '/settle/disputes', 'settle/CommissionDispute', 50), + ('INCENTIVE_PROG', '시책 프로그램', '/settle/incentives', 'settle/IncentiveProgram', 60) +) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_SETTLE' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- ============================================================ +-- 3. 권한 항목 등록 — 규정 도메인 (READ/CREATE/UPDATE) +-- ============================================================ +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES ('READ','조회',10),('CREATE','등록',20),('UPDATE','수정',30)) AS p(code,name,sort) +WHERE m.menu_code IN ('INSTALLMENT_RATIO','CHARGEBACK_GRADE') +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 4. 권한 항목 등록 — 정산 도메인 (READ/CREATE/UPDATE/APPROVE) +-- ============================================================ +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES ('READ','조회',10),('CREATE','등록',20),('UPDATE','수정',30)) AS p(code,name,sort) +WHERE m.menu_code IN ('DEDUCTION','PERIOD_CLOSE','SETTLE_CORRECTION','TAX_INVOICE','INCENTIVE_PROG') +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES ('READ','조회',10),('CREATE','등록',20),('UPDATE','수정',30),('APPROVE','승인',50)) AS p(code,name,sort) +WHERE m.menu_code IN ('DISPUTE','SETTLE_CORRECTION') +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 5. 역할별 권한 부여 +-- SUPER_ADMIN / ADMIN: 전체 +-- MANAGER: READ + CREATE + UPDATE + APPROVE +-- AGENT: DEDUCTION/DISPUTE READ 만 +-- ============================================================ +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN','ADMIN') + AND m.menu_code IN ('INSTALLMENT_RATIO','CHARGEBACK_GRADE', + 'DEDUCTION','PERIOD_CLOSE','SETTLE_CORRECTION', + 'TAX_INVOICE','DISPUTE','INCENTIVE_PROG') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'MANAGER' + AND mp.perm_code IN ('READ','CREATE','UPDATE','APPROVE') + AND m.menu_code IN ('INSTALLMENT_RATIO','CHARGEBACK_GRADE', + 'DEDUCTION','PERIOD_CLOSE','SETTLE_CORRECTION', + 'TAX_INVOICE','DISPUTE','INCENTIVE_PROG') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'AGENT' + AND mp.perm_code = 'READ' + AND m.menu_code IN ('DEDUCTION','DISPUTE') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V60__statement_메뉴권한.sql b/ga-common/src/main/resources/db/migration/V60__statement_메뉴권한.sql new file mode 100644 index 0000000..fecfa59 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V60__statement_메뉴권한.sql @@ -0,0 +1,41 @@ +-- V60: 수수료명세서(STATEMENT) 메뉴 및 권한 등록 +-- CommissionStatementController 에서 menu="STATEMENT" 사용 + +-- 1. STATEMENT 메뉴 PAGE 등록 (GRP_SETTLE 하위) +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, 'STATEMENT', '수수료 명세서', 'PAGE', '/statements', 'settle/CommissionStatement', 2, 70, 'Y', 'Y' +FROM menu p +WHERE p.menu_code = 'GRP_SETTLE' + AND NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = 'STATEMENT'); + +-- 2. 권한 항목 등록 +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES + ('READ', '조회', 10), + ('CREATE', '등록', 20), + ('UPDATE', '수정', 30) +) AS p(code, name, sort) +WHERE m.menu_code = 'STATEMENT' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- 3. 역할별 권한 부여 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN', 'MANAGER') + AND m.menu_code = 'STATEMENT' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'AGENT' + AND mp.perm_code = 'READ' + AND m.menu_code = 'STATEMENT' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V61__installment_plan_메뉴권한.sql b/ga-common/src/main/resources/db/migration/V61__installment_plan_메뉴권한.sql new file mode 100644 index 0000000..630341a --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V61__installment_plan_메뉴권한.sql @@ -0,0 +1,41 @@ +-- V61: 분급계획(INSTALLMENT_PLAN) 메뉴 및 권한 등록 +-- InstallmentController 에서 menu="INSTALLMENT_PLAN" 사용 + +-- 1. INSTALLMENT_PLAN 메뉴 PAGE 등록 (GRP_RULE 하위) +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, 'INSTALLMENT_PLAN', '분급 계획', 'PAGE', '/rules/installment-plans', 'rule/InstallmentPlanList', 2, 9, 'Y', 'Y' +FROM menu p +WHERE p.menu_code = 'GRP_RULE' + AND NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = 'INSTALLMENT_PLAN'); + +-- 2. 권한 항목 등록 +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES + ('READ', '조회', 10), + ('CREATE', '등록', 20), + ('UPDATE', '수정', 30) +) AS p(code, name, sort) +WHERE m.menu_code = 'INSTALLMENT_PLAN' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- 3. 역할별 권한 부여 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN', 'MANAGER') + AND m.menu_code = 'INSTALLMENT_PLAN' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'AGENT' + AND mp.perm_code = 'READ' + AND m.menu_code = 'INSTALLMENT_PLAN' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V62__추가메뉴권한.sql b/ga-common/src/main/resources/db/migration/V62__추가메뉴권한.sql new file mode 100644 index 0000000..784ecc6 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V62__추가메뉴권한.sql @@ -0,0 +1,56 @@ +-- V62: 시책지급/보험사대사/해촉정산 메뉴 및 권한 등록 +-- IncentivePaymentController -> menu="INCENTIVE_PAY" +-- CarrierReconciliationOutController -> menu="RECON_OUT" +-- TerminationSettlementController -> menu="TERM_SETTLE" + +-- ============================================================ +-- 1. GRP_SETTLE 하위 PAGE 3개 등록 +-- ============================================================ +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y' +FROM menu p +JOIN (VALUES + ('INCENTIVE_PAY', '시책 지급', '/settle/incentive-payments', 'settle/IncentivePayment', 65), + ('RECON_OUT', '보험사 대사', '/settle/carrier-reconciliation','settle/CarrierReconOut', 70), + ('TERM_SETTLE', '해촉 정산', '/settle/termination', 'settle/TerminationSettle', 80) +) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_SETTLE' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- ============================================================ +-- 2. 권한 항목 등록 — READ/CREATE/UPDATE +-- ============================================================ +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES ('READ','조회',10),('CREATE','등록',20),('UPDATE','수정',30)) AS p(code,name,sort) +WHERE m.menu_code IN ('INCENTIVE_PAY','RECON_OUT','TERM_SETTLE') +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- 시책지급에 APPROVE 권한 추가 +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, 'APPROVE', '승인', 50 +FROM menu m +WHERE m.menu_code = 'INCENTIVE_PAY' +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 3. 역할별 권한 부여 — SUPER_ADMIN/ADMIN/MANAGER: 전체, AGENT: READ +-- ============================================================ +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN', 'MANAGER') + AND m.menu_code IN ('INCENTIVE_PAY','RECON_OUT','TERM_SETTLE') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'AGENT' + AND mp.perm_code = 'READ' + AND m.menu_code IN ('INCENTIVE_PAY','RECON_OUT','TERM_SETTLE') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-core/src/main/java/com/ga/core/vo/kpi/KpiRetentionResp.java b/ga-core/src/main/java/com/ga/core/vo/kpi/KpiRetentionResp.java index 1c0c686..3a91924 100644 --- a/ga-core/src/main/java/com/ga/core/vo/kpi/KpiRetentionResp.java +++ b/ga-core/src/main/java/com/ga/core/vo/kpi/KpiRetentionResp.java @@ -3,7 +3,7 @@ package com.ga.core.vo.kpi; import lombok.Data; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; /** * 계약 유지율 KPI 응답 VO (mv_retention 기반) @@ -20,5 +20,5 @@ public class KpiRetentionResp { private Long retained25m; // 25개월 시점 유지 계약 수 private BigDecimal retentionRate13m; // 13개월 유지율 (%) private BigDecimal retentionRate25m; // 25개월 유지율 (%) - private LocalDateTime refreshedAt; + private OffsetDateTime refreshedAt; } diff --git a/ga-core/src/main/resources/mapper/chargeback/ChargebackGradeMapper.xml b/ga-core/src/main/resources/mapper/chargeback/ChargebackGradeMapper.xml index 2cb9882..d951388 100644 --- a/ga-core/src/main/resources/mapper/chargeback/ChargebackGradeMapper.xml +++ b/ga-core/src/main/resources/mapper/chargeback/ChargebackGradeMapper.xml @@ -82,9 +82,6 @@ ORDER BY g.months_from, g.effective_from DESC - - LIMIT #{pageSize} OFFSET #{offset} - diff --git a/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml b/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml index d39c81e..e92dd7d 100644 --- a/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml +++ b/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml @@ -88,9 +88,6 @@ ORDER BY p.contract_id, p.plan_year, p.plan_month - - LIMIT #{pageSize} OFFSET #{offset} - diff --git a/ga-core/src/main/resources/mapper/installment/InstallmentRatioMapper.xml b/ga-core/src/main/resources/mapper/installment/InstallmentRatioMapper.xml index b896177..1eb0415 100644 --- a/ga-core/src/main/resources/mapper/installment/InstallmentRatioMapper.xml +++ b/ga-core/src/main/resources/mapper/installment/InstallmentRatioMapper.xml @@ -66,9 +66,6 @@ ORDER BY r.plan_year, r.effective_from DESC - - LIMIT #{pageSize} OFFSET #{offset} - diff --git a/ga-core/src/main/resources/mapper/regulatory/RegulatoryLimitMapper.xml b/ga-core/src/main/resources/mapper/regulatory/RegulatoryLimitMapper.xml index 6f29492..d5509a7 100644 --- a/ga-core/src/main/resources/mapper/regulatory/RegulatoryLimitMapper.xml +++ b/ga-core/src/main/resources/mapper/regulatory/RegulatoryLimitMapper.xml @@ -81,9 +81,6 @@ ORDER BY r.limit_type, r.effective_from DESC - - LIMIT #{pageSize} OFFSET #{offset} - diff --git a/ga-core/src/main/resources/mapper/settle/AgentDeductionMapper.xml b/ga-core/src/main/resources/mapper/settle/AgentDeductionMapper.xml index 05389ef..02b1c7d 100644 --- a/ga-core/src/main/resources/mapper/settle/AgentDeductionMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/AgentDeductionMapper.xml @@ -76,9 +76,6 @@ ORDER BY d.deduction_id DESC - - LIMIT #{pageSize} OFFSET #{offset} - diff --git a/ga-core/src/main/resources/mapper/settle/CommissionStatementMapper.xml b/ga-core/src/main/resources/mapper/settle/CommissionStatementMapper.xml index 7a155f0..b70caa5 100644 --- a/ga-core/src/main/resources/mapper/settle/CommissionStatementMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/CommissionStatementMapper.xml @@ -104,9 +104,6 @@ ORDER BY s.statement_id DESC - - LIMIT #{pageSize} OFFSET #{offset} - diff --git a/ga-frontend/package-lock.json b/ga-frontend/package-lock.json index c3163bf..0f961cd 100644 --- a/ga-frontend/package-lock.json +++ b/ga-frontend/package-lock.json @@ -1737,9 +1737,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1754,9 +1751,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1771,9 +1765,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1788,9 +1779,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1805,9 +1793,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1822,9 +1807,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1839,9 +1821,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1856,9 +1835,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1873,9 +1849,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1890,9 +1863,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1907,9 +1877,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1924,9 +1891,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1941,9 +1905,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 1de0f55..5d80656 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -36,11 +36,14 @@ import CompanyList from '@/pages/product/CompanyList'; import ProductList from '@/pages/product/ProductList'; import ContractList from '@/pages/product/ContractList'; -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 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'; import ReceiveData from '@/pages/receive/ReceiveData'; import ReceiveMapping from '@/pages/receive/ReceiveMapping'; @@ -49,9 +52,15 @@ import RecruitLedger from '@/pages/ledger/RecruitLedger'; import MaintainLedger from '@/pages/ledger/MaintainLedger'; import ExceptionLedger from '@/pages/ledger/ExceptionLedger'; -import SettleList from '@/pages/settle/SettleList'; -import BatchRun from '@/pages/settle/BatchRun'; -import PaymentList from '@/pages/settle/PaymentList'; +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'; import PerformanceReport from '@/pages/report/PerformanceReport'; import OrgReport from '@/pages/report/OrgReport'; @@ -97,11 +106,14 @@ export default function App() { } /> {/* 수수료 규정 */} - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 데이터 수신 */} } /> @@ -113,9 +125,15 @@ export default function App() { } /> {/* 정산 / 지급 */} - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 리포트 */} } /> diff --git a/ga-frontend/src/api/chargeback-grade.ts b/ga-frontend/src/api/chargeback-grade.ts new file mode 100644 index 0000000..10fb233 --- /dev/null +++ b/ga-frontend/src/api/chargeback-grade.ts @@ -0,0 +1,49 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface ChargebackGradeResp { + gradeId: number; + productCategory: string | null; + productCategoryName: string | null; + monthsFrom: number; + monthsTo: number | null; + monthsRange: string; + chargebackPercent: number; + effectiveFrom: string; + effectiveTo: string | null; + isActive: boolean; +} + +export interface ChargebackGradeSaveReq { + productCategory?: string | null; + monthsFrom: number; + monthsTo?: number | null; + chargebackPercent: number; + effectiveFrom: string; + effectiveTo?: string | null; +} + +export interface ChargebackGradeSearchParam { + productCategory?: string; + effectiveDate?: string; + pageNum?: number; + pageSize?: number; +} + +export const chargebackGradeApi = { + list: (params: ChargebackGradeSearchParam) => + unwrap>( + api.get('/api/chargeback-grades', { params }), + ), + + get: (gradeId: number) => + unwrap(api.get(`/api/chargeback-grades/${gradeId}`)), + + create: (req: ChargebackGradeSaveReq) => + unwrap(api.post('/api/chargeback-grades', req)), + + update: (gradeId: number, req: ChargebackGradeSaveReq) => + unwrap(api.put(`/api/chargeback-grades/${gradeId}`, req)), + + deactivate: (gradeId: number) => + unwrap(api.put(`/api/chargeback-grades/${gradeId}/deactivate`)), +}; diff --git a/ga-frontend/src/api/deduction.ts b/ga-frontend/src/api/deduction.ts new file mode 100644 index 0000000..bb5535b --- /dev/null +++ b/ga-frontend/src/api/deduction.ts @@ -0,0 +1,53 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Response VO ──────────────────────────────────────────────────────────── +export interface DeductionResp { + deductionId: number; + agentId: number; + agentName: string; + settleMonth: string; // YYYYMM + deductionType: string; + deductionTypeName: string; + amount: number; // BigDecimal → number + description?: string; + status: string; + statusName: string; + createdAt: string; +} + +// ── Request VO ───────────────────────────────────────────────────────────── +export interface DeductionSaveReq { + agentId: number; + settleMonth: string; // YYYYMM 6자리 + deductionType: string; + amount: number; // 양수 + description?: string; +} + +// ── Search Params ────────────────────────────────────────────────────────── +export interface DeductionSearchParam { + agentId?: number; + settleMonth?: string; + deductionType?: string; + status?: string; + pageNum?: number; + pageSize?: number; +} + +// ── API ──────────────────────────────────────────────────────────────────── +export const deductionApi = { + list: (p: DeductionSearchParam) => + unwrap>(api.get('/api/deductions', { params: p })), + + get: (deductionId: number) => + unwrap(api.get(`/api/deductions/${deductionId}`)), + + create: (req: DeductionSaveReq) => + unwrap(api.post('/api/deductions', req)), + + update: (deductionId: number, req: DeductionSaveReq) => + unwrap(api.put(`/api/deductions/${deductionId}`, req)), + + cancel: (deductionId: number) => + unwrap(api.put(`/api/deductions/${deductionId}/cancel`)), +}; diff --git a/ga-frontend/src/api/dispute.ts b/ga-frontend/src/api/dispute.ts new file mode 100644 index 0000000..48c3b37 --- /dev/null +++ b/ga-frontend/src/api/dispute.ts @@ -0,0 +1,66 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Response VO ──────────────────────────────────────────────────────────────── +export interface DisputeResp { + disputeId: number; + agentId: number; + agentName: string; + orgName?: string; + settleMonth: string; + settleId?: number; + status: string; + statusName?: string; + disputedAmount?: number; + claimAmount?: number; + reason: string; + evidenceUrl?: string; + reviewerName?: string; + reviewedAt?: string; + resolvedByName?: string; + resolvedAt?: string; + resolution?: string; + adjustmentAmount?: number; + correctionId?: number; + createdAt: string; +} + +// ── Save Request ─────────────────────────────────────────────────────────────── +export interface DisputeSaveReq { + agentId: number; + settleMonth: string; + settleId?: number; + disputedAmount?: number; + claimAmount?: number; + reason: string; + evidenceUrl?: string; +} + +// ── Search Param ─────────────────────────────────────────────────────────────── +export interface DisputeSearchParam { + agentId?: number; + settleMonth?: string; + status?: string; + pageNum?: number; + pageSize?: number; +} + +// ── API ──────────────────────────────────────────────────────────────────────── +export const disputeApi = { + list: (p: DisputeSearchParam) => + unwrap>(api.get('/api/disputes', { params: p })), + + get: (disputeId: number) => + unwrap(api.get(`/api/disputes/${disputeId}`)), + + create: (body: DisputeSaveReq) => + unwrap(api.post('/api/disputes', body)), + + review: (disputeId: number) => + unwrap(api.post(`/api/disputes/${disputeId}/review`)), + + approve: (disputeId: number, resolution: string, adjustmentAmount?: number) => + unwrap(api.post(`/api/disputes/${disputeId}/approve`, { resolution, adjustmentAmount })), + + reject: (disputeId: number, resolution: string) => + unwrap(api.post(`/api/disputes/${disputeId}/reject`, { resolution })), +}; diff --git a/ga-frontend/src/api/incentive.ts b/ga-frontend/src/api/incentive.ts new file mode 100644 index 0000000..37300b4 --- /dev/null +++ b/ga-frontend/src/api/incentive.ts @@ -0,0 +1,69 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Response VO ──────────────────────────────────────────────────────────────── +export interface IncentiveProgramResp { + programId: number; + programName: string; + programType: string; + programTypeName?: string; + targetType: string; + targetGradeId?: number; + targetGradeName?: string; + targetOrgId?: number; + targetOrgName?: string; + productCategory?: string; + startMonth: string; + endMonth: string; + conditionDesc?: string; + payRate?: number; + payAmountFixed?: number; + payType: 'RATE' | 'FIXED_AMOUNT'; + budgetAmount?: number; + isActive: boolean; + createdAt: string; +} + +// ── Save Request ─────────────────────────────────────────────────────────────── +export interface IncentiveProgramSaveReq { + programName: string; + programType: string; + targetType: 'AGENT' | 'ORG' | 'GRADE'; + targetGradeId?: number; + targetOrgId?: number; + productCategory?: string; + startMonth: string; + endMonth: string; + conditionDesc?: string; + payRate?: number; + payAmountFixed?: number; + payType: 'RATE' | 'FIXED_AMOUNT'; + budgetAmount?: number; +} + +// ── Search Param ─────────────────────────────────────────────────────────────── +export interface IncentiveProgramSearchParam { + programType?: string; + targetType?: string; + startMonth?: string; + endMonth?: string; + pageNum?: number; + pageSize?: number; +} + +// ── API ──────────────────────────────────────────────────────────────────────── +export const incentiveApi = { + list: (p: IncentiveProgramSearchParam) => + unwrap>(api.get('/api/incentive-programs', { params: p })), + + get: (programId: number) => + unwrap(api.get(`/api/incentive-programs/${programId}`)), + + create: (body: IncentiveProgramSaveReq) => + unwrap(api.post('/api/incentive-programs', body)), + + update: (programId: number, body: IncentiveProgramSaveReq) => + unwrap(api.put(`/api/incentive-programs/${programId}`, body)), + + deactivate: (programId: number) => + unwrap(api.put(`/api/incentive-programs/${programId}/deactivate`)), +}; diff --git a/ga-frontend/src/api/installment.ts b/ga-frontend/src/api/installment.ts new file mode 100644 index 0000000..9504fde --- /dev/null +++ b/ga-frontend/src/api/installment.ts @@ -0,0 +1,36 @@ +import api, { PageResponse, unwrap } from './request'; + +export interface InstallmentRatioResp { + ratioId: number; + productCategory: string | null; + productCategoryName: string | null; + planYear: number; + ratioPercent: number; + effectiveFrom: string; + effectiveTo: string | null; + isActive: boolean; +} + +export interface InstallmentRatioSaveReq { + productCategory?: string | null; + planYear: number; + ratioPercent: number; + effectiveFrom: string; + effectiveTo?: string | null; +} + +export interface InstallmentRatioSearchParam { + productCategory?: string; + pageNum?: number; + pageSize?: number; +} + +export const installmentApi = { + list: (params: InstallmentRatioSearchParam) => + unwrap>( + api.get('/api/installment-ratios', { params }), + ), + + create: (req: InstallmentRatioSaveReq) => + unwrap(api.post('/api/installment-ratios', req)), +}; diff --git a/ga-frontend/src/api/period-close.ts b/ga-frontend/src/api/period-close.ts new file mode 100644 index 0000000..1d0fada --- /dev/null +++ b/ga-frontend/src/api/period-close.ts @@ -0,0 +1,48 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Response VO ──────────────────────────────────────────────────────────── +export interface PeriodCloseResp { + closeId: number; + closeMonth: string; // YYYYMM + closedAt?: string; + closedByName?: string; + reopenAt?: string; + reopenByName?: string; + reopenReason?: string; + note?: string; + closeStatus: 'CLOSED' | 'REOPENED'; + createdAt: string; +} + +// ── Request VOs ──────────────────────────────────────────────────────────── +export interface PeriodCloseCreateReq { + closeMonth: string; // YYYYMM + note?: string; +} + +export interface PeriodReopenReq { + reopenReason: string; +} + +// ── Search Params ────────────────────────────────────────────────────────── +export interface PeriodCloseSearchParam { + settleMonth?: string; + closeStatus?: string; + pageNum?: number; + pageSize?: number; +} + +// ── API ──────────────────────────────────────────────────────────────────── +export const periodCloseApi = { + list: (p: PeriodCloseSearchParam) => + unwrap>(api.get('/api/period-close', { params: p })), + + get: (closeId: number) => + unwrap(api.get(`/api/period-close/${closeId}`)), + + close: (req: PeriodCloseCreateReq) => + unwrap(api.post('/api/period-close', req)), + + reopen: (closeId: number, req: PeriodReopenReq) => + unwrap(api.post(`/api/period-close/${closeId}/reopen`, req)), +}; diff --git a/ga-frontend/src/api/regulatory.ts b/ga-frontend/src/api/regulatory.ts new file mode 100644 index 0000000..8bf0089 --- /dev/null +++ b/ga-frontend/src/api/regulatory.ts @@ -0,0 +1,43 @@ +import api, { unwrap } from './request'; + +export interface RegulatoryLimitResp { + limitId: number; + limitType: string; + limitTypeName?: string; + productCategory?: string; + productCategoryName?: string; + limitValue: number; + unit: string; + unitName?: string; + effectiveFrom?: string; + effectiveTo?: string; + regulationRef?: string; + description?: string; + isActive: boolean; +} + +export interface RegulatoryLimitSaveReq { + limitType: string; + productCategory?: string; + limitValue: number; + unit: string; + effectiveFrom: string; + effectiveTo?: string; + regulationRef?: string; + description?: string; +} + +export const regulatoryApi = { + list: (params?: { limitType?: string; productCategory?: string }) => + unwrap<{ list: RegulatoryLimitResp[]; total: number }>( + api.get('/api/regulatory-limits', { params }) + ), + detail: (id: number) => + unwrap(api.get(`/api/regulatory-limits/${id}`)), + create: (req: RegulatoryLimitSaveReq) => + unwrap(api.post('/api/regulatory-limits', req)), + update: (id: number, req: RegulatoryLimitSaveReq) => + unwrap(api.put(`/api/regulatory-limits/${id}`, req)), + deactivate: (id: number) => + unwrap(api.put(`/api/regulatory-limits/${id}/deactivate`)), +}; diff --git a/ga-frontend/src/api/settle-correction.ts b/ga-frontend/src/api/settle-correction.ts new file mode 100644 index 0000000..8efb648 --- /dev/null +++ b/ga-frontend/src/api/settle-correction.ts @@ -0,0 +1,64 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Response VO ──────────────────────────────────────────────────────────── +export interface SettleCorrectionResp { + correctionId: number; + originalSettleId: number; + agentId: number; + agentName: string; + orgName?: string; + settleMonth: string; // YYYYMM + diffAmount: number; // afterAmount - beforeAmount + beforeAmount: number; + afterAmount: number; + reasonCode: string; + reasonCodeName: string; + reasonDetail?: string; + status: 'DRAFT' | 'APPROVED' | 'APPLIED' | 'CANCELLED'; + statusName: string; + requestedByName?: string; + requestedAt?: string; + approvedByName?: string; + approvedAt?: string; + cancelReason?: string; + createdAt: string; +} + +// ── Request VO ───────────────────────────────────────────────────────────── +export interface SettleCorrectionSaveReq { + originalSettleId: number; + agentId: number; + settleMonth: string; // YYYYMM + beforeAmount: number; + afterAmount: number; + reasonCode: string; + reasonDetail?: string; +} + +// ── Search Params ────────────────────────────────────────────────────────── +export interface SettleCorrectionSearchParam { + agentId?: number; + settleMonth?: string; + status?: string; + reasonCode?: string; + pageNum?: number; + pageSize?: number; +} + +// ── API ──────────────────────────────────────────────────────────────────── +export const settleCorrectionApi = { + list: (p: SettleCorrectionSearchParam) => + unwrap>(api.get('/api/settle-corrections', { params: p })), + + get: (correctionId: number) => + unwrap(api.get(`/api/settle-corrections/${correctionId}`)), + + create: (req: SettleCorrectionSaveReq) => + unwrap(api.post('/api/settle-corrections', req)), + + approve: (correctionId: number) => + unwrap(api.put(`/api/settle-corrections/${correctionId}/approve`)), + + cancel: (correctionId: number, cancelReason: string) => + unwrap(api.put(`/api/settle-corrections/${correctionId}/cancel`, { cancelReason })), +}; diff --git a/ga-frontend/src/api/tax-invoice.ts b/ga-frontend/src/api/tax-invoice.ts new file mode 100644 index 0000000..17d12c4 --- /dev/null +++ b/ga-frontend/src/api/tax-invoice.ts @@ -0,0 +1,66 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Response VO ──────────────────────────────────────────────────────────────── +export interface TaxInvoiceResp { + invoiceId: number; + invoiceNo: string; + invoiceType: string; + invoiceTypeName: string; + issueDate: string; + supplyAmount: number; + taxAmount: number; + totalAmount: number; + counterpartName: string; + counterpartBizNo: string; + counterpartRep?: string; + counterpartAddr?: string; + agentId?: number; + agentName?: string; + settleMonth?: string; + description?: string; + cancelDate?: string; + cancelReason?: string; + issuedByName?: string; + createdAt: string; +} + +// ── Save Request ─────────────────────────────────────────────────────────────── +export interface TaxInvoiceSaveReq { + invoiceNo: string; + invoiceType: 'SUPPLY' | 'PURCHASE'; + issueDate: string; + supplyAmount: number; + taxAmount: number; + counterpartName: string; + counterpartBizNo: string; + counterpartRep?: string; + counterpartAddr?: string; + agentId?: number; + settleMonth?: string; + settleId?: number; + description?: string; +} + +// ── Search Param ─────────────────────────────────────────────────────────────── +export interface TaxInvoiceSearchParam { + agentId?: number; + settleMonth?: string; + invoiceType?: string; + pageNum?: number; + pageSize?: number; +} + +// ── API ──────────────────────────────────────────────────────────────────────── +export const taxInvoiceApi = { + list: (p: TaxInvoiceSearchParam) => + unwrap>(api.get('/api/tax-invoices', { params: p })), + + get: (invoiceId: number) => + unwrap(api.get(`/api/tax-invoices/${invoiceId}`)), + + create: (body: TaxInvoiceSaveReq) => + unwrap(api.post('/api/tax-invoices', body)), + + cancel: (invoiceId: number, cancelReason: string) => + unwrap(api.post(`/api/tax-invoices/${invoiceId}/cancel`, { cancelReason })), +}; diff --git a/ga-frontend/src/api/user.ts b/ga-frontend/src/api/user.ts index 3522251..4612a0f 100644 --- a/ga-frontend/src/api/user.ts +++ b/ga-frontend/src/api/user.ts @@ -86,10 +86,14 @@ export const commonCodeApi = { unwrap(api.post('/api/common/codes/groups', vo)), updateGroup: (groupCode: string, vo: Partial) => unwrap(api.put(`/api/common/codes/groups/${groupCode}`, vo)), + deleteGroup: (groupCode: string) => + unwrap(api.delete(`/api/common/codes/groups/${groupCode}`)), createCode: (groupCode: string, vo: Partial) => unwrap(api.post(`/api/common/codes/groups/${groupCode}/codes`, vo)), updateCode: (codeId: number, vo: Partial) => - unwrap(api.put(`/api/common/codes/codes/${codeId}`, vo)), + unwrap(api.put(`/api/common/codes/${codeId}`, vo)), deleteCode: (codeId: number) => - unwrap(api.delete(`/api/common/codes/codes/${codeId}`)), + unwrap(api.delete(`/api/common/codes/${codeId}`)), + refreshCache: (groupCode?: string) => + unwrap(api.post('/api/common/codes/refresh', null, { params: { groupCode } })), }; diff --git a/ga-frontend/src/pages/rule/ChargebackGradeList.tsx b/ga-frontend/src/pages/rule/ChargebackGradeList.tsx new file mode 100644 index 0000000..1e838ad --- /dev/null +++ b/ga-frontend/src/pages/rule/ChargebackGradeList.tsx @@ -0,0 +1,395 @@ +import { useRef, useState } from 'react'; +import { Modal, Tag, message } from 'antd'; +import { + ModalForm, + ProForm, + ProFormDatePicker, + ProFormDigit, + ProFormSelect, + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import PermissionButton from '@/components/common/PermissionButton'; +import MoneyText from '@/components/common/MoneyText'; +import { + chargebackGradeApi, + type ChargebackGradeResp, + type ChargebackGradeSaveReq, + type ChargebackGradeSearchParam, +} from '@/api/chargeback-grade'; + +const MENU_CODE = 'CHARGEBACK_GRADE'; + +const PRODUCT_CATEGORY_OPTIONS = [ + { value: '', label: '전체' }, + { value: 'LIFE', label: '생명' }, + { value: 'NON_LIFE', label: '손해' }, + { value: 'EXCL_AUTO', label: '자동차제외' }, + { value: 'EXCL_LOSS', label: '실손제외' }, +]; + +function ActiveBadge({ active }: { active: boolean }) { + if (active) { + return ( + + 활성 + + ); + } + return ( + + 비활성 + + ); +} + +export default function ChargebackGradeList() { + const actionRef = useRef(); + const [searchParam, setSearchParam] = useState({ + pageNum: 1, + pageSize: 50, + }); + const [modal, setModal] = useState<{ + open: boolean; + editId?: number; + }>({ open: false }); + const [editRecord, setEditRecord] = useState(null); + + const openCreate = () => { + setEditRecord(null); + setModal({ open: true }); + }; + + const openEdit = (record: ChargebackGradeResp) => { + setEditRecord(record); + setModal({ open: true, editId: record.gradeId }); + }; + + const onDeactivate = (record: ChargebackGradeResp) => { + Modal.confirm({ + title: '환수등급 비활성화', + content: `경과월 구간 "${record.monthsRange}"을(를) 비활성화하시겠습니까?`, + okType: 'danger', + okText: '비활성화', + cancelText: '취소', + onOk: async () => { + await chargebackGradeApi.deactivate(record.gradeId); + message.success('비활성화 완료'); + actionRef.current?.reload(); + }, + }); + }; + + const onSubmit = async (v: any) => { + const req: ChargebackGradeSaveReq = { + productCategory: v.productCategory || null, + monthsFrom: v.monthsFrom, + monthsTo: v.monthsTo != null && v.monthsTo !== '' ? v.monthsTo : null, + chargebackPercent: v.chargebackPercent, + effectiveFrom: dayjs(v.effectiveFrom).format('YYYY-MM-DD'), + effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : null, + }; + + if (modal.editId) { + await chargebackGradeApi.update(modal.editId, req); + message.success('수정 완료'); + } else { + await chargebackGradeApi.create(req); + message.success('구간이 등록되었습니다'); + } + setModal({ open: false }); + actionRef.current?.reload(); + return true; + }; + + const columns: ProColumns[] = [ + { + title: '상품분류', + dataIndex: 'productCategory', + width: 130, + search: false, + render: (_, r) => r.productCategoryName ?? '전체', + }, + { + title: '경과월 구간', + dataIndex: 'monthsRange', + width: 160, + search: false, + render: (_, r) => {r.monthsRange}, + }, + { + title: '환수율(%)', + dataIndex: 'chargebackPercent', + width: 110, + align: 'right', + search: false, + render: (_, r) => ( + + + + ), + }, + { + title: '적용시작', + dataIndex: 'effectiveFrom', + width: 120, + search: false, + }, + { + title: '적용종료', + dataIndex: 'effectiveTo', + width: 120, + search: false, + render: (_, r) => r.effectiveTo ?? '현재유효', + }, + { + title: '활성', + dataIndex: 'isActive', + width: 80, + align: 'center', + search: false, + render: (_, r) => , + }, + { + title: '액션', + valueType: 'option', + width: 160, + fixed: 'right', + render: (_, r) => + r.isActive + ? [ + openEdit(r)} + > + 수정 + , + onDeactivate(r)} + > + 비활성화 + , + ] + : [], + }, + { + title: '상품분류 검색', + dataIndex: 'productCategory', + hideInTable: true, + valueType: 'select', + fieldProps: { + options: PRODUCT_CATEGORY_OPTIONS, + allowClear: true, + placeholder: '전체', + }, + }, + { + title: '기준일', + dataIndex: 'effectiveDate', + hideInTable: true, + valueType: 'date', + }, + ]; + + return ( + + + actionRef={actionRef} + rowKey="gradeId" + columns={columns} + request={async (params) => { + const { productCategory, effectiveDate, current, pageSize } = params as any; + const p: ChargebackGradeSearchParam = { + productCategory: productCategory || undefined, + effectiveDate: effectiveDate + ? dayjs(effectiveDate).format('YYYY-MM-DD') + : undefined, + pageNum: current ?? 1, + pageSize: pageSize ?? 50, + }; + setSearchParam(p); + const res = await chargebackGradeApi.list(p); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: searchParam.pageSize ?? 50, + showTotal: (t) => `총 ${t.toLocaleString()}건`, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + + + 구간 등록 + , + ]} + dateFormatter="string" + search={{ + labelWidth: 'auto', + defaultCollapsed: false, + }} + /> + + !o && setModal({ open: false })} + width={560} + layout="vertical" + key={modal.editId ?? 'new'} + initialValues={ + editRecord + ? { + productCategory: editRecord.productCategory ?? '', + monthsFrom: editRecord.monthsFrom, + monthsTo: editRecord.monthsTo ?? undefined, + chargebackPercent: editRecord.chargebackPercent, + effectiveFrom: editRecord.effectiveFrom + ? dayjs(editRecord.effectiveFrom) + : null, + effectiveTo: editRecord.effectiveTo + ? dayjs(editRecord.effectiveTo) + : null, + } + : undefined + } + onFinish={onSubmit} + submitter={{ + searchConfig: { + submitText: modal.editId ? '수정' : '등록', + resetText: '취소', + }, + }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > + + + + + + v >= 0 + ? Promise.resolve() + : Promise.reject('0 이상의 값을 입력하세요'), + }, + ]} + min={0} + max={9999} + fieldProps={{ precision: 0 }} + placeholder="예: 0" + /> + { + if (v == null || v === '') return Promise.resolve(); + if (v >= 0) return Promise.resolve(); + return Promise.reject('0 이상의 값을 입력하세요'); + }, + }, + ]} + /> + + + + v >= 0 + ? Promise.resolve() + : Promise.reject('0 이상의 값을 입력하세요'), + }, + ]} + min={0} + max={100} + fieldProps={{ precision: 2, step: 0.01 }} + /> + + + + + + + + ); +} diff --git a/ga-frontend/src/pages/rule/InstallmentRatioList.tsx b/ga-frontend/src/pages/rule/InstallmentRatioList.tsx new file mode 100644 index 0000000..39754cb --- /dev/null +++ b/ga-frontend/src/pages/rule/InstallmentRatioList.tsx @@ -0,0 +1,273 @@ +import { useRef, useState } from 'react'; +import { Tag, message } from 'antd'; +import { + ModalForm, + ProForm, + ProFormDatePicker, + ProFormDigit, + ProFormSelect, + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import PermissionButton from '@/components/common/PermissionButton'; +import MoneyText from '@/components/common/MoneyText'; +import { + installmentApi, + type InstallmentRatioResp, + type InstallmentRatioSaveReq, + type InstallmentRatioSearchParam, +} from '@/api/installment'; + +const MENU_CODE = 'INSTALLMENT'; + +/** 상품분류 옵션 — 전체(null)/생명/손해/자동차제외/실손제외 */ +const PRODUCT_CATEGORY_OPTIONS = [ + { value: '', label: '전체' }, + { value: 'LIFE', label: '생명' }, + { value: 'NON_LIFE', label: '손해' }, + { value: 'EXCL_AUTO', label: '자동차제외' }, + { value: 'EXCL_LOSS', label: '실손제외' }, +]; + +const PLAN_YEAR_OPTIONS = [1, 2, 3, 4, 5, 6, 7].map((y) => ({ + value: y, + label: `${y}차년도`, +})); + +function planYearLabel(y: number): string { + return `${y}차년도`; +} + +export default function InstallmentRatioList() { + const actionRef = useRef(); + const [searchParam, setSearchParam] = useState({ + pageNum: 1, + pageSize: 50, + }); + const [modalOpen, setModalOpen] = useState(false); + + const openCreate = () => { + setModalOpen(true); + }; + + const onSubmit = async (v: any) => { + const req: InstallmentRatioSaveReq = { + productCategory: v.productCategory || null, + planYear: v.planYear, + ratioPercent: v.ratioPercent, + effectiveFrom: dayjs(v.effectiveFrom).format('YYYY-MM-DD'), + effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : null, + }; + await installmentApi.create(req); + message.success('분급비율이 등록되었습니다'); + setModalOpen(false); + actionRef.current?.reload(); + return true; + }; + + const columns: ProColumns[] = [ + { + title: '상품분류', + dataIndex: 'productCategory', + width: 130, + search: false, + render: (_, r) => r.productCategoryName ?? '전체', + }, + { + title: '분급연차', + dataIndex: 'planYear', + width: 110, + align: 'center', + search: false, + render: (_, r) => planYearLabel(r.planYear), + }, + { + title: '비율(%)', + dataIndex: 'ratioPercent', + width: 110, + align: 'right', + search: false, + render: (_, r) => ( + + + + ), + }, + { + title: '적용시작', + dataIndex: 'effectiveFrom', + width: 120, + search: false, + }, + { + title: '적용종료', + dataIndex: 'effectiveTo', + width: 120, + search: false, + render: (_, r) => r.effectiveTo ?? '현재유효', + }, + { + title: '활성', + dataIndex: 'isActive', + width: 80, + align: 'center', + search: false, + render: (_, r) => + r.isActive ? ( + + 활성 + + ) : ( + + 비활성 + + ), + }, + { + title: '상품분류 검색', + dataIndex: 'productCategory', + hideInTable: true, + valueType: 'select', + fieldProps: { + options: PRODUCT_CATEGORY_OPTIONS, + allowClear: true, + placeholder: '전체', + }, + }, + ]; + + return ( + + + actionRef={actionRef} + rowKey="ratioId" + columns={columns} + request={async (params) => { + const { productCategory, current, pageSize } = params as any; + const p: InstallmentRatioSearchParam = { + productCategory: productCategory || undefined, + pageNum: current ?? 1, + pageSize: pageSize ?? 50, + }; + setSearchParam(p); + const res = await installmentApi.list(p); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: searchParam.pageSize ?? 50, + showTotal: (t) => `총 ${t.toLocaleString()}건`, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + + + 분급비율 등록 + , + ]} + dateFormatter="string" + search={{ + labelWidth: 'auto', + defaultCollapsed: false, + }} + /> + + !o && setModalOpen(false)} + width={560} + layout="vertical" + key={modalOpen ? 'open' : 'closed'} + onFinish={onSubmit} + submitter={{ searchConfig: { submitText: '등록', resetText: '취소' } }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > + + + + + + + v > 0 ? Promise.resolve() : Promise.reject('0보다 큰 값을 입력하세요'), + }, + ]} + min={0.01} + max={100} + fieldProps={{ precision: 2, step: 0.01 }} + /> + + + + + + + + ); +} diff --git a/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx b/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx new file mode 100644 index 0000000..6c4a406 --- /dev/null +++ b/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx @@ -0,0 +1,192 @@ +import { useRef, useState } from 'react'; +import { Modal, message, Tag, Badge } from 'antd'; +import { + ModalForm, ProForm, ProFormDatePicker, ProFormDigit, ProFormSelect, + ProFormText, ProFormTextArea, ProTable, type ActionType, type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import PermissionButton from '@/components/common/PermissionButton'; +import { regulatoryApi, RegulatoryLimitResp, RegulatoryLimitSaveReq } from '@/api/regulatory'; + +const LIMIT_TYPE_OPTIONS = [ + { value: 'TOTAL_RATIO', label: '총한도비율 (1200%)' }, + { value: 'FIRST_YEAR_RATIO', label: '1차년도한도 (35%)' }, + { value: 'INSTALLMENT_YEARS', label: '분급 연한 (7년)' }, +]; + +const PRODUCT_CATEGORY_OPTIONS = [ + { value: 'ALL', label: '전체 상품' }, + { value: 'LIFE', label: '생명보험' }, + { value: 'NON_LIFE', label: '손해보험' }, + { value: 'EXCLUDED_AUTO', label: '자동차보험(1200%룰 제외)' }, + { value: 'EXCLUDED_HEALTH', label: '실손의료비(1200%룰 제외)' }, +]; + +const UNIT_OPTIONS = [ + { value: 'PERCENT', label: '% (퍼센트)' }, + { value: 'YEAR', label: '년 (연도)' }, + { value: 'MONTH', label: '개월' }, +]; + +export default function RegulatoryLimitList() { + const actionRef = useRef(); + const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); + const [editRecord, setEditRecord] = useState(null); + + const openCreate = () => { + setEditRecord(null); + setModal({ open: true }); + }; + + const openEdit = (record: RegulatoryLimitResp) => { + setEditRecord(record); + setModal({ open: true, editId: record.limitId }); + }; + + const onDeactivate = (record: RegulatoryLimitResp) => { + Modal.confirm({ + title: '규제 한도 비활성화', + content: `[${record.limitTypeName ?? record.limitType}] 한도를 비활성화하시겠습니까?`, + okType: 'danger', + okText: '비활성화', + cancelText: '취소', + onOk: async () => { + await regulatoryApi.deactivate(record.limitId); + message.success('비활성화 완료'); + actionRef.current?.reload(); + }, + }); + }; + + const onSubmit = async (v: any) => { + const req: RegulatoryLimitSaveReq = { + ...v, + productCategory: v.productCategory === 'ALL' ? undefined : v.productCategory, + effectiveFrom: v.effectiveFrom ? dayjs(v.effectiveFrom).format('YYYY-MM-DD') : undefined, + effectiveTo: v.effectiveTo ? dayjs(v.effectiveTo).format('YYYY-MM-DD') : undefined, + }; + if (modal.editId) { + await regulatoryApi.update(modal.editId, req); + message.success('수정 완료'); + } else { + await regulatoryApi.create(req); + message.success('등록 완료'); + } + setModal({ open: false }); + actionRef.current?.reload(); + return true; + }; + + const columns: ProColumns[] = [ + { + title: '한도유형', dataIndex: 'limitType', width: 160, + render: (_, r) => ( + + {r.limitTypeName ?? r.limitType} + + ), + }, + { title: '상품분류', dataIndex: 'productCategoryName', width: 160, render: (_, r) => r.productCategoryName ?? '전체 상품' }, + { + title: '한도값', dataIndex: 'limitValue', width: 110, align: 'right', + render: (_, r) => {Number(r.limitValue).toLocaleString()}, + }, + { title: '단위', dataIndex: 'unitName', width: 80, render: (_, r) => r.unitName ?? r.unit }, + { title: '적용시작', dataIndex: 'effectiveFrom', width: 110 }, + { title: '적용종료', dataIndex: 'effectiveTo', width: 110, render: (v) => v ?? '현재 유효' }, + { title: '근거법령', dataIndex: 'regulationRef', width: 200, ellipsis: true }, + { title: '설명', dataIndex: 'description', ellipsis: true, search: false }, + { + title: '상태', dataIndex: 'isActive', width: 80, align: 'center', + render: (_, r) => r.isActive + ? + : , + }, + { + title: '액션', valueType: 'option', width: 130, fixed: 'right', + render: (_, r) => [ + openEdit(r)}>수정, + r.isActive && ( + onDeactivate(r)}>비활성화 + ), + ], + }, + ]; + + return ( + + + actionRef={actionRef} + rowKey="limitId" + columns={columns} + search={false} + request={async () => { + const res = await regulatoryApi.list(); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ pageSize: 50, showTotal: (t) => `총 ${t.toLocaleString()}건` }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + scroll={{ x: 1200 }} + toolBarRender={() => [ + + 한도 등록, + ]} + dateFormatter="string" + /> + + !o && setModal({ open: false })} + width={620} + layout="vertical" + key={modal.editId ?? 'new'} + initialValues={editRecord ? { + ...editRecord, + productCategory: editRecord.productCategory ?? 'ALL', + effectiveFrom: editRecord.effectiveFrom ? dayjs(editRecord.effectiveFrom) : null, + effectiveTo: editRecord.effectiveTo ? dayjs(editRecord.effectiveTo) : null, + } : { productCategory: 'ALL', unit: 'PERCENT' }} + onFinish={onSubmit} + submitter={{ searchConfig: { submitText: modal.editId ? '수정' : '등록', resetText: '취소' } }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > + + + + + + + + + + + + + + + + + ); +} diff --git a/ga-frontend/src/pages/settle/CommissionDispute.tsx b/ga-frontend/src/pages/settle/CommissionDispute.tsx new file mode 100644 index 0000000..f27adf9 --- /dev/null +++ b/ga-frontend/src/pages/settle/CommissionDispute.tsx @@ -0,0 +1,467 @@ +import { useRef, useState } from 'react'; +import { + Form, + Input, + InputNumber, + Modal, + Space, + Tag, + message, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import DatePicker from 'antd/es/date-picker'; +import PageContainer from '@/components/common/PageContainer'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + disputeApi, + DisputeResp, + DisputeSaveReq, + DisputeSearchParam, +} from '@/api/dispute'; + +const { MonthPicker } = DatePicker; + +// ── 상태 Badge 색상 정의 ───────────────────────────────────────────────────── +const STATUS_TAG: Record = { + PENDING: { color: 'orange', label: '대기' }, + REVIEWING: { color: 'blue', label: '검토중' }, + APPROVED: { color: 'green', label: '승인' }, + REJECTED: { color: 'red', label: '반려' }, +}; + +const STATUS_ENUM: Record = { + PENDING: { text: '대기' }, + REVIEWING: { text: '검토중' }, + APPROVED: { text: '승인' }, + REJECTED: { text: '반려' }, +}; + +// ── 등록 폼 내부 타입 ──────────────────────────────────────────────────────── +interface RegisterFormValues { + agentId: number; + settleMonthPicker?: unknown; + settleId?: number; + disputedAmount?: number; + claimAmount?: number; + reason: string; + evidenceUrl?: string; +} + +export default function CommissionDispute() { + const actionRef = useRef(); + + // 이의신청 등록 모달 + const [registerOpen, setRegisterOpen] = useState(false); + const [registering, setRegistering] = useState(false); + const [registerForm] = Form.useForm(); + + // 승인 모달 + const [approveOpen, setApproveOpen] = useState(false); + const [approving, setApproving] = useState(false); + const [approveTarget, setApproveTarget] = useState(null); + const [approveForm] = Form.useForm<{ resolution: string; adjustmentAmount?: number }>(); + + // 반려 모달 + const [rejectOpen, setRejectOpen] = useState(false); + const [rejecting, setRejecting] = useState(false); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectForm] = Form.useForm<{ resolution: string }>(); + + // ── 등록 처리 ──────────────────────────────────────────────────────────────── + const handleRegister = async () => { + const values = await registerForm.validateFields(); + const payload: DisputeSaveReq = { + agentId: values.agentId, + settleMonth: values.settleMonthPicker + ? dayjs(values.settleMonthPicker as Parameters[0]).format('YYYYMM') + : '', + settleId: values.settleId, + disputedAmount: values.disputedAmount, + claimAmount: values.claimAmount, + reason: values.reason, + evidenceUrl: values.evidenceUrl, + }; + setRegistering(true); + try { + await disputeApi.create(payload); + message.success('이의신청이 등록되었습니다'); + setRegisterOpen(false); + registerForm.resetFields(); + actionRef.current?.reload(); + } finally { + setRegistering(false); + } + }; + + // ── 검토시작 처리 ──────────────────────────────────────────────────────────── + const handleReview = (record: DisputeResp) => { + Modal.confirm({ + title: '검토 시작', + content: `"${record.agentName}"의 이의신청을 검토 상태로 변경하시겠습니까?`, + okText: '검토시작', + cancelText: '취소', + onOk: async () => { + await disputeApi.review(record.disputeId); + message.success('검토 상태로 변경되었습니다'); + actionRef.current?.reload(); + }, + }); + }; + + // ── 승인 처리 ──────────────────────────────────────────────────────────────── + const openApprove = (record: DisputeResp) => { + setApproveTarget(record); + approveForm.resetFields(); + setApproveOpen(true); + }; + + const handleApprove = async () => { + if (!approveTarget) return; + const values = await approveForm.validateFields(); + setApproving(true); + try { + await disputeApi.approve( + approveTarget.disputeId, + values.resolution, + values.adjustmentAmount, + ); + message.success('이의신청이 승인되었습니다'); + setApproveOpen(false); + setApproveTarget(null); + actionRef.current?.reload(); + } finally { + setApproving(false); + } + }; + + // ── 반려 처리 ──────────────────────────────────────────────────────────────── + const openReject = (record: DisputeResp) => { + setRejectTarget(record); + rejectForm.resetFields(); + setRejectOpen(true); + }; + + const handleReject = async () => { + if (!rejectTarget) return; + const { resolution } = await rejectForm.validateFields(); + setRejecting(true); + try { + await disputeApi.reject(rejectTarget.disputeId, resolution); + message.success('이의신청이 반려되었습니다'); + setRejectOpen(false); + setRejectTarget(null); + actionRef.current?.reload(); + } finally { + setRejecting(false); + } + }; + + // ── 컬럼 정의 ──────────────────────────────────────────────────────────────── + const columns: ProColumns[] = [ + { + title: '설계사명', + dataIndex: 'agentName', + width: 100, + fixed: 'left', + search: false, + }, + { + title: '조직명', + dataIndex: 'orgName', + width: 140, + ellipsis: true, + search: false, + }, + { + title: '정산월', + dataIndex: 'settleMonth', + width: 90, + valueType: 'dateMonth', + fieldProps: { format: 'YYYYMM' }, + }, + { + title: '이의금액', + dataIndex: 'disputedAmount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '청구금액', + dataIndex: 'claimAmount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '상태', + dataIndex: 'status', + width: 100, + valueType: 'select', + valueEnum: STATUS_ENUM, + render: (_, r) => { + const info = STATUS_TAG[r.status] ?? { color: 'default', label: r.statusName ?? r.status }; + return {info.label}; + }, + }, + { + title: '이의사유', + dataIndex: 'reason', + width: 200, + ellipsis: true, + search: false, + }, + { + title: '검토자', + dataIndex: 'reviewerName', + width: 90, + search: false, + }, + { + title: '처리자', + dataIndex: 'resolvedByName', + width: 90, + search: false, + }, + { + title: '등록일', + dataIndex: 'createdAt', + width: 160, + search: false, + }, + { + title: '액션', + dataIndex: 'disputeId', + width: 180, + fixed: 'right', + search: false, + render: (_, r) => ( + + {r.status === 'PENDING' && ( + handleReview(r)} + > + 검토시작 + + )} + {r.status === 'REVIEWING' && ( + <> + openApprove(r)} + > + 승인 + + openReject(r)} + > + 반려 + + + )} + + ), + }, + ]; + + return ( + + + actionRef={actionRef} + rowKey="disputeId" + columns={columns} + scroll={{ x: 1400 }} + request={async (params) => { + const { current, pageSize, settleMonth, status } = params as any; + const res = await disputeApi.list({ + settleMonth: settleMonth + ? dayjs(settleMonth).format('YYYYMM') + : undefined, + status, + pageNum: current, + pageSize, + }); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: 30, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (total) => `총 ${total.toLocaleString()}건`, + }} + search={{ + labelWidth: 'auto', + searchText: '검색', + resetText: '초기화', + collapsed: false, + collapseRender: false, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + dateFormatter="string" + toolBarRender={() => [ + } + onClick={() => { + registerForm.resetFields(); + setRegisterOpen(true); + }} + > + 이의신청 등록 + , + ]} + /> + + {/* ── 이의신청 등록 모달 ────────────────────────────────────────────────── */} + setRegisterOpen(false)} + confirmLoading={registering} + okText="등록" + cancelText="닫기" + destroyOnClose + width={560} + > +
+ + + + + + + + + + + + + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(v) => Number(v?.replace(/,/g, '') ?? 0)} + placeholder="이의금액" + min={0} + /> + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(v) => Number(v?.replace(/,/g, '') ?? 0)} + placeholder="청구금액" + min={0} + /> + + + + + + + + + + +
+
+ + {/* ── 승인 모달 ──────────────────────────────────────────────────────────── */} + setApproveOpen(false)} + confirmLoading={approving} + okText="승인" + okButtonProps={{ type: 'primary' }} + cancelText="닫기" + destroyOnClose + > +
+ + + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(v) => Number(v?.replace(/,/g, '') ?? 0)} + placeholder="조정 금액 (선택)" + /> + +
+
+ + {/* ── 반려 모달 ──────────────────────────────────────────────────────────── */} + setRejectOpen(false)} + confirmLoading={rejecting} + okText="반려" + okButtonProps={{ danger: true }} + cancelText="닫기" + destroyOnClose + > +
+ + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/settle/DeductionList.tsx b/ga-frontend/src/pages/settle/DeductionList.tsx new file mode 100644 index 0000000..e1cd4c3 --- /dev/null +++ b/ga-frontend/src/pages/settle/DeductionList.tsx @@ -0,0 +1,331 @@ +import { useRef, useState } from 'react'; +import { Modal, Space, message } from 'antd'; +import { + ModalForm, + ProForm, + ProFormDigit, + ProFormSelect, + ProFormText, + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import CodeBadge from '@/components/common/CodeBadge'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import { useCommonCodes } from '@/hooks/useCommonCodes'; +import { + deductionApi, + DeductionResp, + DeductionSaveReq, + DeductionSearchParam, +} from '@/api/deduction'; + +// ── 상수 ───────────────────────────────────────────────────────────────── +const MENU_CODE = 'DEDUCTION'; + +// YYYYMM 유효성 검사 +function isValidYYYYMM(v: string): boolean { + if (!/^\d{6}$/.test(v)) return false; + const year = parseInt(v.slice(0, 4), 10); + const month = parseInt(v.slice(4, 6), 10); + return year >= 2000 && year <= 2099 && month >= 1 && month <= 12; +} + +// ── 컴포넌트 ────────────────────────────────────────────────────────────── +export default function DeductionList() { + const actionRef = useRef(); + const [modal, setModal] = useState<{ open: boolean; editRecord?: DeductionResp }>({ + open: false, + }); + + const { data: statusCodes = [] } = useCommonCodes('DEDUCTION_STATUS'); + const { data: typeCodes = [] } = useCommonCodes('DEDUCTION_TYPE'); + + // ── 열기 ─────────────────────────────────────────────────────────────── + const openCreate = () => setModal({ open: true }); + const openEdit = (record: DeductionResp) => setModal({ open: true, editRecord: record }); + + // ── 취소 ─────────────────────────────────────────────────────────────── + const onCancel = (record: DeductionResp) => { + Modal.confirm({ + title: '공제 취소', + content: `[${record.agentName}] ${record.settleMonth} 공제를 취소하시겠습니까?`, + okType: 'danger', + okText: '취소 처리', + cancelText: '닫기', + onOk: async () => { + await deductionApi.cancel(record.deductionId); + message.success('취소 완료'); + actionRef.current?.reload(); + }, + }); + }; + + // ── 저장 ─────────────────────────────────────────────────────────────── + const onSubmit = async (values: Record) => { + const req: DeductionSaveReq = { + agentId: values.agentId as number, + settleMonth: values.settleMonth as string, + deductionType: values.deductionType as string, + amount: values.amount as number, + description: values.description as string | undefined, + }; + + if (modal.editRecord) { + await deductionApi.update(modal.editRecord.deductionId, req); + message.success('수정 완료'); + } else { + await deductionApi.create(req); + message.success('등록 완료'); + } + setModal({ open: false }); + actionRef.current?.reload(); + return true; + }; + + // ── 컬럼 ─────────────────────────────────────────────────────────────── + const columns: ProColumns[] = [ + { + title: '설계사명', + dataIndex: 'agentName', + width: 110, + fixed: 'left', + search: false, + }, + { + title: '설계사ID', + dataIndex: 'agentId', + width: 90, + hideInTable: true, + valueType: 'digit', + }, + { + title: '정산월', + dataIndex: 'settleMonth', + width: 90, + align: 'center', + }, + { + title: '차감유형', + dataIndex: 'deductionType', + width: 120, + valueType: 'select', + valueEnum: Object.fromEntries( + typeCodes.map((c) => [c.code, { text: c.codeName }]), + ), + render: (_, r) => ( + + ), + }, + { + title: '금액', + dataIndex: 'amount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '설명', + dataIndex: 'description', + width: 200, + ellipsis: true, + search: false, + }, + { + title: '상태', + dataIndex: 'status', + width: 100, + valueType: 'select', + valueEnum: Object.fromEntries( + statusCodes.map((c) => [c.code, { text: c.codeName }]), + ), + render: (_, r) => ( + + ), + }, + { + title: '등록일', + dataIndex: 'createdAt', + width: 160, + search: false, + render: (_, r) => dayjs(r.createdAt).format('YYYY-MM-DD HH:mm'), + }, + { + title: '액션', + valueType: 'option', + width: 140, + fixed: 'right', + render: (_, r) => + r.status !== 'CANCELLED' ? ( + + openEdit(r)} + > + 수정 + + onCancel(r)} + > + 취소 + + + ) : null, + }, + ]; + + // ── 렌더링 ───────────────────────────────────────────────────────────── + return ( + + + actionRef={actionRef} + rowKey="deductionId" + columns={columns} + scroll={{ x: 1100 }} + request={async (params) => { + const { current, pageSize, agentId, settleMonth, deductionType, status } = + params as DeductionSearchParam & { current?: number; pageSize?: number }; + const res = await deductionApi.list({ + agentId, + settleMonth, + deductionType, + status, + pageNum: current, + pageSize, + }); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: 30, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (total) => `총 ${total.toLocaleString()}건`, + }} + search={{ + labelWidth: 'auto', + searchText: '검색', + resetText: '초기화', + collapsed: false, + collapseRender: false, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + + + 공제 등록 + , + ]} + dateFormatter="string" + /> + + {/* ── 등록/수정 모달 ─────────────────────────────────────────────── */} + !o && setModal({ open: false })} + width={520} + layout="vertical" + key={modal.editRecord?.deductionId ?? 'new'} + initialValues={ + modal.editRecord + ? { + agentId: modal.editRecord.agentId, + settleMonth: modal.editRecord.settleMonth, + deductionType: modal.editRecord.deductionType, + amount: modal.editRecord.amount, + description: modal.editRecord.description, + } + : undefined + } + onFinish={onSubmit} + submitter={{ + searchConfig: { + submitText: modal.editRecord ? '수정' : '등록', + resetText: '취소', + }, + }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > + + + + !v || isValidYYYYMM(v) + ? Promise.resolve() + : Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')), + }, + ]} + width="sm" + placeholder="예: 202501" + fieldProps={{ maxLength: 6 }} + /> + + + ({ value: c.code, label: c.codeName }))} + /> + + v === undefined || v === null || v > 0 + ? Promise.resolve() + : Promise.reject(new Error('금액은 양수여야 합니다')), + }, + ]} + width="md" + min={1} + fieldProps={{ + precision: 0, + formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','), + }} + /> + + + + + ); +} diff --git a/ga-frontend/src/pages/settle/IncentiveProgram.tsx b/ga-frontend/src/pages/settle/IncentiveProgram.tsx new file mode 100644 index 0000000..95de63e --- /dev/null +++ b/ga-frontend/src/pages/settle/IncentiveProgram.tsx @@ -0,0 +1,481 @@ +import { useRef, useState } from 'react'; +import { + Badge, + Form, + Input, + InputNumber, + Modal, + Radio, + Select, + Space, + Tag, + message, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import DatePicker from 'antd/es/date-picker'; +import PageContainer from '@/components/common/PageContainer'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + incentiveApi, + IncentiveProgramResp, + IncentiveProgramSaveReq, + IncentiveProgramSearchParam, +} from '@/api/incentive'; + +const { MonthPicker } = DatePicker; + +// ── 옵션 상수 ──────────────────────────────────────────────────────────────── +const TARGET_TYPE_OPTIONS = [ + { label: '설계사', value: 'AGENT' }, + { label: '조직', value: 'ORG' }, + { label: '직급', value: 'GRADE' }, +]; + +const PAY_TYPE_OPTIONS = [ + { label: '지급률 (%)', value: 'RATE' }, + { label: '고정금액 (원)', value: 'FIXED_AMOUNT' }, +]; + +const TARGET_TYPE_COLOR: Record = { + AGENT: 'cyan', + ORG: 'purple', + GRADE: 'geekblue', +}; + +// ── 폼 내부 타입 ───────────────────────────────────────────────────────────── +interface ProgramFormValues extends Omit { + startMonthPicker?: unknown; + endMonthPicker?: unknown; +} + +export default function IncentiveProgram() { + const actionRef = useRef(); + + // 생성/수정 모달 + const [formOpen, setFormOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [form] = Form.useForm(); + const payType = Form.useWatch('payType', form); + + // ── 모달 열기 ──────────────────────────────────────────────────────────────── + const openCreate = () => { + setEditTarget(null); + form.resetFields(); + form.setFieldsValue({ payType: 'RATE' }); + setFormOpen(true); + }; + + const openEdit = (record: IncentiveProgramResp) => { + setEditTarget(record); + form.setFieldsValue({ + programName: record.programName, + programType: record.programType, + targetType: record.targetType as 'AGENT' | 'ORG' | 'GRADE', + targetGradeId: record.targetGradeId, + targetOrgId: record.targetOrgId, + productCategory: record.productCategory, + startMonthPicker: record.startMonth + ? dayjs(record.startMonth, 'YYYYMM') + : undefined, + endMonthPicker: record.endMonth + ? dayjs(record.endMonth, 'YYYYMM') + : undefined, + conditionDesc: record.conditionDesc, + payRate: record.payRate, + payAmountFixed: record.payAmountFixed, + payType: record.payType, + budgetAmount: record.budgetAmount, + }); + setFormOpen(true); + }; + + // ── 저장 처리 ──────────────────────────────────────────────────────────────── + const handleSave = async () => { + const values = await form.validateFields(); + const payload: IncentiveProgramSaveReq = { + programName: values.programName, + programType: values.programType, + targetType: values.targetType, + targetGradeId: values.targetGradeId, + targetOrgId: values.targetOrgId, + productCategory: values.productCategory, + startMonth: values.startMonthPicker + ? dayjs(values.startMonthPicker as Parameters[0]).format('YYYYMM') + : '', + endMonth: values.endMonthPicker + ? dayjs(values.endMonthPicker as Parameters[0]).format('YYYYMM') + : '', + conditionDesc: values.conditionDesc, + payRate: values.payType === 'RATE' ? values.payRate : undefined, + payAmountFixed: values.payType === 'FIXED_AMOUNT' ? values.payAmountFixed : undefined, + payType: values.payType, + budgetAmount: values.budgetAmount, + }; + setSaving(true); + try { + if (editTarget) { + await incentiveApi.update(editTarget.programId, payload); + message.success('시책 프로그램이 수정되었습니다'); + } else { + await incentiveApi.create(payload); + message.success('시책 프로그램이 등록되었습니다'); + } + setFormOpen(false); + setEditTarget(null); + actionRef.current?.reload(); + } finally { + setSaving(false); + } + }; + + // ── 비활성화 처리 ──────────────────────────────────────────────────────────── + const handleDeactivate = (record: IncentiveProgramResp) => { + Modal.confirm({ + title: '시책 비활성화', + content: `"${record.programName}"을(를) 비활성화하시겠습니까?`, + okText: '비활성화', + okButtonProps: { danger: true }, + cancelText: '취소', + onOk: async () => { + await incentiveApi.deactivate(record.programId); + message.success('비활성화되었습니다'); + actionRef.current?.reload(); + }, + }); + }; + + // ── 컬럼 정의 ──────────────────────────────────────────────────────────────── + const columns: ProColumns[] = [ + { + title: '시책명', + dataIndex: 'programName', + width: 180, + fixed: 'left', + ellipsis: true, + search: false, + }, + { + title: '유형', + dataIndex: 'programType', + width: 110, + search: false, + render: (_, r) => {r.programTypeName ?? r.programType}, + }, + { + title: '대상구분', + dataIndex: 'targetType', + width: 90, + valueType: 'select', + valueEnum: Object.fromEntries( + TARGET_TYPE_OPTIONS.map((o) => [o.value, { text: o.label }]), + ), + render: (_, r) => ( + + {TARGET_TYPE_OPTIONS.find((o) => o.value === r.targetType)?.label ?? r.targetType} + + ), + }, + { + title: '대상 조직/직급', + dataIndex: 'targetOrgName', + width: 140, + ellipsis: true, + search: false, + render: (_, r) => r.targetOrgName ?? r.targetGradeName ?? '-', + }, + { + title: '시작월', + dataIndex: 'startMonth', + width: 90, + valueType: 'dateMonth', + fieldProps: { format: 'YYYYMM' }, + }, + { + title: '종료월', + dataIndex: 'endMonth', + width: 90, + valueType: 'dateMonth', + fieldProps: { format: 'YYYYMM' }, + }, + { + title: '지급방식', + dataIndex: 'payType', + width: 100, + search: false, + render: (_, r) => + r.payType === 'RATE' ? ( + 지급률 + ) : ( + 고정금액 + ), + }, + { + title: '지급률/지급액', + dataIndex: 'payRate', + width: 130, + align: 'right', + search: false, + render: (_, r) => + r.payType === 'RATE' ? ( + + {r.payRate != null ? `${r.payRate}%` : '-'} + + ) : ( + + ), + }, + { + title: '예산', + dataIndex: 'budgetAmount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '활성여부', + dataIndex: 'isActive', + width: 90, + search: false, + render: (_, r) => + r.isActive ? ( + + ) : ( + + ), + }, + { + title: '액션', + dataIndex: 'programId', + width: 160, + fixed: 'right', + search: false, + render: (_, r) => ( + + openEdit(r)} + > + 수정 + + {r.isActive && ( + handleDeactivate(r)} + > + 비활성화 + + )} + + ), + }, + ]; + + return ( + + + actionRef={actionRef} + rowKey="programId" + columns={columns} + scroll={{ x: 1400 }} + request={async (params) => { + const { current, pageSize, programType, targetType, startMonth, endMonth } = + params as any; + const res = await incentiveApi.list({ + programType, + targetType, + startMonth: startMonth + ? dayjs(startMonth).format('YYYYMM') + : undefined, + endMonth: endMonth + ? dayjs(endMonth).format('YYYYMM') + : undefined, + pageNum: current, + pageSize, + }); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: 30, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (total) => `총 ${total.toLocaleString()}건`, + }} + search={{ + labelWidth: 'auto', + searchText: '검색', + resetText: '초기화', + collapsed: false, + collapseRender: false, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + dateFormatter="string" + toolBarRender={() => [ + } + onClick={openCreate} + > + 시책 등록 + , + ]} + /> + + {/* ── 생성/수정 모달 ────────────────────────────────────────────────────── */} + setFormOpen(false)} + confirmLoading={saving} + okText={editTarget ? '수정' : '등록'} + cancelText="닫기" + destroyOnClose + width={640} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {PAY_TYPE_OPTIONS.map((o) => ( + + {o.label} + + ))} + + + + {payType === 'RATE' && ( + + + + )} + + {payType === 'FIXED_AMOUNT' && ( + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(v) => Number(v?.replace(/,/g, '') ?? 0)} + placeholder="고정 지급액" + min={0} + addonAfter="원" + /> + + )} + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(v) => Number(v?.replace(/,/g, '') ?? 0)} + placeholder="예산 (선택)" + min={0} + addonAfter="원" + /> + + +
+
+ ); +} diff --git a/ga-frontend/src/pages/settle/PeriodClose.tsx b/ga-frontend/src/pages/settle/PeriodClose.tsx new file mode 100644 index 0000000..30dd3ce --- /dev/null +++ b/ga-frontend/src/pages/settle/PeriodClose.tsx @@ -0,0 +1,305 @@ +import { useRef, useState } from 'react'; +import { Tag, message } from 'antd'; +import { + ModalForm, + ProFormText, + ProFormTextArea, + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import PermissionButton from '@/components/common/PermissionButton'; +import { useCommonCodes } from '@/hooks/useCommonCodes'; +import { + periodCloseApi, + PeriodCloseResp, + PeriodCloseCreateReq, +} from '@/api/period-close'; + +// ── 상수 ───────────────────────────────────────────────────────────────── +const MENU_CODE = 'PERIOD_CLOSE'; + +// YYYYMM 유효성 검사 +function isValidYYYYMM(v: string): boolean { + if (!/^\d{6}$/.test(v)) return false; + const year = parseInt(v.slice(0, 4), 10); + const month = parseInt(v.slice(4, 6), 10); + return year >= 2000 && year <= 2099 && month >= 1 && month <= 12; +} + +// ── 마감상태 Tag 렌더러 ─────────────────────────────────────────────────── +function CloseStatusTag({ status }: { status: 'CLOSED' | 'REOPENED' | string }) { + if (status === 'CLOSED') { + return ( + + 마감 + + ); + } + if (status === 'REOPENED') { + return ( + + 재오픈 + + ); + } + return {status}; +} + +// ── 컴포넌트 ────────────────────────────────────────────────────────────── +export default function PeriodClose() { + const actionRef = useRef(); + + // 마감 등록 모달 + const [closeModal, setCloseModal] = useState(false); + // 재오픈 모달 + const [reopenModal, setReopenModal] = useState<{ + open: boolean; + record?: PeriodCloseResp; + }>({ open: false }); + + const { data: statusCodes = [] } = useCommonCodes('PERIOD_CLOSE_STATUS'); + + // ── 마감 저장 ────────────────────────────────────────────────────────── + const onClose = async (values: Record) => { + const req: PeriodCloseCreateReq = { + closeMonth: values.closeMonth as string, + note: values.note as string | undefined, + }; + await periodCloseApi.close(req); + message.success('마감 처리 완료'); + setCloseModal(false); + actionRef.current?.reload(); + return true; + }; + + // ── 재오픈 저장 ──────────────────────────────────────────────────────── + const onReopen = async (values: Record) => { + if (!reopenModal.record) return false; + await periodCloseApi.reopen(reopenModal.record.closeId, { + reopenReason: values.reopenReason as string, + }); + message.success('재오픈 처리 완료'); + setReopenModal({ open: false }); + actionRef.current?.reload(); + return true; + }; + + // ── 날짜 포맷 ────────────────────────────────────────────────────────── + const fmtDt = (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'); + + // ── 컬럼 ─────────────────────────────────────────────────────────────── + const columns: ProColumns[] = [ + { + title: '정산월', + dataIndex: 'closeMonth', + width: 90, + align: 'center', + // API 검색 파라미터명: settleMonth + fieldProps: { placeholder: '예: 202501', maxLength: 6 }, + }, + { + title: '마감상태', + dataIndex: 'closeStatus', + width: 100, + valueType: 'select', + valueEnum: Object.fromEntries( + statusCodes.map((c) => [c.code, { text: c.codeName }]), + ), + render: (_, r) => , + }, + { + title: '마감일시', + dataIndex: 'closedAt', + width: 150, + search: false, + render: (_, r) => fmtDt(r.closedAt), + }, + { + title: '마감자', + dataIndex: 'closedByName', + width: 100, + search: false, + render: (_, r) => r.closedByName ?? '-', + }, + { + title: '재오픈일시', + dataIndex: 'reopenAt', + width: 150, + search: false, + render: (_, r) => fmtDt(r.reopenAt), + }, + { + title: '재오픈자', + dataIndex: 'reopenByName', + width: 100, + search: false, + render: (_, r) => r.reopenByName ?? '-', + }, + { + title: '비고', + dataIndex: 'note', + width: 200, + ellipsis: true, + search: false, + render: (_, r) => r.note ?? '-', + }, + { + title: '액션', + valueType: 'option', + width: 100, + fixed: 'right', + render: (_, r) => + r.closeStatus === 'CLOSED' ? ( + setReopenModal({ open: true, record: r })} + > + 재오픈 + + ) : null, + }, + ]; + + // ── 렌더링 ───────────────────────────────────────────────────────────── + return ( + + + actionRef={actionRef} + rowKey="closeId" + columns={columns} + scroll={{ x: 1000 }} + request={async (params) => { + const { current, pageSize, closeMonth, closeStatus } = + params as { current?: number; pageSize?: number; closeMonth?: string; closeStatus?: string }; + const res = await periodCloseApi.list({ + settleMonth: closeMonth, // API 파라미터명: settleMonth + closeStatus, + pageNum: current, + pageSize, + }); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: 30, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (total) => `총 ${total.toLocaleString()}건`, + }} + search={{ + labelWidth: 'auto', + searchText: '검색', + resetText: '초기화', + collapsed: false, + collapseRender: false, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + setCloseModal(true)} + > + + 마감 처리 + , + ]} + dateFormatter="string" + /> + + {/* ── 마감 등록 모달 ─────────────────────────────────────────────── */} + !o && setCloseModal(false)} + width={440} + layout="vertical" + key={closeModal ? 'close-open' : 'close-closed'} + onFinish={onClose} + submitter={{ searchConfig: { submitText: '마감 처리', resetText: '취소' } }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > + + !v || isValidYYYYMM(v) + ? Promise.resolve() + : Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')), + }, + ]} + width="md" + placeholder="예: 202501" + fieldProps={{ maxLength: 6 }} + /> + + + + {/* ── 재오픈 모달 ────────────────────────────────────────────────── */} + !o && setReopenModal({ open: false })} + width={440} + layout="vertical" + key={reopenModal.record?.closeId ?? 'reopen-closed'} + onFinish={onReopen} + submitter={{ searchConfig: { submitText: '재오픈', resetText: '취소' } }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > +
+ 재오픈 사유를 입력하면 해당 정산월이 다시 편집 가능 상태로 변경됩니다. +
+ +
+
+ ); +} diff --git a/ga-frontend/src/pages/settle/SettleCorrection.tsx b/ga-frontend/src/pages/settle/SettleCorrection.tsx new file mode 100644 index 0000000..aa0b04b --- /dev/null +++ b/ga-frontend/src/pages/settle/SettleCorrection.tsx @@ -0,0 +1,381 @@ +import { useRef, useState } from 'react'; +import { Input, Modal, Space, message } from 'antd'; +import { + ModalForm, + ProForm, + ProFormDigit, + ProFormSelect, + ProFormText, + ProFormTextArea, + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import CodeBadge from '@/components/common/CodeBadge'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import { useCommonCodes } from '@/hooks/useCommonCodes'; +import { + settleCorrectionApi, + SettleCorrectionResp, + SettleCorrectionSaveReq, + SettleCorrectionSearchParam, +} from '@/api/settle-correction'; + +// ── 상수 ───────────────────────────────────────────────────────────────── +const MENU_CODE = 'SETTLE_CORRECTION'; + +// YYYYMM 유효성 검사 +function isValidYYYYMM(v: string): boolean { + if (!/^\d{6}$/.test(v)) return false; + const year = parseInt(v.slice(0, 4), 10); + const month = parseInt(v.slice(4, 6), 10); + return year >= 2000 && year <= 2099 && month >= 1 && month <= 12; +} + +// ── 차액 컬러 렌더러 ───────────────────────────────────────────────────── +function DiffAmountCell({ value }: { value: number }) { + if (value === 0) return 0; + const color = value > 0 ? '#185FA5' : '#E24B4A'; + const sign = value > 0 ? '+' : ''; + return ( + + {sign}{value.toLocaleString()} + + ); +} + +// ── 컴포넌트 ────────────────────────────────────────────────────────────── +export default function SettleCorrection() { + const actionRef = useRef(); + const [createModal, setCreateModal] = useState(false); + + const { data: statusCodes = [] } = useCommonCodes('CORRECTION_STATUS'); + const { data: reasonCodes = [] } = useCommonCodes('CORRECTION_REASON'); + + // ── 승인 ─────────────────────────────────────────────────────────────── + const onApprove = (record: SettleCorrectionResp) => { + Modal.confirm({ + title: '정정 승인', + content: `[${record.agentName}] ${record.settleMonth} 정산 정정을 승인하시겠습니까?`, + okText: '승인', + cancelText: '취소', + onOk: async () => { + await settleCorrectionApi.approve(record.correctionId); + message.success('승인 완료'); + actionRef.current?.reload(); + }, + }); + }; + + // ── 취소 ─────────────────────────────────────────────────────────────── + const onCancel = (record: SettleCorrectionResp) => { + let cancelReason = ''; + Modal.confirm({ + title: '정정 취소', + content: ( + { cancelReason = e.target.value; }} + /> + ), + okType: 'danger', + okText: '취소 처리', + cancelText: '닫기', + onOk: async () => { + if (!cancelReason.trim()) { + message.warning('취소 사유를 입력하세요'); + return Promise.reject(new Error('취소 사유 미입력')); + } + await settleCorrectionApi.cancel(record.correctionId, cancelReason); + message.success('취소 완료'); + actionRef.current?.reload(); + }, + }); + }; + + // ── 저장 ─────────────────────────────────────────────────────────────── + const onSubmit = async (values: Record) => { + const req: SettleCorrectionSaveReq = { + originalSettleId: values.originalSettleId as number, + agentId: values.agentId as number, + settleMonth: values.settleMonth as string, + beforeAmount: values.beforeAmount as number, + afterAmount: values.afterAmount as number, + reasonCode: values.reasonCode as string, + reasonDetail: values.reasonDetail as string | undefined, + }; + await settleCorrectionApi.create(req); + message.success('정정 등록 완료'); + setCreateModal(false); + actionRef.current?.reload(); + return true; + }; + + // ── 컬럼 ─────────────────────────────────────────────────────────────── + const columns: ProColumns[] = [ + { + title: '설계사명', + dataIndex: 'agentName', + width: 100, + fixed: 'left', + search: false, + }, + { + title: '설계사ID', + dataIndex: 'agentId', + width: 90, + hideInTable: true, + valueType: 'digit', + }, + { + title: '조직명', + dataIndex: 'orgName', + width: 140, + search: false, + render: (_, r) => r.orgName ?? '-', + }, + { + title: '정산월', + dataIndex: 'settleMonth', + width: 90, + align: 'center', + }, + { + title: '정정전금액', + dataIndex: 'beforeAmount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '정정후금액', + dataIndex: 'afterAmount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '차액', + dataIndex: 'diffAmount', + width: 120, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '사유', + dataIndex: 'reasonCode', + width: 120, + valueType: 'select', + valueEnum: Object.fromEntries( + reasonCodes.map((c) => [c.code, { text: c.codeName }]), + ), + render: (_, r) => ( + + ), + }, + { + title: '상태', + dataIndex: 'status', + width: 100, + valueType: 'select', + valueEnum: Object.fromEntries( + statusCodes.map((c) => [c.code, { text: c.codeName }]), + ), + render: (_, r) => ( + + ), + }, + { + title: '신청자', + dataIndex: 'requestedByName', + width: 90, + search: false, + render: (_, r) => r.requestedByName ?? '-', + }, + { + title: '신청일', + dataIndex: 'requestedAt', + width: 150, + search: false, + render: (_, r) => + r.requestedAt ? dayjs(r.requestedAt).format('YYYY-MM-DD HH:mm') : '-', + }, + { + title: '액션', + valueType: 'option', + width: 140, + fixed: 'right', + render: (_, r) => + r.status === 'DRAFT' ? ( + + onApprove(r)} + > + 승인 + + onCancel(r)} + > + 취소 + + + ) : null, + }, + ]; + + // ── 렌더링 ───────────────────────────────────────────────────────────── + return ( + + + actionRef={actionRef} + rowKey="correctionId" + columns={columns} + scroll={{ x: 1400 }} + request={async (params) => { + const { current, pageSize, agentId, settleMonth, status, reasonCode } = + params as SettleCorrectionSearchParam & { + current?: number; + pageSize?: number; + }; + const res = await settleCorrectionApi.list({ + agentId, + settleMonth, + status, + reasonCode, + pageNum: current, + pageSize, + }); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: 30, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (total) => `총 ${total.toLocaleString()}건`, + }} + search={{ + labelWidth: 'auto', + searchText: '검색', + resetText: '초기화', + collapsed: false, + collapseRender: false, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + toolBarRender={() => [ + setCreateModal(true)} + > + + 정정 등록 + , + ]} + dateFormatter="string" + /> + + {/* ── 정정 등록 모달 ─────────────────────────────────────────────── */} + !o && setCreateModal(false)} + width={560} + layout="vertical" + key={createModal ? 'correction-open' : 'correction-closed'} + onFinish={onSubmit} + submitter={{ searchConfig: { submitText: '등록', resetText: '취소' } }} + modalProps={{ destroyOnClose: true, maskClosable: false }} + > + + + + + + + !v || isValidYYYYMM(v) + ? Promise.resolve() + : Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')), + }, + ]} + width="sm" + placeholder="예: 202501" + fieldProps={{ maxLength: 6 }} + /> + ({ value: c.code, label: c.codeName }))} + /> + + + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','), + }} + /> + `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','), + }} + /> + + + + + ); +} diff --git a/ga-frontend/src/pages/settle/TaxInvoiceList.tsx b/ga-frontend/src/pages/settle/TaxInvoiceList.tsx new file mode 100644 index 0000000..8334fb7 --- /dev/null +++ b/ga-frontend/src/pages/settle/TaxInvoiceList.tsx @@ -0,0 +1,424 @@ +import { useRef, useState } from 'react'; +import { + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Tag, + message, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { + ProTable, + type ActionType, + type ProColumns, +} from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import DatePicker from 'antd/es/date-picker'; +import PageContainer from '@/components/common/PageContainer'; +import MoneyText from '@/components/common/MoneyText'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + taxInvoiceApi, + TaxInvoiceResp, + TaxInvoiceSaveReq, + TaxInvoiceSearchParam, +} from '@/api/tax-invoice'; + +const { MonthPicker } = DatePicker; + +const INVOICE_TYPE_OPTIONS = [ + { label: '매출(공급)', value: 'SUPPLY' }, + { label: '매입(구매)', value: 'PURCHASE' }, +]; + +const INVOICE_TYPE_TAG: Record = { + SUPPLY: { color: 'blue', label: '매출(공급)' }, + PURCHASE: { color: 'orange', label: '매입(구매)' }, +}; + +export default function TaxInvoiceList() { + const actionRef = useRef(); + + // 발행 모달 + const [issueOpen, setIssueOpen] = useState(false); + const [issuing, setIssuing] = useState(false); + const [issueForm] = Form.useForm(); + + // 취소 모달 + const [cancelOpen, setCancelOpen] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [cancelTarget, setCancelTarget] = useState(null); + const [cancelForm] = Form.useForm<{ cancelReason: string }>(); + + // ── 발행 처리 ──────────────────────────────────────────────────────────────── + const handleIssue = async () => { + const values = await issueForm.validateFields(); + const payload: TaxInvoiceSaveReq = { + invoiceNo: values.invoiceNo, + invoiceType: values.invoiceType, + issueDate: values.issueDatePicker + ? dayjs(values.issueDatePicker as Parameters[0]).format('YYYY-MM-DD') + : '', + supplyAmount: values.supplyAmount, + taxAmount: values.taxAmount, + counterpartName: values.counterpartName, + counterpartBizNo: values.counterpartBizNo, + counterpartRep: values.counterpartRep, + counterpartAddr: values.counterpartAddr, + agentId: values.agentId, + settleMonth: values.settleMonthPicker + ? dayjs(values.settleMonthPicker as Parameters[0]).format('YYYYMM') + : undefined, + description: values.description, + }; + setIssuing(true); + try { + await taxInvoiceApi.create(payload); + message.success('세금계산서가 발행되었습니다'); + setIssueOpen(false); + issueForm.resetFields(); + actionRef.current?.reload(); + } finally { + setIssuing(false); + } + }; + + // ── 취소 처리 ──────────────────────────────────────────────────────────────── + const openCancel = (record: TaxInvoiceResp) => { + setCancelTarget(record); + cancelForm.resetFields(); + setCancelOpen(true); + }; + + const handleCancel = async () => { + if (!cancelTarget) return; + const { cancelReason } = await cancelForm.validateFields(); + setCancelling(true); + try { + await taxInvoiceApi.cancel(cancelTarget.invoiceId, cancelReason); + message.success('세금계산서가 취소되었습니다'); + setCancelOpen(false); + setCancelTarget(null); + actionRef.current?.reload(); + } finally { + setCancelling(false); + } + }; + + // ── 컬럼 정의 ──────────────────────────────────────────────────────────────── + const columns: ProColumns[] = [ + { + title: '발행번호', + dataIndex: 'invoiceNo', + width: 160, + fixed: 'left', + copyable: true, + }, + { + title: '유형', + dataIndex: 'invoiceType', + width: 110, + valueType: 'select', + valueEnum: Object.fromEntries( + INVOICE_TYPE_OPTIONS.map((o) => [o.value, { text: o.label }]), + ), + render: (_, r) => { + const info = INVOICE_TYPE_TAG[r.invoiceType] ?? { color: 'default', label: r.invoiceType }; + return {info.label}; + }, + }, + { + title: '발행일', + dataIndex: 'issueDate', + width: 110, + search: false, + }, + { + title: '공급가액', + dataIndex: 'supplyAmount', + width: 130, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '세액', + dataIndex: 'taxAmount', + width: 110, + align: 'right', + search: false, + render: (_, r) => , + }, + { + title: '합계금액', + dataIndex: 'totalAmount', + width: 140, + align: 'right', + search: false, + render: (_, r) => ( + + + + ), + }, + { + title: '거래처명', + dataIndex: 'counterpartName', + width: 140, + search: false, + }, + { + title: '사업자번호', + dataIndex: 'counterpartBizNo', + width: 130, + search: false, + }, + { + title: '설계사명', + dataIndex: 'agentName', + width: 100, + search: false, + }, + { + title: '정산월', + dataIndex: 'settleMonth', + width: 90, + valueType: 'dateMonth', + fieldProps: { format: 'YYYYMM' }, + }, + { + title: '상태', + dataIndex: 'cancelDate', + width: 80, + search: false, + render: (_, r) => + r.cancelDate ? ( + 취소 + ) : ( + 발행 + ), + }, + { + title: '액션', + dataIndex: 'invoiceId', + width: 90, + fixed: 'right', + search: false, + render: (_, r) => + r.cancelDate ? null : ( + openCancel(r)} + > + 취소 + + ), + }, + ]; + + return ( + + + actionRef={actionRef} + rowKey="invoiceId" + columns={columns} + scroll={{ x: 1400 }} + request={async (params) => { + const { current, pageSize, invoiceType, settleMonth } = params as any; + const res = await taxInvoiceApi.list({ + invoiceType, + settleMonth: settleMonth + ? dayjs(settleMonth).format('YYYYMM') + : undefined, + pageNum: current, + pageSize, + }); + return { data: res.list, total: res.total, success: true }; + }} + pagination={{ + pageSize: 30, + showSizeChanger: true, + pageSizeOptions: ['20', '50', '100'], + showTotal: (total) => `총 ${total.toLocaleString()}건`, + }} + search={{ + labelWidth: 'auto', + searchText: '검색', + resetText: '초기화', + collapsed: false, + collapseRender: false, + }} + options={{ density: false, fullScreen: true, reload: true, setting: true }} + dateFormatter="string" + toolBarRender={() => [ + } + onClick={() => { + issueForm.resetFields(); + setIssueOpen(true); + }} + > + 세금계산서 발행 + , + ]} + /> + + {/* ── 발행 모달 ──────────────────────────────────────────────────────────── */} + setIssueOpen(false)} + confirmLoading={issuing} + okText="발행" + cancelText="닫기" + destroyOnClose + width={640} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + {/* ── 취소 모달 ──────────────────────────────────────────────────────────── */} + setCancelOpen(false)} + confirmLoading={cancelling} + okText="취소 처리" + okButtonProps={{ danger: true }} + cancelText="닫기" + destroyOnClose + > +
+ + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/system/CodeList.tsx b/ga-frontend/src/pages/system/CodeList.tsx index 454bd19..32c434e 100644 --- a/ga-frontend/src/pages/system/CodeList.tsx +++ b/ga-frontend/src/pages/system/CodeList.tsx @@ -1,80 +1,539 @@ import { useState } from 'react'; -import { Card, Col, Empty, Row, Table, Tag, Typography } from 'antd'; -import { useQuery } from '@tanstack/react-query'; +import { + Card, Col, Empty, Form, Input, InputNumber, Modal, + Row, Select, Space, Table, Tag, Typography, message, +} from 'antd'; +import { + DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, +} from '@ant-design/icons'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import PageContainer from '@/components/common/PageContainer'; -import { commonCodeApi, CommonCodeGroup } from '@/api/user'; +import PermissionButton from '@/components/common/PermissionButton'; +import { commonCodeApi, CommonCodeGroup, CommonCode } from '@/api/user'; const { Text } = Typography; +const MENU = 'SYSTEM_CODE'; +/* ------------------------------------------------------------------ */ +/* 내부 상태 타입 */ +/* ------------------------------------------------------------------ */ +type GroupModalState = + | { open: false } + | { open: true; mode: 'create' } + | { open: true; mode: 'edit'; group: CommonCodeGroup }; + +type CodeModalState = + | { open: false } + | { open: true; mode: 'create' } + | { open: true; mode: 'edit'; code: CommonCode }; + +/* ------------------------------------------------------------------ */ +/* 메인 컴포넌트 */ +/* ------------------------------------------------------------------ */ export default function CodeList() { - const [selected, setSelected] = useState(null); + const qc = useQueryClient(); - const { data: groups = [] } = useQuery({ - queryKey: ['code', 'groups'], - queryFn: () => commonCodeApi.groups(), + /* 선택 그룹 & 검색어 */ + const [selected, setSelected] = useState(null); + const [keyword, setKeyword] = useState(''); + + /* 모달 상태 */ + const [groupModal, setGroupModal] = useState({ open: false }); + const [codeModal, setCodeModal] = useState({ open: false }); + + /* 폼 인스턴스 */ + const [groupForm] = Form.useForm(); + const [codeForm] = Form.useForm(); + + /* 로딩 상태 */ + const [groupSaving, setGroupSaving] = useState(false); + const [codeSaving, setCodeSaving] = useState(false); + + /* ---------------------------------------------------------------- */ + /* 쿼리 */ + /* ---------------------------------------------------------------- */ + const { data: groups = [], isFetching: groupLoading } = useQuery({ + queryKey: ['code', 'groups', keyword], + queryFn: () => commonCodeApi.groups(keyword || undefined), }); - const { data: codes = [] } = useQuery({ + const { data: codes = [], isFetching: codeLoading } = useQuery({ queryKey: ['code', 'codes', selected?.groupCode], queryFn: () => commonCodeApi.codes(selected!.groupCode), enabled: !!selected, }); + /* ---------------------------------------------------------------- */ + /* 공통 리로드 */ + /* ---------------------------------------------------------------- */ + const invalidateGroups = () => qc.invalidateQueries({ queryKey: ['code', 'groups'] }); + const invalidateCodes = (gc: string) => + qc.invalidateQueries({ queryKey: ['code', 'codes', gc] }); + + /* ---------------------------------------------------------------- */ + /* 그룹 모달 열기 */ + /* ---------------------------------------------------------------- */ + const openGroupCreate = () => { + groupForm.resetFields(); + groupForm.setFieldsValue({ isSystem: 'N', sortOrder: 0 }); + setGroupModal({ open: true, mode: 'create' }); + }; + + const openGroupEdit = (group: CommonCodeGroup) => { + groupForm.resetFields(); + groupForm.setFieldsValue({ + groupCode: group.groupCode, + groupName: group.groupName, + description: group.description, + isSystem: group.isSystem, + sortOrder: group.sortOrder, + }); + setGroupModal({ open: true, mode: 'edit', group }); + }; + + /* ---------------------------------------------------------------- */ + /* 그룹 저장 */ + /* ---------------------------------------------------------------- */ + const handleGroupSave = async () => { + if (!groupModal.open) return; + try { + setGroupSaving(true); + const values = await groupForm.validateFields(); + + if (groupModal.mode === 'create') { + await commonCodeApi.createGroup(values); + message.success('그룹이 추가되었습니다.'); + } else { + const { group } = groupModal; + await commonCodeApi.updateGroup(group.groupCode, { + groupName: values.groupName, + description: values.description, + }); + message.success('그룹이 수정되었습니다.'); + // 선택된 그룹 이름 동기화 + if (selected?.groupCode === group.groupCode) { + setSelected((prev) => + prev ? { ...prev, groupName: values.groupName, description: values.description } : null, + ); + } + await commonCodeApi.refreshCache(group.groupCode); + } + + await invalidateGroups(); + setGroupModal({ open: false }); + } catch { + // validateFields 실패 시 폼 에러 표시 — 별도 처리 불필요 + } finally { + setGroupSaving(false); + } + }; + + /* ---------------------------------------------------------------- */ + /* 그룹 삭제 */ + /* ---------------------------------------------------------------- */ + const confirmDeleteGroup = (group: CommonCodeGroup) => { + Modal.confirm({ + title: '그룹 삭제', + content: ( + + {group.groupName} 그룹과 하위 코드를 모두 삭제합니다. +
계속하시겠습니까? +
+ ), + okText: '삭제', + okType: 'danger', + cancelText: '취소', + onOk: async () => { + await commonCodeApi.deleteGroup(group.groupCode); + message.success('그룹이 삭제되었습니다.'); + if (selected?.groupCode === group.groupCode) setSelected(null); + await commonCodeApi.refreshCache(group.groupCode); + invalidateGroups(); + }, + }); + }; + + /* ---------------------------------------------------------------- */ + /* 코드 모달 열기 */ + /* ---------------------------------------------------------------- */ + const openCodeCreate = () => { + codeForm.resetFields(); + codeForm.setFieldsValue({ isActive: 'Y', sortOrder: 0 }); + setCodeModal({ open: true, mode: 'create' }); + }; + + const openCodeEdit = (code: CommonCode) => { + codeForm.resetFields(); + codeForm.setFieldsValue({ + code: code.code, + codeName: code.codeName, + codeDesc: code.codeDesc, + sortOrder: code.sortOrder, + isActive: code.isActive, + }); + setCodeModal({ open: true, mode: 'edit', code }); + }; + + /* ---------------------------------------------------------------- */ + /* 코드 저장 */ + /* ---------------------------------------------------------------- */ + const handleCodeSave = async () => { + if (!codeModal.open || !selected) return; + try { + setCodeSaving(true); + const values = await codeForm.validateFields(); + + if (codeModal.mode === 'create') { + await commonCodeApi.createCode(selected.groupCode, values); + message.success('코드가 추가되었습니다.'); + } else { + await commonCodeApi.updateCode(codeModal.code.codeId, values); + message.success('코드가 수정되었습니다.'); + } + + await commonCodeApi.refreshCache(selected.groupCode); + invalidateCodes(selected.groupCode); + setCodeModal({ open: false }); + } catch { + // 폼 검증 실패 — 별도 처리 불필요 + } finally { + setCodeSaving(false); + } + }; + + /* ---------------------------------------------------------------- */ + /* 코드 삭제 */ + /* ---------------------------------------------------------------- */ + const confirmDeleteCode = (code: CommonCode) => { + Modal.confirm({ + title: '코드 삭제', + content: ( + + 코드 {code.code} ({code.codeName})을(를) 삭제합니다. +
계속하시겠습니까? +
+ ), + okText: '삭제', + okType: 'danger', + cancelText: '취소', + onOk: async () => { + await commonCodeApi.deleteCode(code.codeId); + message.success('코드가 삭제되었습니다.'); + if (selected) { + await commonCodeApi.refreshCache(selected.groupCode); + invalidateCodes(selected.groupCode); + } + }, + }); + }; + + /* ---------------------------------------------------------------- */ + /* 렌더 */ + /* ---------------------------------------------------------------- */ return ( + {/* ======================================================= */} + {/* 왼쪽: 그룹 목록 */} + {/* ======================================================= */} - - + } + onClick={openGroupCreate} + > + 그룹 추가 + + } + onClick={invalidateGroups} + /> + + } + > + setKeyword(v)} + onChange={(e) => { if (!e.target.value) setKeyword(''); }} + /> + rowKey="groupCode" dataSource={groups} + loading={groupLoading} size="small" pagination={false} - scroll={{ y: 600 }} - onRow={(r) => ({ onClick: () => setSelected(r), style: { cursor: 'pointer' } })} - rowClassName={(r) => r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''} + scroll={{ y: 520 }} + onRow={(r) => ({ + onClick: () => setSelected(r), + style: { cursor: 'pointer' }, + })} + rowClassName={(r) => + r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : '' + } columns={[ - { title: '그룹코드', dataIndex: 'groupCode', width: 160 }, - { title: '그룹명', dataIndex: 'groupName' }, + { title: '그룹코드', dataIndex: 'groupCode', width: 140, ellipsis: true }, + { title: '그룹명', dataIndex: 'groupName', ellipsis: true }, { - title: '시스템', dataIndex: 'isSystem', width: 80, align: 'center', - render: (v) => v === 'Y' ? 시스템 : 일반, + title: '유형', + dataIndex: 'isSystem', + width: 68, + align: 'center', + render: (v: string) => + v === 'Y' ? ( + 시스템 + ) : ( + 일반 + ), + }, + { + title: '작업', + key: 'actions', + width: 72, + align: 'center', + render: (_, r) => ( + e.stopPropagation()}> + } + onClick={() => openGroupEdit(r)} + /> + } + onClick={() => confirmDeleteGroup(r)} + /> + + ), }, ]} /> + + {/* ======================================================= */} + {/* 오른쪽: 코드 목록 */} + {/* ======================================================= */} - + } + onClick={openCodeCreate} + > + 코드 추가 + + ) + } + > {!selected ? ( - + ) : ( -
v === 'Y' ? Y : N, - }, - ]} - /> - )} - {selected?.isSystem === 'Y' && ( - - ⚠ 시스템 그룹의 코드는 식별자 변경/삭제가 제한됩니다. - + <> + + rowKey="codeId" + dataSource={codes} + loading={codeLoading} + size="small" + pagination={false} + scroll={{ y: 520 }} + columns={[ + { title: '코드', dataIndex: 'code', width: 120, ellipsis: true }, + { title: '코드명', dataIndex: 'codeName', width: 130, ellipsis: true }, + { title: '설명', dataIndex: 'codeDesc', ellipsis: true }, + { title: '정렬', dataIndex: 'sortOrder', width: 52, align: 'right' }, + { + title: '활성', + dataIndex: 'isActive', + width: 60, + align: 'center', + render: (v: string) => + v === 'Y' ? ( + Y + ) : ( + N + ), + }, + { + title: '작업', + key: 'actions', + width: 72, + align: 'center', + render: (_, r) => ( + + } + onClick={() => openCodeEdit(r)} + /> + } + onClick={() => confirmDeleteCode(r)} + /> + + ), + }, + ]} + /> + {selected.isSystem === 'Y' && ( + + 시스템 그룹의 코드는 식별자 변경·삭제가 제한됩니다. + + )} + )} + + {/* ========================================================= */} + {/* 그룹 추가 / 수정 모달 */} + {/* ========================================================= */} + setGroupModal({ open: false })} + okText={groupModal.open && groupModal.mode === 'create' ? '추가' : '수정'} + cancelText="취소" + confirmLoading={groupSaving} + destroyOnClose + width={480} + > +
+ + + + + + + + + + {groupModal.open && groupModal.mode === 'create' && ( + +
+ + + + + + + + + + + + + + + + + + + )} + + + {editState.row.isEncrypted === 'Y' && ( +
+ 암호화 항목입니다. 저장 시 서버에서 암호화 처리됩니다. +
+ )} + + )} + ); } diff --git a/ga-frontend/vite.config.ts b/ga-frontend/vite.config.ts index 5ec2b81..c0f8360 100644 --- a/ga-frontend/vite.config.ts +++ b/ga-frontend/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ port: 3000, proxy: { '/api': { - target: 'http://localhost:8080', + target: 'http://localhost:8082', changeOrigin: true, }, }, diff --git a/settings.gradle b/settings.gradle index 3e079c9..8744f9d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,7 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} + rootProject.name = 'ga-commission-system' include 'ga-common'