This commit is contained in:
ysukkyu
2026-05-14 18:14:49 +09:00
parent c0105c6847
commit 88e14edffd
50 changed files with 4870 additions and 172 deletions
+1 -1
View File
@@ -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:
@@ -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);
@@ -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<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
return ApiResponse.ok(installmentService.list(param));
}
@GetMapping("/{planId}")
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
public ApiResponse<InstallmentPlanResp> 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<Void> 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<Void> 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<Void> 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<Void> 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<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
}
@@ -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<PageResponse<InstallmentRatioResp>> 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<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
installmentService.createRatio(req);
return ApiResponse.ok();
@@ -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<PageResponse<SettleCorrectionResp>> 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<SettleCorrectionResp> 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<List<SettleCorrectionResp>> 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<Long> 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<Void> 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<Void> cancel(@PathVariable Long correctionId, @RequestBody Map<String, String> body) {
service.cancel(correctionId, body.get("cancelReason"));
return ApiResponse.ok();
@@ -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
@@ -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:
+2 -2
View File
@@ -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
@@ -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);
@@ -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
@@ -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
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
}
@@ -82,9 +82,6 @@
</if>
</where>
ORDER BY g.months_from, g.effective_from DESC
<if test="pageSize != null and pageSize > 0">
LIMIT #{pageSize} OFFSET #{offset}
</if>
</select>
<!-- 단건 조회 (Resp: 코드명 포함) -->
@@ -88,9 +88,6 @@
</if>
</where>
ORDER BY p.contract_id, p.plan_year, p.plan_month
<if test="pageSize != null and pageSize > 0">
LIMIT #{pageSize} OFFSET #{offset}
</if>
</select>
<!-- 단건 조회 (Resp: 조인 포함) -->
@@ -66,9 +66,6 @@
</if>
</where>
ORDER BY r.plan_year, r.effective_from DESC
<if test="pageSize != null and pageSize > 0">
LIMIT #{pageSize} OFFSET #{offset}
</if>
</select>
<!-- 단건 조회 (Resp: 코드명 포함) -->
@@ -81,9 +81,6 @@
</if>
</where>
ORDER BY r.limit_type, r.effective_from DESC
<if test="pageSize != null and pageSize > 0">
LIMIT #{pageSize} OFFSET #{offset}
</if>
</select>
<!-- 단건 조회 (Resp: 코드명 포함) -->
@@ -76,9 +76,6 @@
</if>
</where>
ORDER BY d.deduction_id DESC
<if test="pageSize != null and pageSize > 0">
LIMIT #{pageSize} OFFSET #{offset}
</if>
</select>
<!-- 단건 조회 (Resp: 조인 포함) -->
@@ -104,9 +104,6 @@
</if>
</where>
ORDER BY s.statement_id DESC
<if test="pageSize != null and pageSize > 0">
LIMIT #{pageSize} OFFSET #{offset}
</if>
</select>
<!-- 단건 조회 (Resp: 조인 포함) -->
-39
View File
@@ -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": [
+34 -16
View File
@@ -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() {
<Route path="contracts" element={<ContractList />} />
{/* 수수료 규정 */}
<Route path="rules/commission" element={<CommissionRateList />} />
<Route path="rules/payout" element={<PayoutRuleList />} />
<Route path="rules/override" element={<OverrideRuleList />} />
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
<Route path="rules/commission" element={<CommissionRateList />} />
<Route path="rules/payout" element={<PayoutRuleList />} />
<Route path="rules/override" element={<OverrideRuleList />} />
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
<Route path="rules/regulatory" element={<RegulatoryLimitList />} />
<Route path="rules/installment-ratios" element={<InstallmentRatioList />} />
<Route path="rules/chargeback-grades" element={<ChargebackGradeList />} />
{/* 데이터 수신 */}
<Route path="receive/data" element={<ReceiveData />} />
@@ -113,9 +125,15 @@ export default function App() {
<Route path="ledger/exception" element={<ExceptionLedger />} />
{/* 정산 / 지급 */}
<Route path="settle" element={<SettleList />} />
<Route path="settle/batch" element={<BatchRun />} />
<Route path="payments" element={<PaymentList />} />
<Route path="settle" element={<SettleList />} />
<Route path="settle/batch" element={<BatchRun />} />
<Route path="settle/deductions" element={<DeductionList />} />
<Route path="settle/period-close" element={<PeriodClose />} />
<Route path="settle/corrections" element={<SettleCorrection />} />
<Route path="settle/tax-invoices" element={<TaxInvoiceList />} />
<Route path="settle/disputes" element={<CommissionDispute />} />
<Route path="settle/incentives" element={<IncentiveProgram />} />
<Route path="payments" element={<PaymentList />} />
{/* 리포트 */}
<Route path="report/performance" element={<PerformanceReport />} />
+49
View File
@@ -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<PageResponse<ChargebackGradeResp>>(
api.get('/api/chargeback-grades', { params }),
),
get: (gradeId: number) =>
unwrap<ChargebackGradeResp>(api.get(`/api/chargeback-grades/${gradeId}`)),
create: (req: ChargebackGradeSaveReq) =>
unwrap<number>(api.post('/api/chargeback-grades', req)),
update: (gradeId: number, req: ChargebackGradeSaveReq) =>
unwrap<void>(api.put(`/api/chargeback-grades/${gradeId}`, req)),
deactivate: (gradeId: number) =>
unwrap<void>(api.put(`/api/chargeback-grades/${gradeId}/deactivate`)),
};
+53
View File
@@ -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<PageResponse<DeductionResp>>(api.get('/api/deductions', { params: p })),
get: (deductionId: number) =>
unwrap<DeductionResp>(api.get(`/api/deductions/${deductionId}`)),
create: (req: DeductionSaveReq) =>
unwrap<number>(api.post('/api/deductions', req)),
update: (deductionId: number, req: DeductionSaveReq) =>
unwrap<void>(api.put(`/api/deductions/${deductionId}`, req)),
cancel: (deductionId: number) =>
unwrap<void>(api.put(`/api/deductions/${deductionId}/cancel`)),
};
+66
View File
@@ -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<PageResponse<DisputeResp>>(api.get('/api/disputes', { params: p })),
get: (disputeId: number) =>
unwrap<DisputeResp>(api.get(`/api/disputes/${disputeId}`)),
create: (body: DisputeSaveReq) =>
unwrap<DisputeResp>(api.post('/api/disputes', body)),
review: (disputeId: number) =>
unwrap<void>(api.post(`/api/disputes/${disputeId}/review`)),
approve: (disputeId: number, resolution: string, adjustmentAmount?: number) =>
unwrap<void>(api.post(`/api/disputes/${disputeId}/approve`, { resolution, adjustmentAmount })),
reject: (disputeId: number, resolution: string) =>
unwrap<void>(api.post(`/api/disputes/${disputeId}/reject`, { resolution })),
};
+69
View File
@@ -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<PageResponse<IncentiveProgramResp>>(api.get('/api/incentive-programs', { params: p })),
get: (programId: number) =>
unwrap<IncentiveProgramResp>(api.get(`/api/incentive-programs/${programId}`)),
create: (body: IncentiveProgramSaveReq) =>
unwrap<IncentiveProgramResp>(api.post('/api/incentive-programs', body)),
update: (programId: number, body: IncentiveProgramSaveReq) =>
unwrap<IncentiveProgramResp>(api.put(`/api/incentive-programs/${programId}`, body)),
deactivate: (programId: number) =>
unwrap<void>(api.put(`/api/incentive-programs/${programId}/deactivate`)),
};
+36
View File
@@ -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<PageResponse<InstallmentRatioResp>>(
api.get('/api/installment-ratios', { params }),
),
create: (req: InstallmentRatioSaveReq) =>
unwrap<number>(api.post('/api/installment-ratios', req)),
};
+48
View File
@@ -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<PageResponse<PeriodCloseResp>>(api.get('/api/period-close', { params: p })),
get: (closeId: number) =>
unwrap<PeriodCloseResp>(api.get(`/api/period-close/${closeId}`)),
close: (req: PeriodCloseCreateReq) =>
unwrap<number>(api.post('/api/period-close', req)),
reopen: (closeId: number, req: PeriodReopenReq) =>
unwrap<void>(api.post(`/api/period-close/${closeId}/reopen`, req)),
};
+43
View File
@@ -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<RegulatoryLimitResp>(api.get(`/api/regulatory-limits/${id}`)),
create: (req: RegulatoryLimitSaveReq) =>
unwrap<void>(api.post('/api/regulatory-limits', req)),
update: (id: number, req: RegulatoryLimitSaveReq) =>
unwrap<void>(api.put(`/api/regulatory-limits/${id}`, req)),
deactivate: (id: number) =>
unwrap<void>(api.put(`/api/regulatory-limits/${id}/deactivate`)),
};
+64
View File
@@ -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<PageResponse<SettleCorrectionResp>>(api.get('/api/settle-corrections', { params: p })),
get: (correctionId: number) =>
unwrap<SettleCorrectionResp>(api.get(`/api/settle-corrections/${correctionId}`)),
create: (req: SettleCorrectionSaveReq) =>
unwrap<number>(api.post('/api/settle-corrections', req)),
approve: (correctionId: number) =>
unwrap<void>(api.put(`/api/settle-corrections/${correctionId}/approve`)),
cancel: (correctionId: number, cancelReason: string) =>
unwrap<void>(api.put(`/api/settle-corrections/${correctionId}/cancel`, { cancelReason })),
};
+66
View File
@@ -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<PageResponse<TaxInvoiceResp>>(api.get('/api/tax-invoices', { params: p })),
get: (invoiceId: number) =>
unwrap<TaxInvoiceResp>(api.get(`/api/tax-invoices/${invoiceId}`)),
create: (body: TaxInvoiceSaveReq) =>
unwrap<TaxInvoiceResp>(api.post('/api/tax-invoices', body)),
cancel: (invoiceId: number, cancelReason: string) =>
unwrap<void>(api.post(`/api/tax-invoices/${invoiceId}/cancel`, { cancelReason })),
};
+6 -2
View File
@@ -86,10 +86,14 @@ export const commonCodeApi = {
unwrap<void>(api.post('/api/common/codes/groups', vo)),
updateGroup: (groupCode: string, vo: Partial<CommonCodeGroup>) =>
unwrap<void>(api.put(`/api/common/codes/groups/${groupCode}`, vo)),
deleteGroup: (groupCode: string) =>
unwrap<void>(api.delete(`/api/common/codes/groups/${groupCode}`)),
createCode: (groupCode: string, vo: Partial<CommonCode>) =>
unwrap<void>(api.post(`/api/common/codes/groups/${groupCode}/codes`, vo)),
updateCode: (codeId: number, vo: Partial<CommonCode>) =>
unwrap<void>(api.put(`/api/common/codes/codes/${codeId}`, vo)),
unwrap<void>(api.put(`/api/common/codes/${codeId}`, vo)),
deleteCode: (codeId: number) =>
unwrap<void>(api.delete(`/api/common/codes/codes/${codeId}`)),
unwrap<void>(api.delete(`/api/common/codes/${codeId}`)),
refreshCache: (groupCode?: string) =>
unwrap<void>(api.post('/api/common/codes/refresh', null, { params: { groupCode } })),
};
@@ -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 (
<Tag
style={{
color: '#0F6E56',
background: '#E1F5EE',
border: '1px solid #B9E5D5',
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
</Tag>
);
}
return (
<Tag
style={{
color: '#666666',
background: '#F5F5F5',
border: '1px solid #DDDDDD',
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
</Tag>
);
}
export default function ChargebackGradeList() {
const actionRef = useRef<ActionType>();
const [searchParam, setSearchParam] = useState<ChargebackGradeSearchParam>({
pageNum: 1,
pageSize: 50,
});
const [modal, setModal] = useState<{
open: boolean;
editId?: number;
}>({ open: false });
const [editRecord, setEditRecord] = useState<ChargebackGradeResp | null>(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<ChargebackGradeResp>[] = [
{
title: '상품분류',
dataIndex: 'productCategory',
width: 130,
search: false,
render: (_, r) => r.productCategoryName ?? '전체',
},
{
title: '경과월 구간',
dataIndex: 'monthsRange',
width: 160,
search: false,
render: (_, r) => <strong>{r.monthsRange}</strong>,
},
{
title: '환수율(%)',
dataIndex: 'chargebackPercent',
width: 110,
align: 'right',
search: false,
render: (_, r) => (
<strong>
<MoneyText value={r.chargebackPercent} fraction={2} />
</strong>
),
},
{
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) => <ActiveBadge active={r.isActive} />,
},
{
title: '액션',
valueType: 'option',
width: 160,
fixed: 'right',
render: (_, r) =>
r.isActive
? [
<PermissionButton
key="edit"
menuCode={MENU_CODE}
permCode="UPDATE"
size="small"
type="link"
onClick={() => openEdit(r)}
>
</PermissionButton>,
<PermissionButton
key="deactivate"
menuCode={MENU_CODE}
permCode="UPDATE"
size="small"
type="link"
danger
onClick={() => onDeactivate(r)}
>
</PermissionButton>,
]
: [],
},
{
title: '상품분류 검색',
dataIndex: 'productCategory',
hideInTable: true,
valueType: 'select',
fieldProps: {
options: PRODUCT_CATEGORY_OPTIONS,
allowClear: true,
placeholder: '전체',
},
},
{
title: '기준일',
dataIndex: 'effectiveDate',
hideInTable: true,
valueType: 'date',
},
];
return (
<PageContainer
title="환수등급 (차등환수룰) 관리"
description="보험 해약 경과월에 따른 차등 환수율 구간 설정 (보험업감독규정)"
>
<ProTable<ChargebackGradeResp>
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={() => [
<PermissionButton
key="create"
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={openCreate}
>
+
</PermissionButton>,
]}
dateFormatter="string"
search={{
labelWidth: 'auto',
defaultCollapsed: false,
}}
/>
<ModalForm
title={modal.editId ? '환수등급 수정' : '환수등급 구간 등록'}
open={modal.open}
onOpenChange={(o) => !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 }}
>
<ProForm.Group>
<ProFormSelect
name="productCategory"
label="상품분류"
width="md"
placeholder="전체 (비워두면 전체 적용)"
options={PRODUCT_CATEGORY_OPTIONS}
allowClear
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="monthsFrom"
label="경과월 시작"
width="md"
rules={[
{ required: true, message: '경과월 시작을 입력하세요' },
{
validator: (_, v) =>
v >= 0
? Promise.resolve()
: Promise.reject('0 이상의 값을 입력하세요'),
},
]}
min={0}
max={9999}
fieldProps={{ precision: 0 }}
placeholder="예: 0"
/>
<ProFormDigit
name="monthsTo"
label="경과월 종료"
width="md"
min={0}
max={9999}
fieldProps={{ precision: 0 }}
placeholder="비워두면 무한대"
rules={[
{
validator: (_, v) => {
if (v == null || v === '') return Promise.resolve();
if (v >= 0) return Promise.resolve();
return Promise.reject('0 이상의 값을 입력하세요');
},
},
]}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="chargebackPercent"
label="환수율(%)"
width="md"
rules={[
{ required: true, message: '환수율을 입력하세요' },
{
validator: (_, v) =>
v >= 0
? Promise.resolve()
: Promise.reject('0 이상의 값을 입력하세요'),
},
]}
min={0}
max={100}
fieldProps={{ precision: 2, step: 0.01 }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker
name="effectiveFrom"
label="적용시작"
width="md"
rules={[{ required: true, message: '적용시작일을 선택하세요' }]}
/>
<ProFormDatePicker
name="effectiveTo"
label="적용종료"
width="md"
placeholder="비워두면 현재유효"
/>
</ProForm.Group>
</ModalForm>
</PageContainer>
);
}
@@ -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<ActionType>();
const [searchParam, setSearchParam] = useState<InstallmentRatioSearchParam>({
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<InstallmentRatioResp>[] = [
{
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) => (
<strong>
<MoneyText value={r.ratioPercent} fraction={2} />
</strong>
),
},
{
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 ? (
<Tag
style={{
color: '#0F6E56',
background: '#E1F5EE',
border: '1px solid #B9E5D5',
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
</Tag>
) : (
<Tag
style={{
color: '#666666',
background: '#F5F5F5',
border: '1px solid #DDDDDD',
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
</Tag>
),
},
{
title: '상품분류 검색',
dataIndex: 'productCategory',
hideInTable: true,
valueType: 'select',
fieldProps: {
options: PRODUCT_CATEGORY_OPTIONS,
allowClear: true,
placeholder: '전체',
},
},
];
return (
<PageContainer
title="분급비율 설정"
description="분급 비율 합계가 100%가 되도록 1~7차년도 각 비율 설정"
>
<ProTable<InstallmentRatioResp>
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={() => [
<PermissionButton
key="create"
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={openCreate}
>
+
</PermissionButton>,
]}
dateFormatter="string"
search={{
labelWidth: 'auto',
defaultCollapsed: false,
}}
/>
<ModalForm
title="분급비율 등록"
open={modalOpen}
onOpenChange={(o) => !o && setModalOpen(false)}
width={560}
layout="vertical"
key={modalOpen ? 'open' : 'closed'}
onFinish={onSubmit}
submitter={{ searchConfig: { submitText: '등록', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProForm.Group>
<ProFormSelect
name="productCategory"
label="상품분류"
width="md"
placeholder="전체 (비워두면 전체 적용)"
options={PRODUCT_CATEGORY_OPTIONS}
allowClear
/>
<ProFormSelect
name="planYear"
label="분급연차"
width="md"
rules={[{ required: true, message: '분급연차를 선택하세요' }]}
options={PLAN_YEAR_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="ratioPercent"
label="비율(%)"
width="md"
rules={[
{ required: true, message: '비율을 입력하세요' },
{
validator: (_, v) =>
v > 0 ? Promise.resolve() : Promise.reject('0보다 큰 값을 입력하세요'),
},
]}
min={0.01}
max={100}
fieldProps={{ precision: 2, step: 0.01 }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker
name="effectiveFrom"
label="적용시작"
width="md"
rules={[{ required: true, message: '적용시작일을 선택하세요' }]}
/>
<ProFormDatePicker
name="effectiveTo"
label="적용종료"
width="md"
placeholder="비워두면 현재유효"
/>
</ProForm.Group>
</ModalForm>
</PageContainer>
);
}
@@ -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<ActionType>();
const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false });
const [editRecord, setEditRecord] = useState<RegulatoryLimitResp | null>(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<RegulatoryLimitResp>[] = [
{
title: '한도유형', dataIndex: 'limitType', width: 160,
render: (_, r) => (
<Tag color={r.limitType === 'TOTAL_RATIO' ? 'red' : r.limitType === 'FIRST_YEAR_RATIO' ? 'orange' : 'blue'}>
{r.limitTypeName ?? r.limitType}
</Tag>
),
},
{ title: '상품분류', dataIndex: 'productCategoryName', width: 160, render: (_, r) => r.productCategoryName ?? '전체 상품' },
{
title: '한도값', dataIndex: 'limitValue', width: 110, align: 'right',
render: (_, r) => <strong>{Number(r.limitValue).toLocaleString()}</strong>,
},
{ 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
? <Badge status="success" text="활성" />
: <Badge status="default" text="비활성" />,
},
{
title: '액션', valueType: 'option', width: 130, fixed: 'right',
render: (_, r) => [
<PermissionButton key="edit" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link"
onClick={() => openEdit(r)}></PermissionButton>,
r.isActive && (
<PermissionButton key="deact" menuCode="REGULATORY_LIMIT" permCode="UPDATE" size="small" type="link" danger
onClick={() => onDeactivate(r)}></PermissionButton>
),
],
},
];
return (
<PageContainer
title="1200%룰 규제 한도 관리"
description="보험업감독규정 제5-15조의5 — 모집수수료 총 한도(1200%), 1차년도 한도(35%), 분급 연한(7년) 관리"
>
<ProTable<RegulatoryLimitResp>
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={() => [
<PermissionButton key="create" menuCode="REGULATORY_LIMIT" permCode="CREATE" type="primary"
onClick={openCreate}>+ </PermissionButton>,
]}
dateFormatter="string"
/>
<ModalForm
title={modal.editId ? '규제 한도 수정' : '규제 한도 등록'}
open={modal.open}
onOpenChange={(o) => !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 }}
>
<ProForm.Group>
<ProFormSelect
name="limitType" label="한도유형" rules={[{ required: true }]} width="md"
options={LIMIT_TYPE_OPTIONS}
/>
<ProFormSelect
name="productCategory" label="상품분류" width="md"
options={PRODUCT_CATEGORY_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="limitValue" label="한도값" rules={[{ required: true }]} width="md"
min={0} fieldProps={{ precision: 2 }}
/>
<ProFormSelect
name="unit" label="단위" rules={[{ required: true }]} width="md"
options={UNIT_OPTIONS}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDatePicker name="effectiveFrom" label="적용 시작일" rules={[{ required: true }]} width="md" />
<ProFormDatePicker name="effectiveTo" label="적용 종료일 (미입력 = 현재 유효)" width="md" />
</ProForm.Group>
<ProFormText
name="regulationRef" label="근거 법령" width="xl"
placeholder="예: 보험업감독규정 제5-15조의5"
/>
<ProFormTextArea name="description" label="설명" fieldProps={{ rows: 3 }} />
</ModalForm>
</PageContainer>
);
}
@@ -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<string, { color: string; label: string }> = {
PENDING: { color: 'orange', label: '대기' },
REVIEWING: { color: 'blue', label: '검토중' },
APPROVED: { color: 'green', label: '승인' },
REJECTED: { color: 'red', label: '반려' },
};
const STATUS_ENUM: Record<string, { text: string }> = {
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<ActionType>();
// 이의신청 등록 모달
const [registerOpen, setRegisterOpen] = useState(false);
const [registering, setRegistering] = useState(false);
const [registerForm] = Form.useForm<RegisterFormValues>();
// 승인 모달
const [approveOpen, setApproveOpen] = useState(false);
const [approving, setApproving] = useState(false);
const [approveTarget, setApproveTarget] = useState<DisputeResp | null>(null);
const [approveForm] = Form.useForm<{ resolution: string; adjustmentAmount?: number }>();
// 반려 모달
const [rejectOpen, setRejectOpen] = useState(false);
const [rejecting, setRejecting] = useState(false);
const [rejectTarget, setRejectTarget] = useState<DisputeResp | null>(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<typeof dayjs>[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<DisputeResp>[] = [
{
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) => <MoneyText value={r.disputedAmount} />,
},
{
title: '청구금액',
dataIndex: 'claimAmount',
width: 130,
align: 'right',
search: false,
render: (_, r) => <MoneyText value={r.claimAmount} />,
},
{
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 <Tag color={info.color}>{info.label}</Tag>;
},
},
{
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) => (
<Space>
{r.status === 'PENDING' && (
<PermissionButton
menuCode="DISPUTE"
permCode="UPDATE"
size="small"
type="default"
onClick={() => handleReview(r)}
>
</PermissionButton>
)}
{r.status === 'REVIEWING' && (
<>
<PermissionButton
menuCode="DISPUTE"
permCode="APPROVE"
size="small"
type="primary"
onClick={() => openApprove(r)}
>
</PermissionButton>
<PermissionButton
menuCode="DISPUTE"
permCode="APPROVE"
size="small"
danger
onClick={() => openReject(r)}
>
</PermissionButton>
</>
)}
</Space>
),
},
];
return (
<PageContainer
title="수수료 이의신청 관리"
description="설계사 수수료 이의신청 접수 · 검토 · 승인/반려"
>
<ProTable<DisputeResp, DisputeSearchParam>
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={() => [
<PermissionButton
key="register"
menuCode="DISPUTE"
permCode="CREATE"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
registerForm.resetFields();
setRegisterOpen(true);
}}
>
</PermissionButton>,
]}
/>
{/* ── 이의신청 등록 모달 ────────────────────────────────────────────────── */}
<Modal
title="이의신청 등록"
open={registerOpen}
onOk={handleRegister}
onCancel={() => setRegisterOpen(false)}
confirmLoading={registering}
okText="등록"
cancelText="닫기"
destroyOnClose
width={560}
>
<Form form={registerForm} layout="vertical" style={{ marginTop: 16 }}>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item
name="agentId"
label="설계사 ID"
rules={[{ required: true, message: '설계사 ID를 입력해주세요' }]}
style={{ flex: 1 }}
>
<InputNumber style={{ width: '100%' }} placeholder="설계사 ID" min={1} />
</Form.Item>
<Form.Item
name="settleMonthPicker"
label="정산월"
rules={[{ required: true, message: '정산월을 선택해주세요' }]}
style={{ flex: 1 }}
>
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="YYYYMM" />
</Form.Item>
</Space>
<Form.Item name="settleId" label="정산 ID (선택)">
<InputNumber style={{ width: '100%' }} placeholder="정산 ID" min={1} />
</Form.Item>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item name="disputedAmount" label="이의금액" style={{ flex: 1 }}>
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="이의금액"
min={0}
/>
</Form.Item>
<Form.Item name="claimAmount" label="청구금액" style={{ flex: 1 }}>
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="청구금액"
min={0}
/>
</Form.Item>
</Space>
<Form.Item
name="reason"
label="이의 사유"
rules={[{ required: true, message: '이의 사유를 입력해주세요' }]}
>
<Input.TextArea rows={3} placeholder="이의 사유를 상세히 입력해주세요" />
</Form.Item>
<Form.Item name="evidenceUrl" label="증빙 파일 URL">
<Input placeholder="https://..." />
</Form.Item>
</Form>
</Modal>
{/* ── 승인 모달 ──────────────────────────────────────────────────────────── */}
<Modal
title={`이의신청 승인 — ${approveTarget?.agentName ?? ''}`}
open={approveOpen}
onOk={handleApprove}
onCancel={() => setApproveOpen(false)}
confirmLoading={approving}
okText="승인"
okButtonProps={{ type: 'primary' }}
cancelText="닫기"
destroyOnClose
>
<Form form={approveForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="resolution"
label="처리 결과"
rules={[{ required: true, message: '처리 결과를 입력해주세요' }]}
>
<Input.TextArea rows={3} placeholder="처리 결과 내용" />
</Form.Item>
<Form.Item name="adjustmentAmount" label="조정 금액">
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="조정 금액 (선택)"
/>
</Form.Item>
</Form>
</Modal>
{/* ── 반려 모달 ──────────────────────────────────────────────────────────── */}
<Modal
title={`이의신청 반려 — ${rejectTarget?.agentName ?? ''}`}
open={rejectOpen}
onOk={handleReject}
onCancel={() => setRejectOpen(false)}
confirmLoading={rejecting}
okText="반려"
okButtonProps={{ danger: true }}
cancelText="닫기"
destroyOnClose
>
<Form form={rejectForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="resolution"
label="처리 결과"
rules={[{ required: true, message: '처리 결과를 입력해주세요' }]}
>
<Input.TextArea rows={3} placeholder="반려 사유를 입력해주세요" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -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<ActionType>();
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<string, unknown>) => {
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<DeductionResp>[] = [
{
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) => (
<CodeBadge groupCode="DEDUCTION_TYPE" value={r.deductionType} />
),
},
{
title: '금액',
dataIndex: 'amount',
width: 130,
align: 'right',
search: false,
render: (_, r) => <MoneyText value={r.amount} />,
},
{
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) => (
<CodeBadge groupCode="DEDUCTION_STATUS" value={r.status} />
),
},
{
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' ? (
<Space>
<PermissionButton
key="edit"
menuCode={MENU_CODE}
permCode="UPDATE"
size="small"
type="link"
onClick={() => openEdit(r)}
>
</PermissionButton>
<PermissionButton
key="cancel"
menuCode={MENU_CODE}
permCode="UPDATE"
size="small"
type="link"
danger
onClick={() => onCancel(r)}
>
</PermissionButton>
</Space>
) : null,
},
];
// ── 렌더링 ─────────────────────────────────────────────────────────────
return (
<PageContainer title="공제차감 관리" description="설계사별 정산월 공제 항목 등록 및 관리">
<ProTable<DeductionResp, DeductionSearchParam>
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={() => [
<PermissionButton
key="create"
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={openCreate}
>
+
</PermissionButton>,
]}
dateFormatter="string"
/>
{/* ── 등록/수정 모달 ─────────────────────────────────────────────── */}
<ModalForm
title={modal.editRecord ? '공제 수정' : '공제 등록'}
open={modal.open}
onOpenChange={(o) => !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 }}
>
<ProForm.Group>
<ProFormDigit
name="agentId"
label="설계사 ID"
rules={[{ required: true, message: '설계사 ID를 입력하세요' }]}
width="sm"
min={1}
fieldProps={{ precision: 0 }}
/>
<ProFormText
name="settleMonth"
label="정산월"
rules={[
{ required: true, message: '정산월을 입력하세요' },
{
validator: (_, v) =>
!v || isValidYYYYMM(v)
? Promise.resolve()
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
},
]}
width="sm"
placeholder="예: 202501"
fieldProps={{ maxLength: 6 }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormSelect
name="deductionType"
label="차감유형"
rules={[{ required: true, message: '차감유형을 선택하세요' }]}
width="md"
options={typeCodes.map((c) => ({ value: c.code, label: c.codeName }))}
/>
<ProFormDigit
name="amount"
label="금액"
rules={[
{ required: true, message: '금액을 입력하세요' },
{
validator: (_, v) =>
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, ','),
}}
/>
</ProForm.Group>
<ProFormText
name="description"
label="설명"
width="xl"
placeholder="공제 사유를 입력하세요"
/>
</ModalForm>
</PageContainer>
);
}
@@ -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<string, string> = {
AGENT: 'cyan',
ORG: 'purple',
GRADE: 'geekblue',
};
// ── 폼 내부 타입 ─────────────────────────────────────────────────────────────
interface ProgramFormValues extends Omit<IncentiveProgramSaveReq, 'startMonth' | 'endMonth'> {
startMonthPicker?: unknown;
endMonthPicker?: unknown;
}
export default function IncentiveProgram() {
const actionRef = useRef<ActionType>();
// 생성/수정 모달
const [formOpen, setFormOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [editTarget, setEditTarget] = useState<IncentiveProgramResp | null>(null);
const [form] = Form.useForm<ProgramFormValues>();
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<typeof dayjs>[0]).format('YYYYMM')
: '',
endMonth: values.endMonthPicker
? dayjs(values.endMonthPicker as Parameters<typeof dayjs>[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<IncentiveProgramResp>[] = [
{
title: '시책명',
dataIndex: 'programName',
width: 180,
fixed: 'left',
ellipsis: true,
search: false,
},
{
title: '유형',
dataIndex: 'programType',
width: 110,
search: false,
render: (_, r) => <Tag color="blue">{r.programTypeName ?? r.programType}</Tag>,
},
{
title: '대상구분',
dataIndex: 'targetType',
width: 90,
valueType: 'select',
valueEnum: Object.fromEntries(
TARGET_TYPE_OPTIONS.map((o) => [o.value, { text: o.label }]),
),
render: (_, r) => (
<Tag color={TARGET_TYPE_COLOR[r.targetType] ?? 'default'}>
{TARGET_TYPE_OPTIONS.find((o) => o.value === r.targetType)?.label ?? r.targetType}
</Tag>
),
},
{
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' ? (
<Tag color="gold"></Tag>
) : (
<Tag color="volcano"></Tag>
),
},
{
title: '지급률/지급액',
dataIndex: 'payRate',
width: 130,
align: 'right',
search: false,
render: (_, r) =>
r.payType === 'RATE' ? (
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
{r.payRate != null ? `${r.payRate}%` : '-'}
</span>
) : (
<MoneyText value={r.payAmountFixed} />
),
},
{
title: '예산',
dataIndex: 'budgetAmount',
width: 130,
align: 'right',
search: false,
render: (_, r) => <MoneyText value={r.budgetAmount} />,
},
{
title: '활성여부',
dataIndex: 'isActive',
width: 90,
search: false,
render: (_, r) =>
r.isActive ? (
<Badge status="success" text="활성" />
) : (
<Badge status="default" text="비활성" />
),
},
{
title: '액션',
dataIndex: 'programId',
width: 160,
fixed: 'right',
search: false,
render: (_, r) => (
<Space>
<PermissionButton
menuCode="INCENTIVE_PROG"
permCode="UPDATE"
size="small"
onClick={() => openEdit(r)}
>
</PermissionButton>
{r.isActive && (
<PermissionButton
menuCode="INCENTIVE_PROG"
permCode="UPDATE"
size="small"
danger
onClick={() => handleDeactivate(r)}
>
</PermissionButton>
)}
</Space>
),
},
];
return (
<PageContainer
title="시책 프로그램 관리"
description="설계사/조직/직급 대상 시책 프로그램 등록 및 관리"
>
<ProTable<IncentiveProgramResp, IncentiveProgramSearchParam>
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={() => [
<PermissionButton
key="create"
menuCode="INCENTIVE_PROG"
permCode="CREATE"
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
</PermissionButton>,
]}
/>
{/* ── 생성/수정 모달 ────────────────────────────────────────────────────── */}
<Modal
title={editTarget ? '시책 프로그램 수정' : '시책 프로그램 등록'}
open={formOpen}
onOk={handleSave}
onCancel={() => setFormOpen(false)}
confirmLoading={saving}
okText={editTarget ? '수정' : '등록'}
cancelText="닫기"
destroyOnClose
width={640}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="programName"
label="시책명"
rules={[{ required: true, message: '시책명을 입력해주세요' }]}
>
<Input placeholder="시책 프로그램명" />
</Form.Item>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item
name="programType"
label="시책 유형"
rules={[{ required: true, message: '시책 유형을 입력해주세요' }]}
style={{ flex: 1 }}
>
<Input placeholder="시책 유형 코드 (예: RECRUIT_BOOST)" />
</Form.Item>
<Form.Item
name="targetType"
label="대상 구분"
rules={[{ required: true, message: '대상 구분을 선택해주세요' }]}
style={{ flex: 1 }}
>
<Select placeholder="대상 구분" options={TARGET_TYPE_OPTIONS} />
</Form.Item>
</Space>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item name="targetOrgId" label="대상 조직 ID" style={{ flex: 1 }}>
<InputNumber style={{ width: '100%' }} placeholder="조직 ID (선택)" min={1} />
</Form.Item>
<Form.Item name="targetGradeId" label="대상 직급 ID" style={{ flex: 1 }}>
<InputNumber style={{ width: '100%' }} placeholder="직급 ID (선택)" min={1} />
</Form.Item>
</Space>
<Form.Item name="productCategory" label="상품 카테고리">
<Input placeholder="상품 카테고리 (선택)" />
</Form.Item>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item
name="startMonthPicker"
label="시작월"
rules={[{ required: true, message: '시작월을 선택해주세요' }]}
style={{ flex: 1 }}
>
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="YYYYMM" />
</Form.Item>
<Form.Item
name="endMonthPicker"
label="종료월"
rules={[{ required: true, message: '종료월을 선택해주세요' }]}
style={{ flex: 1 }}
>
<MonthPicker style={{ width: '100%' }} format="YYYYMM" placeholder="YYYYMM" />
</Form.Item>
</Space>
<Form.Item name="conditionDesc" label="조건 설명">
<Input.TextArea rows={2} placeholder="지급 조건 상세 설명" />
</Form.Item>
<Form.Item
name="payType"
label="지급 방식"
rules={[{ required: true, message: '지급 방식을 선택해주세요' }]}
>
<Radio.Group>
{PAY_TYPE_OPTIONS.map((o) => (
<Radio key={o.value} value={o.value}>
{o.label}
</Radio>
))}
</Radio.Group>
</Form.Item>
{payType === 'RATE' && (
<Form.Item
name="payRate"
label="지급률 (%)"
rules={[{ required: true, message: '지급률을 입력해주세요' }]}
>
<InputNumber
style={{ width: '100%' }}
min={0}
max={100}
precision={2}
placeholder="지급률 (예: 5.5)"
addonAfter="%"
/>
</Form.Item>
)}
{payType === 'FIXED_AMOUNT' && (
<Form.Item
name="payAmountFixed"
label="고정 지급액 (원)"
rules={[{ required: true, message: '고정 지급액을 입력해주세요' }]}
>
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="고정 지급액"
min={0}
addonAfter="원"
/>
</Form.Item>
)}
<Form.Item name="budgetAmount" label="예산 (원)">
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="예산 (선택)"
min={0}
addonAfter="원"
/>
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
@@ -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 (
<Tag
style={{
color: '#E24B4A',
background: '#FCEBEB',
border: '1px solid #F4C8C8',
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
</Tag>
);
}
if (status === 'REOPENED') {
return (
<Tag
style={{
color: '#BA7517',
background: '#FAEEDA',
border: '1px solid #F1D9A8',
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
fontSize: 12,
fontWeight: 500,
borderRadius: 4,
}}
>
</Tag>
);
}
return <Tag>{status}</Tag>;
}
// ── 컴포넌트 ──────────────────────────────────────────────────────────────
export default function PeriodClose() {
const actionRef = useRef<ActionType>();
// 마감 등록 모달
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<string, unknown>) => {
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<string, unknown>) => {
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<PeriodCloseResp>[] = [
{
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) => <CloseStatusTag status={r.closeStatus} />,
},
{
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' ? (
<PermissionButton
menuCode={MENU_CODE}
permCode="UPDATE"
size="small"
type="link"
onClick={() => setReopenModal({ open: true, record: r })}
>
</PermissionButton>
) : null,
},
];
// ── 렌더링 ─────────────────────────────────────────────────────────────
return (
<PageContainer title="정산 마감 관리" description="정산월 마감 처리 및 재오픈 관리">
<ProTable<PeriodCloseResp>
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={() => [
<PermissionButton
key="close"
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={() => setCloseModal(true)}
>
+
</PermissionButton>,
]}
dateFormatter="string"
/>
{/* ── 마감 등록 모달 ─────────────────────────────────────────────── */}
<ModalForm
title="마감 처리"
open={closeModal}
onOpenChange={(o) => !o && setCloseModal(false)}
width={440}
layout="vertical"
key={closeModal ? 'close-open' : 'close-closed'}
onFinish={onClose}
submitter={{ searchConfig: { submitText: '마감 처리', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProFormText
name="closeMonth"
label="정산월"
rules={[
{ required: true, message: '정산월을 입력하세요' },
{
validator: (_, v) =>
!v || isValidYYYYMM(v)
? Promise.resolve()
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
},
]}
width="md"
placeholder="예: 202501"
fieldProps={{ maxLength: 6 }}
/>
<ProFormTextArea
name="note"
label="비고"
width="xl"
placeholder="마감 관련 메모를 입력하세요 (선택)"
fieldProps={{ rows: 3 }}
/>
</ModalForm>
{/* ── 재오픈 모달 ────────────────────────────────────────────────── */}
<ModalForm
title={`재오픈 처리 — ${reopenModal.record?.closeMonth ?? ''}`}
open={reopenModal.open}
onOpenChange={(o) => !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 }}
>
<div style={{ marginBottom: 16, color: '#666', fontSize: 13 }}>
.
</div>
<ProFormTextArea
name="reopenReason"
label="재오픈 사유"
rules={[{ required: true, message: '재오픈 사유를 입력하세요' }]}
width="xl"
placeholder="재오픈 사유를 상세히 입력하세요"
fieldProps={{ rows: 4 }}
/>
</ModalForm>
</PageContainer>
);
}
@@ -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 <span style={{ color: '#888', fontVariantNumeric: 'tabular-nums' }}>0</span>;
const color = value > 0 ? '#185FA5' : '#E24B4A';
const sign = value > 0 ? '+' : '';
return (
<span style={{ color, fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
{sign}{value.toLocaleString()}
</span>
);
}
// ── 컴포넌트 ──────────────────────────────────────────────────────────────
export default function SettleCorrection() {
const actionRef = useRef<ActionType>();
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: (
<Input
placeholder="취소 사유를 입력하세요"
onChange={(e) => { 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<string, unknown>) => {
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<SettleCorrectionResp>[] = [
{
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) => <MoneyText value={r.beforeAmount} />,
},
{
title: '정정후금액',
dataIndex: 'afterAmount',
width: 130,
align: 'right',
search: false,
render: (_, r) => <MoneyText value={r.afterAmount} />,
},
{
title: '차액',
dataIndex: 'diffAmount',
width: 120,
align: 'right',
search: false,
render: (_, r) => <DiffAmountCell value={r.diffAmount} />,
},
{
title: '사유',
dataIndex: 'reasonCode',
width: 120,
valueType: 'select',
valueEnum: Object.fromEntries(
reasonCodes.map((c) => [c.code, { text: c.codeName }]),
),
render: (_, r) => (
<CodeBadge groupCode="CORRECTION_REASON" value={r.reasonCode} />
),
},
{
title: '상태',
dataIndex: 'status',
width: 100,
valueType: 'select',
valueEnum: Object.fromEntries(
statusCodes.map((c) => [c.code, { text: c.codeName }]),
),
render: (_, r) => (
<CodeBadge groupCode="CORRECTION_STATUS" value={r.status} />
),
},
{
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' ? (
<Space>
<PermissionButton
key="approve"
menuCode={MENU_CODE}
permCode="APPROVE"
size="small"
type="primary"
onClick={() => onApprove(r)}
>
</PermissionButton>
<PermissionButton
key="cancel"
menuCode={MENU_CODE}
permCode="UPDATE"
size="small"
danger
onClick={() => onCancel(r)}
>
</PermissionButton>
</Space>
) : null,
},
];
// ── 렌더링 ─────────────────────────────────────────────────────────────
return (
<PageContainer title="정산 정정 관리" description="설계사 정산 금액 정정 요청 및 승인 관리">
<ProTable<SettleCorrectionResp, SettleCorrectionSearchParam>
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={() => [
<PermissionButton
key="create"
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={() => setCreateModal(true)}
>
+
</PermissionButton>,
]}
dateFormatter="string"
/>
{/* ── 정정 등록 모달 ─────────────────────────────────────────────── */}
<ModalForm
title="정산 정정 등록"
open={createModal}
onOpenChange={(o) => !o && setCreateModal(false)}
width={560}
layout="vertical"
key={createModal ? 'correction-open' : 'correction-closed'}
onFinish={onSubmit}
submitter={{ searchConfig: { submitText: '등록', resetText: '취소' } }}
modalProps={{ destroyOnClose: true, maskClosable: false }}
>
<ProForm.Group>
<ProFormDigit
name="originalSettleId"
label="원 정산 ID"
rules={[{ required: true, message: '원 정산 ID를 입력하세요' }]}
width="sm"
min={1}
fieldProps={{ precision: 0 }}
/>
<ProFormDigit
name="agentId"
label="설계사 ID"
rules={[{ required: true, message: '설계사 ID를 입력하세요' }]}
width="sm"
min={1}
fieldProps={{ precision: 0 }}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText
name="settleMonth"
label="정산월"
rules={[
{ required: true, message: '정산월을 입력하세요' },
{
validator: (_, v) =>
!v || isValidYYYYMM(v)
? Promise.resolve()
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
},
]}
width="sm"
placeholder="예: 202501"
fieldProps={{ maxLength: 6 }}
/>
<ProFormSelect
name="reasonCode"
label="사유코드"
rules={[{ required: true, message: '사유코드를 선택하세요' }]}
width="md"
options={reasonCodes.map((c) => ({ value: c.code, label: c.codeName }))}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormDigit
name="beforeAmount"
label="정정전금액"
rules={[{ required: true, message: '정정전금액을 입력하세요' }]}
width="md"
fieldProps={{
precision: 0,
formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
}}
/>
<ProFormDigit
name="afterAmount"
label="정정후금액"
rules={[{ required: true, message: '정정후금액을 입력하세요' }]}
width="md"
fieldProps={{
precision: 0,
formatter: (v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
}}
/>
</ProForm.Group>
<ProFormTextArea
name="reasonDetail"
label="정정 상세사유"
width="xl"
placeholder="정정 사유를 상세히 입력하세요 (선택)"
fieldProps={{ rows: 4 }}
/>
</ModalForm>
</PageContainer>
);
}
@@ -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<string, { color: string; label: string }> = {
SUPPLY: { color: 'blue', label: '매출(공급)' },
PURCHASE: { color: 'orange', label: '매입(구매)' },
};
export default function TaxInvoiceList() {
const actionRef = useRef<ActionType>();
// 발행 모달
const [issueOpen, setIssueOpen] = useState(false);
const [issuing, setIssuing] = useState(false);
const [issueForm] = Form.useForm<TaxInvoiceSaveReq & { issueDatePicker?: unknown; settleMonthPicker?: unknown }>();
// 취소 모달
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [cancelTarget, setCancelTarget] = useState<TaxInvoiceResp | null>(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<typeof dayjs>[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<typeof dayjs>[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<TaxInvoiceResp>[] = [
{
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 <Tag color={info.color}>{info.label}</Tag>;
},
},
{
title: '발행일',
dataIndex: 'issueDate',
width: 110,
search: false,
},
{
title: '공급가액',
dataIndex: 'supplyAmount',
width: 130,
align: 'right',
search: false,
render: (_, r) => <MoneyText value={r.supplyAmount} />,
},
{
title: '세액',
dataIndex: 'taxAmount',
width: 110,
align: 'right',
search: false,
render: (_, r) => <MoneyText value={r.taxAmount} />,
},
{
title: '합계금액',
dataIndex: 'totalAmount',
width: 140,
align: 'right',
search: false,
render: (_, r) => (
<strong>
<MoneyText value={r.totalAmount} />
</strong>
),
},
{
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 ? (
<Tag color="red"></Tag>
) : (
<Tag color="green"></Tag>
),
},
{
title: '액션',
dataIndex: 'invoiceId',
width: 90,
fixed: 'right',
search: false,
render: (_, r) =>
r.cancelDate ? null : (
<PermissionButton
menuCode="TAX_INVOICE"
permCode="UPDATE"
size="small"
danger
onClick={() => openCancel(r)}
>
</PermissionButton>
),
},
];
return (
<PageContainer
title="세금계산서 관리"
description="매출·매입 세금계산서 발행 및 취소 관리"
>
<ProTable<TaxInvoiceResp, TaxInvoiceSearchParam>
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={() => [
<PermissionButton
key="issue"
menuCode="TAX_INVOICE"
permCode="CREATE"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
issueForm.resetFields();
setIssueOpen(true);
}}
>
</PermissionButton>,
]}
/>
{/* ── 발행 모달 ──────────────────────────────────────────────────────────── */}
<Modal
title="세금계산서 발행"
open={issueOpen}
onOk={handleIssue}
onCancel={() => setIssueOpen(false)}
confirmLoading={issuing}
okText="발행"
cancelText="닫기"
destroyOnClose
width={640}
>
<Form form={issueForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="invoiceNo"
label="발행번호"
rules={[{ required: true, message: '발행번호를 입력해주세요' }]}
>
<Input placeholder="발행번호" />
</Form.Item>
<Form.Item
name="invoiceType"
label="유형"
rules={[{ required: true, message: '유형을 선택해주세요' }]}
>
<Select placeholder="유형 선택" options={INVOICE_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="issueDatePicker"
label="발행일"
rules={[{ required: true, message: '발행일을 선택해주세요' }]}
>
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
</Form.Item>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item
name="supplyAmount"
label="공급가액"
rules={[{ required: true, message: '공급가액을 입력해주세요' }]}
style={{ flex: 1 }}
>
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="공급가액"
min={0}
/>
</Form.Item>
<Form.Item
name="taxAmount"
label="세액"
rules={[{ required: true, message: '세액을 입력해주세요' }]}
style={{ flex: 1 }}
>
<InputNumber
style={{ width: '100%' }}
formatter={(v) => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number(v?.replace(/,/g, '') ?? 0)}
placeholder="세액"
min={0}
/>
</Form.Item>
</Space>
<Form.Item
name="counterpartName"
label="거래처명"
rules={[{ required: true, message: '거래처명을 입력해주세요' }]}
>
<Input placeholder="거래처명" />
</Form.Item>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item
name="counterpartBizNo"
label="사업자번호"
rules={[{ required: true, message: '사업자번호를 입력해주세요' }]}
style={{ flex: 1 }}
>
<Input placeholder="000-00-00000" />
</Form.Item>
<Form.Item
name="counterpartRep"
label="대표자명"
style={{ flex: 1 }}
>
<Input placeholder="대표자명" />
</Form.Item>
</Space>
<Form.Item name="counterpartAddr" label="거래처 주소">
<Input placeholder="거래처 주소" />
</Form.Item>
<Space style={{ width: '100%', display: 'flex', gap: 8 }}>
<Form.Item name="agentId" label="설계사 ID" style={{ flex: 1 }}>
<InputNumber
style={{ width: '100%' }}
placeholder="설계사 ID"
min={1}
/>
</Form.Item>
<Form.Item name="settleMonthPicker" label="정산월" style={{ flex: 1 }}>
<MonthPicker
style={{ width: '100%' }}
format="YYYYMM"
placeholder="정산월 (선택)"
/>
</Form.Item>
</Space>
<Form.Item name="description" label="비고">
<Input.TextArea rows={2} placeholder="비고" />
</Form.Item>
</Form>
</Modal>
{/* ── 취소 모달 ──────────────────────────────────────────────────────────── */}
<Modal
title={`세금계산서 취소 — ${cancelTarget?.invoiceNo ?? ''}`}
open={cancelOpen}
onOk={handleCancel}
onCancel={() => setCancelOpen(false)}
confirmLoading={cancelling}
okText="취소 처리"
okButtonProps={{ danger: true }}
cancelText="닫기"
destroyOnClose
>
<Form form={cancelForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="cancelReason"
label="취소 사유"
rules={[{ required: true, message: '취소 사유를 입력해주세요' }]}
>
<Input.TextArea rows={3} placeholder="취소 사유를 입력해주세요" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
+500 -41
View File
@@ -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<CommonCodeGroup | null>(null);
const qc = useQueryClient();
const { data: groups = [] } = useQuery({
queryKey: ['code', 'groups'],
queryFn: () => commonCodeApi.groups(),
/* 선택 그룹 & 검색어 */
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
const [keyword, setKeyword] = useState('');
/* 모달 상태 */
const [groupModal, setGroupModal] = useState<GroupModalState>({ open: false });
const [codeModal, setCodeModal] = useState<CodeModalState>({ 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: (
<span>
<b>{group.groupName}</b> .
<br />?
</span>
),
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: (
<span>
<b>{code.code} ({code.codeName})</b>() .
<br />?
</span>
),
okText: '삭제',
okType: 'danger',
cancelText: '취소',
onOk: async () => {
await commonCodeApi.deleteCode(code.codeId);
message.success('코드가 삭제되었습니다.');
if (selected) {
await commonCodeApi.refreshCache(selected.groupCode);
invalidateCodes(selected.groupCode);
}
},
});
};
/* ---------------------------------------------------------------- */
/* 렌더 */
/* ---------------------------------------------------------------- */
return (
<PageContainer title="공통코드 관리">
<Row gutter={16}>
{/* ======================================================= */}
{/* 왼쪽: 그룹 목록 */}
{/* ======================================================= */}
<Col span={10}>
<Card title="그룹" size="small">
<Table
<Card
size="small"
title="코드 그룹"
extra={
<Space size={4}>
<PermissionButton
menuCode={MENU}
permCode="CREATE"
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={openGroupCreate}
>
</PermissionButton>
<PermissionButton
menuCode={MENU}
permCode="READ"
size="small"
icon={<ReloadOutlined />}
onClick={invalidateGroups}
/>
</Space>
}
>
<Input.Search
placeholder="그룹코드 / 그룹명 검색"
allowClear
size="small"
style={{ marginBottom: 8 }}
onSearch={(v) => setKeyword(v)}
onChange={(e) => { if (!e.target.value) setKeyword(''); }}
/>
<Table<CommonCodeGroup>
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' ? <Tag color="orange"></Tag> : <Tag></Tag>,
title: '유형',
dataIndex: 'isSystem',
width: 68,
align: 'center',
render: (v: string) =>
v === 'Y' ? (
<Tag color="orange" style={{ margin: 0, fontSize: 11 }}></Tag>
) : (
<Tag style={{ margin: 0, fontSize: 11 }}></Tag>
),
},
{
title: '작업',
key: 'actions',
width: 72,
align: 'center',
render: (_, r) => (
<Space size={2} onClick={(e) => e.stopPropagation()}>
<PermissionButton
menuCode={MENU}
permCode="UPDATE"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openGroupEdit(r)}
/>
<PermissionButton
menuCode={MENU}
permCode="DELETE"
type="text"
size="small"
danger
disabled={r.isSystem === 'Y'}
icon={<DeleteOutlined />}
onClick={() => confirmDeleteGroup(r)}
/>
</Space>
),
},
]}
/>
</Card>
</Col>
{/* ======================================================= */}
{/* 오른쪽: 코드 목록 */}
{/* ======================================================= */}
<Col span={14}>
<Card title={selected ? `상세 코드 — ${selected.groupName}` : '상세 코드'} size="small">
<Card
size="small"
title={selected ? `코드 목록 — ${selected.groupName}` : '코드 목록'}
extra={
selected && (
<PermissionButton
menuCode={MENU}
permCode="CREATE"
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={openCodeCreate}
>
</PermissionButton>
)
}
>
{!selected ? (
<Empty description="그룹을 선택하세요" />
<Empty description="왼쪽에서 그룹을 선택하세요" style={{ padding: '40px 0' }} />
) : (
<Table
rowKey="codeId"
dataSource={codes}
size="small"
pagination={false}
scroll={{ y: 600 }}
columns={[
{ title: '코드', dataIndex: 'code', width: 120 },
{ title: '코드명', dataIndex: 'codeName' },
{ title: '설명', dataIndex: 'codeDesc' },
{ title: '정렬', dataIndex: 'sortOrder', width: 60, align: 'right' },
{
title: '활성', dataIndex: 'isActive', width: 70, align: 'center',
render: (v) => v === 'Y' ? <Tag color="green">Y</Tag> : <Tag>N</Tag>,
},
]}
/>
)}
{selected?.isSystem === 'Y' && (
<Text type="warning" style={{ display: 'block', marginTop: 8 }}>
/ .
</Text>
<>
<Table<CommonCode>
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' ? (
<Tag color="green" style={{ margin: 0 }}>Y</Tag>
) : (
<Tag style={{ margin: 0 }}>N</Tag>
),
},
{
title: '작업',
key: 'actions',
width: 72,
align: 'center',
render: (_, r) => (
<Space size={2}>
<PermissionButton
menuCode={MENU}
permCode="UPDATE"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openCodeEdit(r)}
/>
<PermissionButton
menuCode={MENU}
permCode="DELETE"
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => confirmDeleteCode(r)}
/>
</Space>
),
},
]}
/>
{selected.isSystem === 'Y' && (
<Text type="warning" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
· .
</Text>
)}
</>
)}
</Card>
</Col>
</Row>
{/* ========================================================= */}
{/* 그룹 추가 / 수정 모달 */}
{/* ========================================================= */}
<Modal
title={groupModal.open && groupModal.mode === 'create' ? '그룹 추가' : '그룹 수정'}
open={groupModal.open}
onOk={handleGroupSave}
onCancel={() => setGroupModal({ open: false })}
okText={groupModal.open && groupModal.mode === 'create' ? '추가' : '수정'}
cancelText="취소"
confirmLoading={groupSaving}
destroyOnClose
width={480}
>
<Form form={groupForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="groupCode"
label="그룹코드"
rules={[
{ required: true, message: '그룹코드를 입력하세요' },
{ pattern: /^[A-Z0-9_]+$/, message: '영문 대문자, 숫자, 밑줄만 사용 가능합니다' },
]}
>
<Input
placeholder="예) AGENT_STATUS"
disabled={groupModal.open && groupModal.mode === 'edit'}
/>
</Form.Item>
<Form.Item
name="groupName"
label="그룹명"
rules={[{ required: true, message: '그룹명을 입력하세요' }]}
>
<Input placeholder="예) 설계사 상태" />
</Form.Item>
<Form.Item name="description" label="설명">
<Input.TextArea rows={2} placeholder="그룹에 대한 설명을 입력하세요" />
</Form.Item>
{groupModal.open && groupModal.mode === 'create' && (
<Row gutter={12}>
<Col span={12}>
<Form.Item name="isSystem" label="시스템 여부">
<Select options={[{ value: 'Y', label: '시스템' }, { value: 'N', label: '일반' }]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="sortOrder" label="정렬 순서">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
)}
</Form>
</Modal>
{/* ========================================================= */}
{/* 코드 추가 / 수정 모달 */}
{/* ========================================================= */}
<Modal
title={codeModal.open && codeModal.mode === 'create' ? '코드 추가' : '코드 수정'}
open={codeModal.open}
onOk={handleCodeSave}
onCancel={() => setCodeModal({ open: false })}
okText={codeModal.open && codeModal.mode === 'create' ? '추가' : '수정'}
cancelText="취소"
confirmLoading={codeSaving}
destroyOnClose
width={460}
>
<Form form={codeForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="code"
label="코드값"
rules={[
{ required: true, message: '코드값을 입력하세요' },
{ pattern: /^[A-Z0-9_]+$/, message: '영문 대문자, 숫자, 밑줄만 사용 가능합니다' },
]}
>
<Input
placeholder="예) ACTIVE"
disabled={codeModal.open && codeModal.mode === 'edit'}
/>
</Form.Item>
<Form.Item
name="codeName"
label="코드명"
rules={[{ required: true, message: '코드명을 입력하세요' }]}
>
<Input placeholder="예) 활성" />
</Form.Item>
<Form.Item name="codeDesc" label="설명">
<Input.TextArea rows={2} placeholder="코드에 대한 설명을 입력하세요" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="sortOrder" label="정렬 순서">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="isActive" label="활성 여부">
<Select
options={[
{ value: 'Y', label: '활성' },
{ value: 'N', label: '비활성' },
]}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</PageContainer>
);
}
+208 -21
View File
@@ -1,54 +1,241 @@
import { Tag } from 'antd';
import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { useRef, useState } from 'react';
import { Form, Input, Modal, Tag, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { ProTable, type ProColumns, type ActionType } from '@ant-design/pro-components';
import PageContainer from '@/components/common/PageContainer';
import PermissionButton from '@/components/common/PermissionButton';
import { configApi, SystemConfigResp } from '@/api/config';
const MENU = 'SYSTEM_CONFIG';
const GROUP_COLOR: Record<string, string> = {
GENERAL: 'blue', BATCH: 'cyan', SECURITY: 'red', MAIL: 'green', TAX: 'orange',
GENERAL: 'blue',
BATCH: 'cyan',
SECURITY: 'red',
MAIL: 'green',
TAX: 'orange',
};
/* ------------------------------------------------------------------ */
/* 수정 모달 상태 타입 */
/* ------------------------------------------------------------------ */
type EditState =
| { open: false }
| { open: true; row: SystemConfigResp };
/* ------------------------------------------------------------------ */
/* 메인 컴포넌트 */
/* ------------------------------------------------------------------ */
export default function SystemConfig() {
const actionRef = useRef<ActionType>();
const [form] = Form.useForm<{ value: string }>();
const [editState, setEditState] = useState<EditState>({ open: false });
const [saving, setSaving] = useState(false);
/* ---------------------------------------------------------------- */
/* 수정 모달 열기 */
/* ---------------------------------------------------------------- */
const openEdit = (row: SystemConfigResp) => {
form.resetFields();
// 암호화 항목은 현재값을 초기화하지 않음
form.setFieldsValue({ value: row.isEncrypted === 'Y' ? '' : (row.configValue ?? '') });
setEditState({ open: true, row });
};
/* ---------------------------------------------------------------- */
/* 저장 처리 */
/* ---------------------------------------------------------------- */
const handleSave = async () => {
if (!editState.open) return;
try {
setSaving(true);
const values = await form.validateFields();
await configApi.update(editState.row.configKey!, values.value);
message.success('설정 값이 수정되었습니다.');
setEditState({ open: false });
actionRef.current?.reload();
} catch {
// validateFields 실패 — 폼 에러 표시로 대체
} finally {
setSaving(false);
}
};
/* ---------------------------------------------------------------- */
/* 컬럼 정의 */
/* ---------------------------------------------------------------- */
const columns: ProColumns<SystemConfigResp>[] = [
{
title: '그룹', dataIndex: 'configGroup', width: 110, valueType: 'select',
title: '그룹',
dataIndex: 'configGroup',
width: 110,
valueType: 'select',
valueEnum: {
GENERAL: { text: '일반' }, BATCH: { text: '배치' }, SECURITY: { text: '보안' },
MAIL: { text: '메일' }, TAX: { text: '세금' },
GENERAL: { text: '일반' },
BATCH: { text: '배치' },
SECURITY: { text: '보안' },
MAIL: { text: '메일' },
TAX: { text: '세금' },
},
render: (_, r) => <Tag color={GROUP_COLOR[r.configGroup ?? ''] ?? 'default'}>
{r.configGroup}</Tag>,
render: (_, r) => (
<Tag color={GROUP_COLOR[r.configGroup ?? ''] ?? 'default'}>
{r.configGroup}
</Tag>
),
},
{ title: '설정 키', dataIndex: 'configKey', width: 220, search: false,
render: (_, r) => <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>
{r.configKey}</code> },
{
title: '설정 값', dataIndex: 'configValue', search: false,
render: (_, r) => r.isEncrypted === 'Y' ? <Tag color="red"> (***)</Tag> : <span>{r.configValue}</span>,
title: '설정 키',
dataIndex: 'configKey',
width: 240,
search: false,
render: (_, r) => (
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4, fontSize: 12 }}>
{r.configKey}
</code>
),
},
{ title: '타입', dataIndex: 'valueType', width: 90, search: false },
{ title: '설명', dataIndex: 'configDesc', ellipsis: true, search: false },
{
title: '가능', dataIndex: 'isEditable', width: 100, align: 'center', search: false,
render: (_, r) => r.isEditable === 'Y' ? <Tag color="success"></Tag> : <Tag></Tag>,
title: '값',
dataIndex: 'configValue',
search: false,
render: (_, r) =>
r.isEncrypted === 'Y' ? (
<Tag color="red"> (***)</Tag>
) : (
<span>{r.configValue}</span>
),
},
{
title: '타입',
dataIndex: 'valueType',
width: 90,
search: false,
},
{
title: '설명',
dataIndex: 'configDesc',
ellipsis: true,
search: false,
},
{
title: '수정 가능',
dataIndex: 'isEditable',
width: 100,
align: 'center',
search: false,
render: (_, r) =>
r.isEditable === 'Y' ? (
<Tag color="success"></Tag>
) : (
<Tag></Tag>
),
},
{
title: '수정일시',
dataIndex: 'updatedAt',
width: 160,
search: false,
},
{
title: '작업',
key: 'actions',
width: 80,
align: 'center',
search: false,
render: (_, r) => {
if (r.isEditable !== 'Y') return null;
return (
<PermissionButton
menuCode={MENU}
permCode="UPDATE"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(r)}
>
</PermissionButton>
);
},
},
{ title: '수정일시', dataIndex: 'updatedAt', width: 160, search: false },
];
/* ---------------------------------------------------------------- */
/* 렌더 */
/* ---------------------------------------------------------------- */
return (
<PageContainer title="시스템 설정" description="배치 / 보안 / 세금 / 메일 등 키-값 설정">
<PageContainer
title="시스템 설정"
description="배치 / 보안 / 세금 / 메일 등 키-값 설정"
>
<ProTable<SystemConfigResp>
actionRef={actionRef}
rowKey="configKey"
columns={columns}
scroll={{ x: 1200 }}
request={async (params) => {
const list = await configApi.list((params as any).configGroup);
const list = await configApi.list((params as Record<string, string>).configGroup);
return { data: list, total: list.length, success: true };
}}
pagination={{ pageSize: 50, showTotal: (t) => `${t}` }}
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
search={{
labelWidth: 'auto',
searchText: '검색',
resetText: '초기화',
collapsed: false,
collapseRender: false,
}}
options={{ density: false, fullScreen: true, reload: true, setting: true }}
dateFormatter="string"
/>
{/* ========================================================= */}
{/* 수정 모달 */}
{/* ========================================================= */}
<Modal
title="설정 값 수정"
open={editState.open}
onOk={handleSave}
onCancel={() => setEditState({ open: false })}
okText="저장"
cancelText="취소"
confirmLoading={saving}
destroyOnClose
width={440}
>
{editState.open && (
<>
<div style={{ marginBottom: 16, padding: '8px 12px', background: '#fafafa', borderRadius: 6, border: '1px solid #f0f0f0' }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}> </div>
<code style={{ fontSize: 13 }}>{editState.row.configKey}</code>
{editState.row.configDesc && (
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>{editState.row.configDesc}</div>
)}
</div>
<Form form={form} layout="vertical">
<Form.Item
name="value"
label="새 값"
rules={[{ required: true, message: '값을 입력하세요' }]}
>
{editState.row.isEncrypted === 'Y' ? (
<Input.Password
placeholder="새 비밀번호 / 암호화 값 입력"
autoComplete="new-password"
/>
) : (
<Input placeholder="새 값 입력" />
)}
</Form.Item>
</Form>
{editState.row.isEncrypted === 'Y' && (
<div style={{ fontSize: 12, color: '#faad14', marginTop: -8 }}>
. .
</div>
)}
</>
)}
</Modal>
</PageContainer>
);
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://localhost:8082',
changeOrigin: true,
},
},
+4
View File
@@ -1,3 +1,7 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
}
rootProject.name = 'ga-commission-system'
include 'ga-common'