1
This commit is contained in:
+1
-1
@@ -46,7 +46,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
|
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
|
||||||
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
|
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8082:8082"
|
||||||
|
|
||||||
batch:
|
batch:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package com.ga.api;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GA 수수료 정산 API 서버.
|
* GA 수수료 정산 API 서버.
|
||||||
* 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함)
|
* 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함)
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
||||||
|
@EnableCaching
|
||||||
public class GaApiApplication {
|
public class GaApiApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(GaApiApplication.class, args);
|
SpringApplication.run(GaApiApplication.class, args);
|
||||||
|
|||||||
@@ -24,20 +24,20 @@ public class InstallmentController {
|
|||||||
private final InstallmentService installmentService;
|
private final InstallmentService installmentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
|
||||||
public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
|
public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
|
||||||
return ApiResponse.ok(installmentService.list(param));
|
return ApiResponse.ok(installmentService.list(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{planId}")
|
@GetMapping("/{planId}")
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
|
||||||
public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) {
|
public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) {
|
||||||
return ApiResponse.ok(installmentService.detail(planId));
|
return ApiResponse.ok(installmentService.detail(planId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/generate")
|
@PostMapping("/generate")
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
public ApiResponse<Void> generate(@Valid @RequestBody GeneratePlanReq req) {
|
public ApiResponse<Void> generate(@Valid @RequestBody GeneratePlanReq req) {
|
||||||
installmentService.createPlan(
|
installmentService.createPlan(
|
||||||
req.getContractId(),
|
req.getContractId(),
|
||||||
@@ -49,8 +49,8 @@ public class InstallmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{planId}/pay")
|
@PutMapping("/{planId}/pay")
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
public ApiResponse<Void> pay(@PathVariable Long planId,
|
public ApiResponse<Void> pay(@PathVariable Long planId,
|
||||||
@Valid @RequestBody PayPlanReq req) {
|
@Valid @RequestBody PayPlanReq req) {
|
||||||
installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount());
|
installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount());
|
||||||
@@ -58,23 +58,23 @@ public class InstallmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{planId}/defer")
|
@PutMapping("/{planId}/defer")
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
public ApiResponse<Void> defer(@PathVariable Long planId) {
|
public ApiResponse<Void> defer(@PathVariable Long planId) {
|
||||||
installmentService.deferPlan(planId);
|
installmentService.deferPlan(planId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{planId}/cancel")
|
@PutMapping("/{planId}/cancel")
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
public ApiResponse<Void> cancel(@PathVariable Long planId) {
|
public ApiResponse<Void> cancel(@PathVariable Long planId) {
|
||||||
installmentService.cancelPlan(planId);
|
installmentService.cancelPlan(planId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/scheduled")
|
@GetMapping("/scheduled")
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
|
||||||
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
|
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
|
||||||
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
|
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -22,14 +22,14 @@ public class InstallmentRatioController {
|
|||||||
private final InstallmentService installmentService;
|
private final InstallmentService installmentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
|
@RequirePermission(menu = "INSTALLMENT_RATIO", perm = "READ")
|
||||||
public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
|
public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
|
||||||
return ApiResponse.ok(installmentService.listRatios(param));
|
return ApiResponse.ok(installmentService.listRatios(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
|
@RequirePermission(menu = "INSTALLMENT_RATIO", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio")
|
@DataChangeLog(menu = "INSTALLMENT_RATIO", table = "installment_ratio")
|
||||||
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
|
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
|
||||||
installmentService.createRatio(req);
|
installmentService.createRatio(req);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
|
|||||||
@@ -25,41 +25,41 @@ public class SettleCorrectionController {
|
|||||||
private final SettleCorrectionService service;
|
private final SettleCorrectionService service;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "SETTLE_CORR", perm = "READ")
|
@RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ")
|
||||||
public ApiResponse<PageResponse<SettleCorrectionResp>> list(@ModelAttribute SettleCorrectionSearchParam param) {
|
public ApiResponse<PageResponse<SettleCorrectionResp>> list(@ModelAttribute SettleCorrectionSearchParam param) {
|
||||||
return ApiResponse.ok(service.list(param));
|
return ApiResponse.ok(service.list(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{correctionId}")
|
@GetMapping("/{correctionId}")
|
||||||
@RequirePermission(menu = "SETTLE_CORR", perm = "READ")
|
@RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ")
|
||||||
public ApiResponse<SettleCorrectionResp> detail(@PathVariable Long correctionId) {
|
public ApiResponse<SettleCorrectionResp> detail(@PathVariable Long correctionId) {
|
||||||
return ApiResponse.ok(service.detail(correctionId));
|
return ApiResponse.ok(service.detail(correctionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/by-settle/{originalSettleId}")
|
@GetMapping("/by-settle/{originalSettleId}")
|
||||||
@RequirePermission(menu = "SETTLE_CORR", perm = "READ")
|
@RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ")
|
||||||
public ApiResponse<List<SettleCorrectionResp>> listByOriginalSettle(@PathVariable Long originalSettleId) {
|
public ApiResponse<List<SettleCorrectionResp>> listByOriginalSettle(@PathVariable Long originalSettleId) {
|
||||||
return ApiResponse.ok(service.listByOriginalSettle(originalSettleId));
|
return ApiResponse.ok(service.listByOriginalSettle(originalSettleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(menu = "SETTLE_CORR", perm = "CREATE")
|
@RequirePermission(menu = "SETTLE_CORRECTION", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction")
|
@DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction")
|
||||||
public ApiResponse<Long> create(@Valid @RequestBody SettleCorrectionSaveReq req) {
|
public ApiResponse<Long> create(@Valid @RequestBody SettleCorrectionSaveReq req) {
|
||||||
return ApiResponse.ok(service.create(req));
|
return ApiResponse.ok(service.create(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{correctionId}/approve")
|
@PostMapping("/{correctionId}/approve")
|
||||||
@RequirePermission(menu = "SETTLE_CORR", perm = "APPROVE")
|
@RequirePermission(menu = "SETTLE_CORRECTION", perm = "APPROVE")
|
||||||
@DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction")
|
@DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction")
|
||||||
public ApiResponse<Void> approve(@PathVariable Long correctionId) {
|
public ApiResponse<Void> approve(@PathVariable Long correctionId) {
|
||||||
service.approve(correctionId);
|
service.approve(correctionId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{correctionId}/cancel")
|
@PostMapping("/{correctionId}/cancel")
|
||||||
@RequirePermission(menu = "SETTLE_CORR", perm = "UPDATE")
|
@RequirePermission(menu = "SETTLE_CORRECTION", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction")
|
@DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction")
|
||||||
public ApiResponse<Void> cancel(@PathVariable Long correctionId, @RequestBody Map<String, String> body) {
|
public ApiResponse<Void> cancel(@PathVariable Long correctionId, @RequestBody Map<String, String> body) {
|
||||||
service.cancel(correctionId, body.get("cancelReason"));
|
service.cancel(correctionId, body.get("cancelReason"));
|
||||||
return ApiResponse.ok();
|
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:
|
spring:
|
||||||
datasource:
|
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}
|
username: ${DB_USER:kyu}
|
||||||
password: ${DB_PASSWORD:7895123}
|
password: ${DB_PASSWORD:7895123}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
@@ -20,6 +20,7 @@ spring:
|
|||||||
baseline-on-migrate: true
|
baseline-on-migrate: true
|
||||||
baseline-version: 17 # 현재 적용된 최신 버전. 새 V18+ 만 적용
|
baseline-version: 17 # 현재 적용된 최신 버전. 새 V18+ 만 적용
|
||||||
baseline-description: "Initial baseline (V1-V17 applied manually on 2026-05-09)"
|
baseline-description: "Initial baseline (V1-V17 applied manually on 2026-05-09)"
|
||||||
|
validate-on-migrate: false
|
||||||
|
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ spring:
|
|||||||
application:
|
application:
|
||||||
name: ga-api
|
name: ga-api
|
||||||
profiles:
|
profiles:
|
||||||
active: local
|
active: trading_ai
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://localhost:5432/ga
|
url: jdbc:postgresql://localhost:5432/ga
|
||||||
username: ga
|
username: ga
|
||||||
@@ -22,7 +22,7 @@ spring:
|
|||||||
max-request-size: 100MB
|
max-request-size: 100MB
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8082
|
||||||
servlet:
|
servlet:
|
||||||
encoding:
|
encoding:
|
||||||
charset: UTF-8
|
charset: UTF-8
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.ga.batch;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
|
||||||
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
||||||
|
@EnableCaching
|
||||||
public class GaBatchApplication {
|
public class GaBatchApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(GaBatchApplication.class, 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)
|
# 실제 운영 PostgreSQL 연결용 프로파일 (ga-batch)
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
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}
|
username: ${DB_USER:kyu}
|
||||||
password: ${DB_PASSWORD:7895123}
|
password: ${DB_PASSWORD:7895123}
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
hikari:
|
||||||
maximum-pool-size: 5
|
maximum-pool-size: 5
|
||||||
|
|
||||||
data:
|
cache:
|
||||||
redis:
|
type: simple
|
||||||
host: ${REDIS_HOST:localhost}
|
autoconfigure:
|
||||||
port: ${REDIS_PORT:6379}
|
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 sortField;
|
||||||
private String sortOrder = "DESC";
|
private String sortOrder = "DESC";
|
||||||
|
|
||||||
|
/** MyBatis XML에서 #{offset} 참조 시 사용되는 계산값 */
|
||||||
|
public int getOffset() {
|
||||||
|
return Math.max(0, pageNum - 1) * pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
/** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */
|
/** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */
|
||||||
public void startPage() {
|
public void startPage() {
|
||||||
PageHelper.startPage(pageNum, pageSize);
|
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 lombok.Data;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계약 유지율 KPI 응답 VO (mv_retention 기반)
|
* 계약 유지율 KPI 응답 VO (mv_retention 기반)
|
||||||
@@ -20,5 +20,5 @@ public class KpiRetentionResp {
|
|||||||
private Long retained25m; // 25개월 시점 유지 계약 수
|
private Long retained25m; // 25개월 시점 유지 계약 수
|
||||||
private BigDecimal retentionRate13m; // 13개월 유지율 (%)
|
private BigDecimal retentionRate13m; // 13개월 유지율 (%)
|
||||||
private BigDecimal retentionRate25m; // 25개월 유지율 (%)
|
private BigDecimal retentionRate25m; // 25개월 유지율 (%)
|
||||||
private LocalDateTime refreshedAt;
|
private OffsetDateTime refreshedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,9 +82,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY g.months_from, g.effective_from DESC
|
ORDER BY g.months_from, g.effective_from DESC
|
||||||
<if test="pageSize != null and pageSize > 0">
|
|
||||||
LIMIT #{pageSize} OFFSET #{offset}
|
|
||||||
</if>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 단건 조회 (Resp: 코드명 포함) -->
|
<!-- 단건 조회 (Resp: 코드명 포함) -->
|
||||||
|
|||||||
@@ -88,9 +88,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY p.contract_id, p.plan_year, p.plan_month
|
ORDER BY p.contract_id, p.plan_year, p.plan_month
|
||||||
<if test="pageSize != null and pageSize > 0">
|
|
||||||
LIMIT #{pageSize} OFFSET #{offset}
|
|
||||||
</if>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 단건 조회 (Resp: 조인 포함) -->
|
<!-- 단건 조회 (Resp: 조인 포함) -->
|
||||||
|
|||||||
@@ -66,9 +66,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY r.plan_year, r.effective_from DESC
|
ORDER BY r.plan_year, r.effective_from DESC
|
||||||
<if test="pageSize != null and pageSize > 0">
|
|
||||||
LIMIT #{pageSize} OFFSET #{offset}
|
|
||||||
</if>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 단건 조회 (Resp: 코드명 포함) -->
|
<!-- 단건 조회 (Resp: 코드명 포함) -->
|
||||||
|
|||||||
@@ -81,9 +81,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY r.limit_type, r.effective_from DESC
|
ORDER BY r.limit_type, r.effective_from DESC
|
||||||
<if test="pageSize != null and pageSize > 0">
|
|
||||||
LIMIT #{pageSize} OFFSET #{offset}
|
|
||||||
</if>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 단건 조회 (Resp: 코드명 포함) -->
|
<!-- 단건 조회 (Resp: 코드명 포함) -->
|
||||||
|
|||||||
@@ -76,9 +76,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY d.deduction_id DESC
|
ORDER BY d.deduction_id DESC
|
||||||
<if test="pageSize != null and pageSize > 0">
|
|
||||||
LIMIT #{pageSize} OFFSET #{offset}
|
|
||||||
</if>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 단건 조회 (Resp: 조인 포함) -->
|
<!-- 단건 조회 (Resp: 조인 포함) -->
|
||||||
|
|||||||
@@ -104,9 +104,6 @@
|
|||||||
</if>
|
</if>
|
||||||
</where>
|
</where>
|
||||||
ORDER BY s.statement_id DESC
|
ORDER BY s.statement_id DESC
|
||||||
<if test="pageSize != null and pageSize > 0">
|
|
||||||
LIMIT #{pageSize} OFFSET #{offset}
|
|
||||||
</if>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- 단건 조회 (Resp: 조인 포함) -->
|
<!-- 단건 조회 (Resp: 조인 포함) -->
|
||||||
|
|||||||
Generated
-39
@@ -1737,9 +1737,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1754,9 +1751,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1771,9 +1765,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1788,9 +1779,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1805,9 +1793,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1822,9 +1807,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1839,9 +1821,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1856,9 +1835,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1873,9 +1849,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1890,9 +1863,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1907,9 +1877,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1924,9 +1891,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1941,9 +1905,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+34
-16
@@ -36,11 +36,14 @@ import CompanyList from '@/pages/product/CompanyList';
|
|||||||
import ProductList from '@/pages/product/ProductList';
|
import ProductList from '@/pages/product/ProductList';
|
||||||
import ContractList from '@/pages/product/ContractList';
|
import ContractList from '@/pages/product/ContractList';
|
||||||
|
|
||||||
import CommissionRateList from '@/pages/rule/CommissionRateList';
|
import CommissionRateList from '@/pages/rule/CommissionRateList';
|
||||||
import PayoutRuleList from '@/pages/rule/PayoutRuleList';
|
import PayoutRuleList from '@/pages/rule/PayoutRuleList';
|
||||||
import OverrideRuleList from '@/pages/rule/OverrideRuleList';
|
import OverrideRuleList from '@/pages/rule/OverrideRuleList';
|
||||||
import ChargebackRuleList from '@/pages/rule/ChargebackRuleList';
|
import ChargebackRuleList from '@/pages/rule/ChargebackRuleList';
|
||||||
import ExceptionCodeList from '@/pages/rule/ExceptionCodeList';
|
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 ReceiveData from '@/pages/receive/ReceiveData';
|
||||||
import ReceiveMapping from '@/pages/receive/ReceiveMapping';
|
import ReceiveMapping from '@/pages/receive/ReceiveMapping';
|
||||||
@@ -49,9 +52,15 @@ import RecruitLedger from '@/pages/ledger/RecruitLedger';
|
|||||||
import MaintainLedger from '@/pages/ledger/MaintainLedger';
|
import MaintainLedger from '@/pages/ledger/MaintainLedger';
|
||||||
import ExceptionLedger from '@/pages/ledger/ExceptionLedger';
|
import ExceptionLedger from '@/pages/ledger/ExceptionLedger';
|
||||||
|
|
||||||
import SettleList from '@/pages/settle/SettleList';
|
import SettleList from '@/pages/settle/SettleList';
|
||||||
import BatchRun from '@/pages/settle/BatchRun';
|
import BatchRun from '@/pages/settle/BatchRun';
|
||||||
import PaymentList from '@/pages/settle/PaymentList';
|
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 PerformanceReport from '@/pages/report/PerformanceReport';
|
||||||
import OrgReport from '@/pages/report/OrgReport';
|
import OrgReport from '@/pages/report/OrgReport';
|
||||||
@@ -97,11 +106,14 @@ export default function App() {
|
|||||||
<Route path="contracts" element={<ContractList />} />
|
<Route path="contracts" element={<ContractList />} />
|
||||||
|
|
||||||
{/* 수수료 규정 */}
|
{/* 수수료 규정 */}
|
||||||
<Route path="rules/commission" element={<CommissionRateList />} />
|
<Route path="rules/commission" element={<CommissionRateList />} />
|
||||||
<Route path="rules/payout" element={<PayoutRuleList />} />
|
<Route path="rules/payout" element={<PayoutRuleList />} />
|
||||||
<Route path="rules/override" element={<OverrideRuleList />} />
|
<Route path="rules/override" element={<OverrideRuleList />} />
|
||||||
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
|
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
|
||||||
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
|
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
|
||||||
|
<Route path="rules/regulatory" element={<RegulatoryLimitList />} />
|
||||||
|
<Route path="rules/installment-ratios" element={<InstallmentRatioList />} />
|
||||||
|
<Route path="rules/chargeback-grades" element={<ChargebackGradeList />} />
|
||||||
|
|
||||||
{/* 데이터 수신 */}
|
{/* 데이터 수신 */}
|
||||||
<Route path="receive/data" element={<ReceiveData />} />
|
<Route path="receive/data" element={<ReceiveData />} />
|
||||||
@@ -113,9 +125,15 @@ export default function App() {
|
|||||||
<Route path="ledger/exception" element={<ExceptionLedger />} />
|
<Route path="ledger/exception" element={<ExceptionLedger />} />
|
||||||
|
|
||||||
{/* 정산 / 지급 */}
|
{/* 정산 / 지급 */}
|
||||||
<Route path="settle" element={<SettleList />} />
|
<Route path="settle" element={<SettleList />} />
|
||||||
<Route path="settle/batch" element={<BatchRun />} />
|
<Route path="settle/batch" element={<BatchRun />} />
|
||||||
<Route path="payments" element={<PaymentList />} />
|
<Route path="settle/deductions" element={<DeductionList />} />
|
||||||
|
<Route path="settle/period-close" element={<PeriodClose />} />
|
||||||
|
<Route path="settle/corrections" element={<SettleCorrection />} />
|
||||||
|
<Route path="settle/tax-invoices" element={<TaxInvoiceList />} />
|
||||||
|
<Route path="settle/disputes" element={<CommissionDispute />} />
|
||||||
|
<Route path="settle/incentives" element={<IncentiveProgram />} />
|
||||||
|
<Route path="payments" element={<PaymentList />} />
|
||||||
|
|
||||||
{/* 리포트 */}
|
{/* 리포트 */}
|
||||||
<Route path="report/performance" element={<PerformanceReport />} />
|
<Route path="report/performance" element={<PerformanceReport />} />
|
||||||
|
|||||||
@@ -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`)),
|
||||||
|
};
|
||||||
@@ -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`)),
|
||||||
|
};
|
||||||
@@ -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 })),
|
||||||
|
};
|
||||||
@@ -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`)),
|
||||||
|
};
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
@@ -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`)),
|
||||||
|
};
|
||||||
@@ -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 })),
|
||||||
|
};
|
||||||
@@ -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 })),
|
||||||
|
};
|
||||||
@@ -86,10 +86,14 @@ export const commonCodeApi = {
|
|||||||
unwrap<void>(api.post('/api/common/codes/groups', vo)),
|
unwrap<void>(api.post('/api/common/codes/groups', vo)),
|
||||||
updateGroup: (groupCode: string, vo: Partial<CommonCodeGroup>) =>
|
updateGroup: (groupCode: string, vo: Partial<CommonCodeGroup>) =>
|
||||||
unwrap<void>(api.put(`/api/common/codes/groups/${groupCode}`, vo)),
|
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>) =>
|
createCode: (groupCode: string, vo: Partial<CommonCode>) =>
|
||||||
unwrap<void>(api.post(`/api/common/codes/groups/${groupCode}/codes`, vo)),
|
unwrap<void>(api.post(`/api/common/codes/groups/${groupCode}/codes`, vo)),
|
||||||
updateCode: (codeId: number, vo: Partial<CommonCode>) =>
|
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) =>
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,80 +1,539 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, Col, Empty, Row, Table, Tag, Typography } from 'antd';
|
import {
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 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 { 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() {
|
export default function CodeList() {
|
||||||
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const { data: groups = [] } = useQuery({
|
/* 선택 그룹 & 검색어 */
|
||||||
queryKey: ['code', 'groups'],
|
const [selected, setSelected] = useState<CommonCodeGroup | null>(null);
|
||||||
queryFn: () => commonCodeApi.groups(),
|
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],
|
queryKey: ['code', 'codes', selected?.groupCode],
|
||||||
queryFn: () => commonCodeApi.codes(selected!.groupCode),
|
queryFn: () => commonCodeApi.codes(selected!.groupCode),
|
||||||
enabled: !!selected,
|
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 (
|
return (
|
||||||
<PageContainer title="공통코드 관리">
|
<PageContainer title="공통코드 관리">
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* 왼쪽: 그룹 목록 */}
|
||||||
|
{/* ======================================================= */}
|
||||||
<Col span={10}>
|
<Col span={10}>
|
||||||
<Card title="그룹" size="small">
|
<Card
|
||||||
<Table
|
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"
|
rowKey="groupCode"
|
||||||
dataSource={groups}
|
dataSource={groups}
|
||||||
|
loading={groupLoading}
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ y: 600 }}
|
scroll={{ y: 520 }}
|
||||||
onRow={(r) => ({ onClick: () => setSelected(r), style: { cursor: 'pointer' } })}
|
onRow={(r) => ({
|
||||||
rowClassName={(r) => r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''}
|
onClick: () => setSelected(r),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
rowClassName={(r) =>
|
||||||
|
r.groupCode === selected?.groupCode ? 'ant-table-row-selected' : ''
|
||||||
|
}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '그룹코드', dataIndex: 'groupCode', width: 160 },
|
{ title: '그룹코드', dataIndex: 'groupCode', width: 140, ellipsis: true },
|
||||||
{ title: '그룹명', dataIndex: 'groupName' },
|
{ title: '그룹명', dataIndex: 'groupName', ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '시스템', dataIndex: 'isSystem', width: 80, align: 'center',
|
title: '유형',
|
||||||
render: (v) => v === 'Y' ? <Tag color="orange">시스템</Tag> : <Tag>일반</Tag>,
|
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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
{/* ======================================================= */}
|
||||||
|
{/* 오른쪽: 코드 목록 */}
|
||||||
|
{/* ======================================================= */}
|
||||||
<Col span={14}>
|
<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 ? (
|
{!selected ? (
|
||||||
<Empty description="그룹을 선택하세요" />
|
<Empty description="왼쪽에서 그룹을 선택하세요" style={{ padding: '40px 0' }} />
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<>
|
||||||
rowKey="codeId"
|
<Table<CommonCode>
|
||||||
dataSource={codes}
|
rowKey="codeId"
|
||||||
size="small"
|
dataSource={codes}
|
||||||
pagination={false}
|
loading={codeLoading}
|
||||||
scroll={{ y: 600 }}
|
size="small"
|
||||||
columns={[
|
pagination={false}
|
||||||
{ title: '코드', dataIndex: 'code', width: 120 },
|
scroll={{ y: 520 }}
|
||||||
{ title: '코드명', dataIndex: 'codeName' },
|
columns={[
|
||||||
{ title: '설명', dataIndex: 'codeDesc' },
|
{ title: '코드', dataIndex: 'code', width: 120, ellipsis: true },
|
||||||
{ title: '정렬', dataIndex: 'sortOrder', width: 60, align: 'right' },
|
{ title: '코드명', dataIndex: 'codeName', width: 130, ellipsis: true },
|
||||||
{
|
{ title: '설명', dataIndex: 'codeDesc', ellipsis: true },
|
||||||
title: '활성', dataIndex: 'isActive', width: 70, align: 'center',
|
{ title: '정렬', dataIndex: 'sortOrder', width: 52, align: 'right' },
|
||||||
render: (v) => v === 'Y' ? <Tag color="green">Y</Tag> : <Tag>N</Tag>,
|
{
|
||||||
},
|
title: '활성',
|
||||||
]}
|
dataIndex: 'isActive',
|
||||||
/>
|
width: 60,
|
||||||
)}
|
align: 'center',
|
||||||
{selected?.isSystem === 'Y' && (
|
render: (v: string) =>
|
||||||
<Text type="warning" style={{ display: 'block', marginTop: 8 }}>
|
v === 'Y' ? (
|
||||||
⚠ 시스템 그룹의 코드는 식별자 변경/삭제가 제한됩니다.
|
<Tag color="green" style={{ margin: 0 }}>Y</Tag>
|
||||||
</Text>
|
) : (
|
||||||
|
<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>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,241 @@
|
|||||||
import { Tag } from 'antd';
|
import { useRef, useState } from 'react';
|
||||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
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 PageContainer from '@/components/common/PageContainer';
|
||||||
|
import PermissionButton from '@/components/common/PermissionButton';
|
||||||
import { configApi, SystemConfigResp } from '@/api/config';
|
import { configApi, SystemConfigResp } from '@/api/config';
|
||||||
|
|
||||||
|
const MENU = 'SYSTEM_CONFIG';
|
||||||
|
|
||||||
const GROUP_COLOR: Record<string, string> = {
|
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() {
|
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>[] = [
|
const columns: ProColumns<SystemConfigResp>[] = [
|
||||||
{
|
{
|
||||||
title: '그룹', dataIndex: 'configGroup', width: 110, valueType: 'select',
|
title: '그룹',
|
||||||
|
dataIndex: 'configGroup',
|
||||||
|
width: 110,
|
||||||
|
valueType: 'select',
|
||||||
valueEnum: {
|
valueEnum: {
|
||||||
GENERAL: { text: '일반' }, BATCH: { text: '배치' }, SECURITY: { text: '보안' },
|
GENERAL: { text: '일반' },
|
||||||
MAIL: { text: '메일' }, TAX: { text: '세금' },
|
BATCH: { text: '배치' },
|
||||||
|
SECURITY: { text: '보안' },
|
||||||
|
MAIL: { text: '메일' },
|
||||||
|
TAX: { text: '세금' },
|
||||||
},
|
},
|
||||||
render: (_, r) => <Tag color={GROUP_COLOR[r.configGroup ?? ''] ?? 'default'}>
|
render: (_, r) => (
|
||||||
{r.configGroup}</Tag>,
|
<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,
|
title: '설정 키',
|
||||||
render: (_, r) => r.isEncrypted === 'Y' ? <Tag color="red">암호화 (***)</Tag> : <span>{r.configValue}</span>,
|
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,
|
title: '설정 값',
|
||||||
render: (_, r) => r.isEditable === 'Y' ? <Tag color="success">가능</Tag> : <Tag>잠김</Tag>,
|
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 (
|
return (
|
||||||
<PageContainer title="시스템 설정" description="배치 / 보안 / 세금 / 메일 등 키-값 설정">
|
<PageContainer
|
||||||
|
title="시스템 설정"
|
||||||
|
description="배치 / 보안 / 세금 / 메일 등 키-값 설정"
|
||||||
|
>
|
||||||
<ProTable<SystemConfigResp>
|
<ProTable<SystemConfigResp>
|
||||||
|
actionRef={actionRef}
|
||||||
rowKey="configKey"
|
rowKey="configKey"
|
||||||
columns={columns}
|
columns={columns}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 1200 }}
|
||||||
request={async (params) => {
|
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 };
|
return { data: list, total: list.length, success: true };
|
||||||
}}
|
}}
|
||||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
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 }}
|
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||||
dateFormatter="string"
|
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>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8082',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
|
||||||
|
}
|
||||||
|
|
||||||
rootProject.name = 'ga-commission-system'
|
rootProject.name = 'ga-commission-system'
|
||||||
|
|
||||||
include 'ga-common'
|
include 'ga-common'
|
||||||
|
|||||||
Reference in New Issue
Block a user