Merge origin/main into gbpark-node — johngreen 11 commits 받음
This commit is contained in:
@@ -5,9 +5,15 @@ import java.util.Set;
|
|||||||
public final class InputTypeConstants {
|
public final class InputTypeConstants {
|
||||||
private InputTypeConstants() {}
|
private InputTypeConstants() {}
|
||||||
|
|
||||||
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
|
/**
|
||||||
|
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
|
||||||
|
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
|
||||||
|
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
|
||||||
|
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
|
||||||
|
*/
|
||||||
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||||
"text", "number", "date", "code", "entity",
|
"text", "number", "date", "code", "entity",
|
||||||
"numbering", "file", "image"
|
"numbering", "file", "image",
|
||||||
|
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.erp.controller;
|
package com.erp.controller;
|
||||||
|
|
||||||
import com.erp.dto.ApiResponse;
|
import com.erp.dto.ApiResponse;
|
||||||
|
import com.erp.provisioning.SuperAdminGuard;
|
||||||
import com.erp.service.AdminService;
|
import com.erp.service.AdminService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -30,13 +32,17 @@ public class AdminController {
|
|||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
@RequestAttribute("role") String role,
|
@RequestAttribute("role") String role,
|
||||||
@RequestAttribute("user_id") String userId,
|
@RequestAttribute("user_id") String userId,
|
||||||
@RequestParam Map<String, Object> params) {
|
@RequestParam Map<String, Object> params,
|
||||||
|
HttpServletRequest request) {
|
||||||
params.put("company_code", companyCode);
|
params.put("company_code", companyCode);
|
||||||
params.put("user_type", role);
|
params.put("user_type", role);
|
||||||
params.put("user_id", userId);
|
params.put("user_id", userId);
|
||||||
params.putIfAbsent("user_lang", "ko");
|
params.putIfAbsent("user_lang", "ko");
|
||||||
params.put("is_management_screen",
|
params.put("is_management_screen",
|
||||||
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
|
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
|
||||||
|
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||||
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
|
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,11 +55,15 @@ public class AdminController {
|
|||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
@RequestAttribute("role") String role,
|
@RequestAttribute("role") String role,
|
||||||
@RequestAttribute("user_id") String userId,
|
@RequestAttribute("user_id") String userId,
|
||||||
@RequestParam Map<String, Object> params) {
|
@RequestParam Map<String, Object> params,
|
||||||
|
HttpServletRequest request) {
|
||||||
params.put("company_code", companyCode);
|
params.put("company_code", companyCode);
|
||||||
params.put("user_type", role);
|
params.put("user_type", role);
|
||||||
params.put("user_id", userId);
|
params.put("user_id", userId);
|
||||||
params.putIfAbsent("user_lang", "ko");
|
params.putIfAbsent("user_lang", "ko");
|
||||||
|
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||||
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.erp.controller;
|
package com.erp.controller;
|
||||||
|
|
||||||
import com.erp.dto.ApiResponse;
|
import com.erp.dto.ApiResponse;
|
||||||
|
import com.erp.provisioning.SuperAdminGuard;
|
||||||
import com.erp.service.CompanyManagementService;
|
import com.erp.service.CompanyManagementService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -16,6 +18,7 @@ import java.util.Map;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class CompanyManagementController {
|
public class CompanyManagementController {
|
||||||
|
|
||||||
|
private final SuperAdminGuard guard;
|
||||||
private final CompanyManagementService companyManagementService;
|
private final CompanyManagementService companyManagementService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,9 +27,12 @@ public class CompanyManagementController {
|
|||||||
*/
|
*/
|
||||||
@DeleteMapping("/{companyCode}")
|
@DeleteMapping("/{companyCode}")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
|
||||||
|
HttpServletRequest request,
|
||||||
@PathVariable String companyCode,
|
@PathVariable String companyCode,
|
||||||
@RequestBody(required = false) Map<String, Object> body) {
|
@RequestBody(required = false) Map<String, Object> body) {
|
||||||
|
|
||||||
|
guard.enforce(request);
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("company_code", companyCode);
|
params.put("company_code", companyCode);
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
@@ -52,7 +58,11 @@ public class CompanyManagementController {
|
|||||||
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
|
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/disk-usage/all")
|
@GetMapping("/disk-usage/all")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
guard.enforce(request);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
|
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
|
||||||
return ResponseEntity.ok(ApiResponse.success(data));
|
return ResponseEntity.ok(ApiResponse.success(data));
|
||||||
@@ -68,7 +78,11 @@ public class CompanyManagementController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/{companyCode}/disk-usage")
|
@GetMapping("/{companyCode}/disk-usage")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
|
||||||
|
HttpServletRequest request,
|
||||||
@PathVariable String companyCode) {
|
@PathVariable String companyCode) {
|
||||||
|
|
||||||
|
guard.enforce(request);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
|
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
|
||||||
return ResponseEntity.ok(ApiResponse.success(data));
|
return ResponseEntity.ok(ApiResponse.success(data));
|
||||||
|
|||||||
@@ -142,6 +142,135 @@ public class DepartmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄 미리보기 (read-only validation).
|
||||||
|
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
|
||||||
|
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
|
||||||
|
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
|
||||||
|
*/
|
||||||
|
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
|
||||||
|
@PathVariable String companyCode,
|
||||||
|
@RequestAttribute("company_code") String userCompanyCode,
|
||||||
|
@RequestAttribute("role") String role,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
|
if (!isAdmin(role)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||||
|
}
|
||||||
|
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
|
||||||
|
}
|
||||||
|
String action = body.get("action") != null ? body.get("action").toString() : "";
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||||
|
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||||
|
if (rows == null) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> result;
|
||||||
|
switch (action) {
|
||||||
|
case "create":
|
||||||
|
result = departmentService.bulkPreviewCreate(companyCode, rows);
|
||||||
|
break;
|
||||||
|
case "update_department":
|
||||||
|
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
|
||||||
|
break;
|
||||||
|
case "update_manager":
|
||||||
|
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return ResponseEntity.status(400)
|
||||||
|
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
|
||||||
|
}
|
||||||
|
int ok = 0, err = 0;
|
||||||
|
for (Map<String, Object> r : result) {
|
||||||
|
if ("ok".equals(r.get("result"))) ok++; else err++;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new java.util.HashMap<>();
|
||||||
|
data.put("rows", result);
|
||||||
|
data.put("ok_count", ok);
|
||||||
|
data.put("error_count", err);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄등록 적용 (@Transactional, all-or-nothing).
|
||||||
|
* POST /api/departments/companies/{companyCode}/departments/bulk/create
|
||||||
|
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
|
||||||
|
*/
|
||||||
|
@PostMapping("/companies/{companyCode}/departments/bulk/create")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
|
||||||
|
@PathVariable String companyCode,
|
||||||
|
@RequestAttribute("company_code") String userCompanyCode,
|
||||||
|
@RequestAttribute("role") String role,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
|
if (!isAdmin(role)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||||
|
}
|
||||||
|
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
|
||||||
|
}
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||||
|
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
|
||||||
|
Map<String, Object> data = new java.util.HashMap<>();
|
||||||
|
data.put("inserted", inserted);
|
||||||
|
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
|
||||||
|
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||||
|
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
|
||||||
|
* POST /api/departments/companies/{companyCode}/departments/bulk/update
|
||||||
|
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
|
||||||
|
*/
|
||||||
|
@PostMapping("/companies/{companyCode}/departments/bulk/update")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
|
||||||
|
@PathVariable String companyCode,
|
||||||
|
@RequestAttribute("company_code") String userCompanyCode,
|
||||||
|
@RequestAttribute("role") String role,
|
||||||
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
|
if (!isAdmin(role)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||||
|
}
|
||||||
|
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||||
|
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
|
||||||
|
}
|
||||||
|
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||||
|
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||||
|
if (rows == null || rows.isEmpty()) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
|
||||||
|
Map<String, Object> data = new java.util.HashMap<>();
|
||||||
|
data.put("updated", updated);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
|
||||||
|
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||||
|
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 부서 삭제 (soft-delete, V1 slim scope).
|
* 부서 삭제 (soft-delete, V1 slim scope).
|
||||||
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
|
|||||||
|
|
||||||
import com.erp.tenant.DbContextHolder;
|
import com.erp.tenant.DbContextHolder;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
||||||
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
|
|||||||
public static boolean isMetaContext() {
|
public static boolean isMetaContext() {
|
||||||
return DbContextHolder.isMeta();
|
return DbContextHolder.isMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
|
||||||
|
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
|
||||||
|
*/
|
||||||
|
public static void requireManagementHost(HttpServletRequest request) {
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"Cross-tenant operations are only available on the management site");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ public class CrossTenantController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/_active-companies")
|
@GetMapping("/_active-companies")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -92,6 +98,12 @@ public class CrossTenantController {
|
|||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@RequestParam Map<String, Object> queryParams) {
|
@RequestParam Map<String, Object> queryParams) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -173,6 +185,12 @@ public class CrossTenantController {
|
|||||||
Map<String, Object> queryParams,
|
Map<String, Object> queryParams,
|
||||||
String mapperId,
|
String mapperId,
|
||||||
boolean wrapSearchWithPercent) {
|
boolean wrapSearchWithPercent) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
|
|||||||
public ResponseEntity<Map<String, Object>> listDepartments(
|
public ResponseEntity<Map<String, Object>> listDepartments(
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@RequestParam("company_code") String companyCode) {
|
@RequestParam("company_code") String companyCode) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(errorBody(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(errorBody("super_admin_required", request.getRequestURI()));
|
.body(errorBody("super_admin_required", request.getRequestURI()));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.erp.crosstenant;
|
package com.erp.crosstenant;
|
||||||
|
|
||||||
import com.erp.dto.ApiResponse;
|
import com.erp.dto.ApiResponse;
|
||||||
|
import com.erp.provisioning.CompanyAuditLogService;
|
||||||
import com.erp.service.RoleService;
|
import com.erp.service.RoleService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
|
|||||||
|
|
||||||
private final CrossTenantExecutor executor;
|
private final CrossTenantExecutor executor;
|
||||||
private final RoleService roleService;
|
private final RoleService roleService;
|
||||||
|
private final CompanyAuditLogService auditLogService;
|
||||||
|
|
||||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
|
|||||||
if (g != null) return g;
|
if (g != null) return g;
|
||||||
|
|
||||||
String targetCompany = (String) body.get("company_code");
|
String targetCompany = (String) body.get("company_code");
|
||||||
|
String actorId = (String) request.getAttribute("user_id");
|
||||||
try {
|
try {
|
||||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||||
Map<String, Object> params = new HashMap<>(body);
|
Map<String, Object> params = new HashMap<>(body);
|
||||||
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
|
|||||||
}
|
}
|
||||||
return roleService.createRoleGroup(params);
|
return roleService.createRoleGroup(params);
|
||||||
});
|
});
|
||||||
|
auditLogService.log(targetCompany, actorId,
|
||||||
|
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||||
|
(String) body.get("auth_code"),
|
||||||
|
auditDetails(request, null));
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
|
|||||||
if (g != null) return g;
|
if (g != null) return g;
|
||||||
|
|
||||||
String targetCompany = (String) body.get("company_code");
|
String targetCompany = (String) body.get("company_code");
|
||||||
|
String actorId = (String) request.getAttribute("user_id");
|
||||||
try {
|
try {
|
||||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||||
Map<String, Object> params = new HashMap<>(body);
|
Map<String, Object> params = new HashMap<>(body);
|
||||||
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
|
|||||||
}
|
}
|
||||||
return roleService.updateRoleGroup(params);
|
return roleService.updateRoleGroup(params);
|
||||||
});
|
});
|
||||||
|
auditLogService.log(targetCompany, actorId,
|
||||||
|
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||||
|
id,
|
||||||
|
auditDetails(request, id));
|
||||||
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
|
|||||||
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
||||||
if (g != null) return g;
|
if (g != null) return g;
|
||||||
|
|
||||||
|
String actorId = (String) request.getAttribute("user_id");
|
||||||
try {
|
try {
|
||||||
executor.runInCompany(companyCode, () -> {
|
executor.runInCompany(companyCode, () -> {
|
||||||
Map<String, Object> p = new HashMap<>();
|
Map<String, Object> p = new HashMap<>();
|
||||||
p.put("objid", id);
|
p.put("objid", id);
|
||||||
roleService.deleteRoleGroup(p);
|
roleService.deleteRoleGroup(p);
|
||||||
});
|
});
|
||||||
|
auditLogService.log(companyCode, actorId,
|
||||||
|
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||||
|
id,
|
||||||
|
auditDetails(request, id));
|
||||||
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
|
|||||||
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
||||||
|
|
||||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
|
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
|
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** audit log details 기본 맵 생성 헬퍼. */
|
||||||
|
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
|
||||||
|
Map<String, Object> d = new HashMap<>();
|
||||||
|
d.put("host", request.getHeader("Host"));
|
||||||
|
if (roleId != null) d.put("role_id", roleId);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
||||||
private String asYn(Object raw) {
|
private String asYn(Object raw) {
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.erp.crosstenant;
|
package com.erp.crosstenant;
|
||||||
|
|
||||||
import com.erp.dto.ApiResponse;
|
import com.erp.dto.ApiResponse;
|
||||||
|
import com.erp.provisioning.CompanyAuditLogService;
|
||||||
import com.erp.service.AdminService;
|
import com.erp.service.AdminService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
|
|||||||
|
|
||||||
private final CrossTenantExecutor executor;
|
private final CrossTenantExecutor executor;
|
||||||
private final AdminService adminService;
|
private final AdminService adminService;
|
||||||
|
private final CompanyAuditLogService auditLogService;
|
||||||
|
|
||||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
|
|||||||
if (guard != null) return guard;
|
if (guard != null) return guard;
|
||||||
|
|
||||||
String targetCompanyCode = (String) body.get("company_code");
|
String targetCompanyCode = (String) body.get("company_code");
|
||||||
|
String actorId = (String) request.getAttribute("user_id");
|
||||||
try {
|
try {
|
||||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||||
() -> adminService.saveUser(body));
|
() -> adminService.saveUser(body));
|
||||||
|
auditLogService.log(targetCompanyCode, actorId,
|
||||||
|
CompanyAuditLogService.ACTION_CT_USER_CREATE,
|
||||||
|
(String) body.get("user_id"),
|
||||||
|
auditDetails(request, (String) body.get("user_id")));
|
||||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||||
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
|
|||||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||||
if (guard != null) return guard;
|
if (guard != null) return guard;
|
||||||
|
|
||||||
|
String actorId = (String) request.getAttribute("user_id");
|
||||||
try {
|
try {
|
||||||
executor.runInCompany(companyCode, () -> {
|
executor.runInCompany(companyCode, () -> {
|
||||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||||
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
|
|||||||
}
|
}
|
||||||
adminService.changeUserStatus(userId, "inactive");
|
adminService.changeUserStatus(userId, "inactive");
|
||||||
});
|
});
|
||||||
|
auditLogService.log(companyCode, actorId,
|
||||||
|
CompanyAuditLogService.ACTION_CT_USER_DELETE,
|
||||||
|
userId,
|
||||||
|
auditDetails(request, userId));
|
||||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||||
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
|
|||||||
|
|
||||||
String targetCompanyCode = (String) body.get("company_code");
|
String targetCompanyCode = (String) body.get("company_code");
|
||||||
String userId = (String) body.get("user_id");
|
String userId = (String) body.get("user_id");
|
||||||
|
String actorId = (String) request.getAttribute("user_id");
|
||||||
try {
|
try {
|
||||||
executor.runInCompany(targetCompanyCode, () ->
|
executor.runInCompany(targetCompanyCode, () ->
|
||||||
adminService.resetUserPassword(userId));
|
adminService.resetUserPassword(userId));
|
||||||
|
auditLogService.log(targetCompanyCode, actorId,
|
||||||
|
CompanyAuditLogService.ACTION_CT_PW_RESET,
|
||||||
|
userId,
|
||||||
|
auditDetails(request, userId));
|
||||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||||
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
|
|||||||
|
|
||||||
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
||||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
|
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
|
|||||||
|
|
||||||
/** Void 응답용 가드 (제네릭만 다름). */
|
/** Void 응답용 가드 (제네릭만 다름). */
|
||||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
CrossTenantContext.requireManagementHost(request);
|
||||||
|
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||||
|
}
|
||||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||||
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** audit log details 기본 맵 생성 헬퍼. */
|
||||||
|
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
|
||||||
|
Map<String, Object> d = new HashMap<>();
|
||||||
|
d.put("host", request.getHeader("Host"));
|
||||||
|
if (targetUserId != null) d.put("target_user_id", targetUserId);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,7 +203,110 @@ public class StartupSchemaMigrator {
|
|||||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
|
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
|
||||||
|
|
||||||
|
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
|
||||||
|
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
|
||||||
|
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
|
||||||
|
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||||
|
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
|
||||||
|
|
||||||
|
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
|
||||||
|
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
|
||||||
|
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
|
||||||
|
"""
|
||||||
|
UPDATE MENU_INFO
|
||||||
|
SET IS_SOLUTION_ONLY = TRUE
|
||||||
|
WHERE IS_SOLUTION_ONLY = FALSE
|
||||||
|
AND MENU_URL IN (
|
||||||
|
'/admin/sysMng/subdomainList',
|
||||||
|
'/admin/userMng/companyList',
|
||||||
|
'/admin/audit-log'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
|
||||||
|
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
|
||||||
|
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
|
||||||
|
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
|
||||||
|
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
|
||||||
|
// - 이미 CODE_INFO 인 신규 테넌트: no-op
|
||||||
|
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'table_type_columns'
|
||||||
|
AND column_name = 'code_category'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'table_type_columns'
|
||||||
|
AND column_name = 'code_info'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||||
|
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
""",
|
||||||
|
|
||||||
|
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
|
||||||
|
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
|
||||||
|
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
|
||||||
|
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
|
||||||
|
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
|
||||||
|
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
|
||||||
|
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
|
||||||
|
"""
|
||||||
|
DELETE FROM TABLE_TYPE_COLUMNS
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||||
|
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||||
|
id::bigint DESC
|
||||||
|
) AS rn
|
||||||
|
FROM TABLE_TYPE_COLUMNS
|
||||||
|
) r
|
||||||
|
WHERE r.rn > 1
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
|
||||||
|
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
|
||||||
|
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
|
||||||
|
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
|
||||||
|
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
|
||||||
|
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
|
||||||
|
// IF NOT EXISTS 누락 문제도 회피.
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
|
||||||
|
|
||||||
|
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
|
||||||
|
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
|
||||||
|
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
|
||||||
|
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
|
||||||
|
// 매핑:
|
||||||
|
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
|
||||||
|
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
|
||||||
|
// datetime → date
|
||||||
|
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
|
||||||
|
// WHERE 절로 멱등 (재실행 시 0 row).
|
||||||
|
"""
|
||||||
|
UPDATE TABLE_TYPE_COLUMNS
|
||||||
|
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||||
|
WHEN 'category' THEN 'code'
|
||||||
|
WHEN 'select' THEN 'code'
|
||||||
|
WHEN 'radio' THEN 'code'
|
||||||
|
WHEN 'checkbox' THEN 'code'
|
||||||
|
WHEN 'boolean' THEN 'code'
|
||||||
|
WHEN 'textarea' THEN 'text'
|
||||||
|
WHEN 'datetime' THEN 'date'
|
||||||
|
END,
|
||||||
|
UPDATED_DATE = NOW()
|
||||||
|
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
|
||||||
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
|
|||||||
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
|
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
|
||||||
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
|
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
|
||||||
|
|
||||||
|
// cross-tenant write 감사 액션
|
||||||
|
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
|
||||||
|
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
|
||||||
|
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
|
||||||
|
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
|
||||||
|
|
||||||
private final SqlSession sqlSession;
|
private final SqlSession sqlSession;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.ibatis.session.SqlSession;
|
import org.apache.ibatis.session.SqlSession;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.dao.DataIntegrityViolationException;
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -40,13 +37,7 @@ public class ProvisioningController {
|
|||||||
private final ProvisioningRegistry registry;
|
private final ProvisioningRegistry registry;
|
||||||
private final SqlSession sqlSession;
|
private final SqlSession sqlSession;
|
||||||
private final CompanyStatsService statsService;
|
private final CompanyStatsService statsService;
|
||||||
|
private final SuperAdminGuard guard;
|
||||||
/**
|
|
||||||
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
|
|
||||||
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
|
|
||||||
*/
|
|
||||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
|
||||||
private boolean requireSuperAdmin;
|
|
||||||
|
|
||||||
@GetMapping("/table-groups")
|
@GetMapping("/table-groups")
|
||||||
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
||||||
@@ -208,23 +199,11 @@ public class ProvisioningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// 권한 체크
|
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
|
||||||
//
|
// CompanyMgmtController 와 동일한 가드를 공유.
|
||||||
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
|
|
||||||
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
|
|
||||||
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
|
|
||||||
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
private void enforceSuperAdmin(HttpServletRequest request) {
|
private void enforceSuperAdmin(HttpServletRequest request) {
|
||||||
String userType = (String) request.getAttribute("user_type");
|
guard.enforce(request);
|
||||||
if ("SUPER_ADMIN".equals(userType)) return;
|
|
||||||
|
|
||||||
if (!requireSuperAdmin && userType == null) {
|
|
||||||
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
|
|
||||||
"tenant.provisioning.require-super-admin=true in production)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Validation helpers ---
|
// --- Validation helpers ---
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.erp.provisioning;
|
package com.erp.provisioning;
|
||||||
|
|
||||||
|
import com.erp.tenant.ReservedSubdomains;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
|
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
|
||||||
*
|
*
|
||||||
|
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
|
||||||
|
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
|
||||||
|
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
|
||||||
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
|
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
|
||||||
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
|
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
|
||||||
*
|
*
|
||||||
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class SuperAdminGuard {
|
public class SuperAdminGuard {
|
||||||
|
|
||||||
|
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
|
||||||
|
|
||||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||||
private boolean requireSuperAdmin;
|
private boolean requireSuperAdmin;
|
||||||
|
|
||||||
public void enforce(HttpServletRequest request) {
|
public void enforce(HttpServletRequest request) {
|
||||||
|
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
if (isTenantHost(host)) {
|
||||||
|
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
|
||||||
|
host, request.getRequestURI(), request.getAttribute("user_type"));
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
"Provisioning is only available on the management site");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) role 체크
|
||||||
String userType = (String) request.getAttribute("user_type");
|
String userType = (String) request.getAttribute("user_type");
|
||||||
if ("SUPER_ADMIN".equals(userType)) return;
|
if ("SUPER_ADMIN".equals(userType)) return;
|
||||||
if (!requireSuperAdmin && userType == null) {
|
if (!requireSuperAdmin && userType == null) {
|
||||||
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
|
|||||||
String userId = (String) request.getAttribute("user_id");
|
String userId = (String) request.getAttribute("user_id");
|
||||||
return userId == null ? "dev-anonymous" : userId;
|
return userId == null ? "dev-anonymous" : userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
|
||||||
|
public String requestHost(HttpServletRequest request) {
|
||||||
|
String host = request.getHeader("Host");
|
||||||
|
return host == null ? "" : host;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
|
||||||
|
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
|
||||||
|
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
|
||||||
|
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
|
||||||
|
*/
|
||||||
|
public static boolean isTenantHost(String host) {
|
||||||
|
if (host == null || host.isBlank()) return false;
|
||||||
|
|
||||||
|
int colon = host.indexOf(':');
|
||||||
|
if (colon != -1) host = host.substring(0, colon);
|
||||||
|
host = host.toLowerCase();
|
||||||
|
|
||||||
|
if ("localhost".equals(host)) return false;
|
||||||
|
if (IPV4.matcher(host).matches()) return false;
|
||||||
|
|
||||||
|
String[] parts = host.split("\\.");
|
||||||
|
if (parts.length == 2) {
|
||||||
|
// {sub}.localhost (dev)
|
||||||
|
if (!"localhost".equals(parts[1])) return false;
|
||||||
|
String first = parts[0];
|
||||||
|
if (first.isEmpty()) return false;
|
||||||
|
return !ReservedSubdomains.VALUES.contains(first);
|
||||||
|
}
|
||||||
|
if (parts.length < 3) return false;
|
||||||
|
|
||||||
|
String first = parts[0];
|
||||||
|
return !ReservedSubdomains.VALUES.contains(first);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ public class CommonCodeService extends BaseService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
|
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
|
||||||
|
Object rawCodeInfo = body.get("code_info");
|
||||||
|
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
|
||||||
|
if (codeInfo != null && !codeInfo.isEmpty()
|
||||||
|
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
|
||||||
|
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("code_info", body.get("code_info"));
|
params.put("code_info", body.get("code_info"));
|
||||||
params.put("code_name", body.get("code_name"));
|
params.put("code_name", body.get("code_name"));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -365,6 +366,330 @@ public class DepartmentService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// 일괄등록 / 일괄업데이트 (Bulk)
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static final int BULK_MAX_ROWS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
|
||||||
|
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
|
||||||
|
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||||
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
|
if (rows == null || rows.isEmpty()) return results;
|
||||||
|
if (rows.size() > BULK_MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||||
|
}
|
||||||
|
Set<String> existingNames = collectActiveDeptNames(companyCode);
|
||||||
|
Set<String> batchNames = new HashSet<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
Map<String, Object> input = rows.get(i);
|
||||||
|
Map<String, Object> out = new HashMap<>(input);
|
||||||
|
out.put("row_index", i);
|
||||||
|
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
|
||||||
|
if (error == null) {
|
||||||
|
out.put("result", "ok");
|
||||||
|
out.put("error_detail", null);
|
||||||
|
String dn = trimString(input.get("dept_name"));
|
||||||
|
if (dn != null) batchNames.add(dn.toLowerCase());
|
||||||
|
} else {
|
||||||
|
out.put("result", "error");
|
||||||
|
out.put("error_detail", error);
|
||||||
|
}
|
||||||
|
results.add(out);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄업데이트 — preview (read-only). mode = department | manager.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||||
|
List<Map<String, Object>> results = new ArrayList<>();
|
||||||
|
if (rows == null || rows.isEmpty()) return results;
|
||||||
|
if (rows.size() > BULK_MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||||
|
}
|
||||||
|
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||||
|
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
Map<String, Object> input = rows.get(i);
|
||||||
|
Map<String, Object> out = new HashMap<>(input);
|
||||||
|
out.put("row_index", i);
|
||||||
|
String error = validateBulkUpdateRow(input, companyCode, mode);
|
||||||
|
if (error == null) {
|
||||||
|
out.put("result", "ok");
|
||||||
|
out.put("error_detail", null);
|
||||||
|
} else {
|
||||||
|
out.put("result", "error");
|
||||||
|
out.put("error_detail", error);
|
||||||
|
}
|
||||||
|
results.add(out);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
|
||||||
|
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
|
||||||
|
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||||
|
if (rows == null || rows.isEmpty()) return 0;
|
||||||
|
if (rows.size() > BULK_MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
|
||||||
|
}
|
||||||
|
int inserted = 0;
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
Map<String, Object> row = rows.get(i);
|
||||||
|
String label = trimString(row.get("dept_name"));
|
||||||
|
try {
|
||||||
|
createDepartment(companyCode, row);
|
||||||
|
inserted++;
|
||||||
|
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||||
|
throw new IllegalArgumentException("행 " + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
|
||||||
|
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
|
||||||
|
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||||
|
if (rows == null || rows.isEmpty()) return 0;
|
||||||
|
if (rows.size() > BULK_MAX_ROWS) {
|
||||||
|
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
|
||||||
|
}
|
||||||
|
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||||
|
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||||
|
}
|
||||||
|
int updated = 0;
|
||||||
|
for (int i = 0; i < rows.size(); i++) {
|
||||||
|
Map<String, Object> row = rows.get(i);
|
||||||
|
String deptCode = trimString(row.get("dept_code"));
|
||||||
|
if (deptCode == null) {
|
||||||
|
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서코드(dept_code) 필수.");
|
||||||
|
}
|
||||||
|
Map<String, Object> existing = getDepartment(deptCode);
|
||||||
|
if (existing == null) {
|
||||||
|
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
|
||||||
|
}
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null
|
||||||
|
? existing.get("company_code").toString() : null;
|
||||||
|
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||||
|
throw new IllegalArgumentException("행 " + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ("department".equals(mode)) {
|
||||||
|
Map<String, Object> merged = buildMergedDeptBody(existing, row);
|
||||||
|
Map<String, Object> result = updateDepartment(deptCode, merged);
|
||||||
|
if (result == null) {
|
||||||
|
throw new IllegalStateException("수정 실패: " + deptCode);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// manager mode — row 에 명시된 role 만 sync
|
||||||
|
if (row.containsKey("approval_managers")) {
|
||||||
|
syncManagers(deptCode, companyCode, row, "approval");
|
||||||
|
}
|
||||||
|
if (row.containsKey("dept_managers")) {
|
||||||
|
syncManagers(deptCode, companyCode, row, "dept");
|
||||||
|
}
|
||||||
|
if (row.containsKey("org_leaders")) {
|
||||||
|
syncManagers(deptCode, companyCode, row, "org_leader");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated++;
|
||||||
|
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||||
|
throw new IllegalArgumentException("행 " + (i + 1) + " (" + deptCode + "): " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
|
||||||
|
private Set<String> collectActiveDeptNames(String companyCode) {
|
||||||
|
Set<String> names = new HashSet<>();
|
||||||
|
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
|
||||||
|
Object name = d.get("dept_name");
|
||||||
|
if (name != null) names.add(name.toString().trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
|
||||||
|
*/
|
||||||
|
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
|
||||||
|
Set<String> existingNames, Set<String> batchNames) {
|
||||||
|
String deptName = trimString(row.get("dept_name"));
|
||||||
|
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
|
||||||
|
String lower = deptName.toLowerCase();
|
||||||
|
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
|
||||||
|
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
|
||||||
|
|
||||||
|
String dt = trimString(row.get("dept_type"));
|
||||||
|
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||||
|
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||||
|
}
|
||||||
|
String parent = trimString(row.get("parent_dept_code"));
|
||||||
|
String parentErr = validateParentForBulk(parent, companyCode);
|
||||||
|
if (parentErr != null) return parentErr;
|
||||||
|
|
||||||
|
String dateErr = validateDateRange(row);
|
||||||
|
if (dateErr != null) return dateErr;
|
||||||
|
|
||||||
|
String mgrErr = validateManagerIds(row, companyCode);
|
||||||
|
if (mgrErr != null) return mgrErr;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
|
||||||
|
*/
|
||||||
|
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
|
||||||
|
String deptCode = trimString(row.get("dept_code"));
|
||||||
|
if (deptCode == null) return "부서코드(dept_code) 필수.";
|
||||||
|
Map<String, Object> existing = getDepartment(deptCode);
|
||||||
|
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
|
||||||
|
String deptCompanyCode = existing.get("company_code") != null
|
||||||
|
? existing.get("company_code").toString() : null;
|
||||||
|
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||||
|
return "다른 회사의 부서: " + deptCode;
|
||||||
|
}
|
||||||
|
if ("department".equals(mode)) {
|
||||||
|
String newName = trimString(row.get("dept_name"));
|
||||||
|
if (newName != null && !newName.isEmpty()) {
|
||||||
|
String existingName = existing.get("dept_name") != null
|
||||||
|
? existing.get("dept_name").toString().trim() : "";
|
||||||
|
if (!newName.equalsIgnoreCase(existingName)) {
|
||||||
|
Map<String, Object> dupParams = new HashMap<>();
|
||||||
|
dupParams.put("company_code", companyCode);
|
||||||
|
dupParams.put("dept_name", newName);
|
||||||
|
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||||
|
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
|
||||||
|
return "이미 존재하는 부서명: " + newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String dt = trimString(row.get("dept_type"));
|
||||||
|
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||||
|
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||||
|
}
|
||||||
|
String dateErr = validateDateRange(row);
|
||||||
|
if (dateErr != null) return dateErr;
|
||||||
|
String parent = trimString(row.get("parent_dept_code"));
|
||||||
|
String parentErr = validateParentForBulk(parent, companyCode);
|
||||||
|
if (parentErr != null) return parentErr;
|
||||||
|
}
|
||||||
|
return validateManagerIds(row, companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateParentForBulk(String parent, String companyCode) {
|
||||||
|
if (parent == null) return null;
|
||||||
|
Map<String, Object> p = sqlSession.selectOne(
|
||||||
|
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
|
||||||
|
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
|
||||||
|
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
|
||||||
|
Object pc = p.get("company_code");
|
||||||
|
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
|
||||||
|
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateDateRange(Map<String, Object> row) {
|
||||||
|
String sd = trimString(row.get("start_date"));
|
||||||
|
String ed = trimString(row.get("end_date"));
|
||||||
|
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
|
||||||
|
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
|
||||||
|
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateManagerIds(Map<String, Object> row, String companyCode) {
|
||||||
|
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||||
|
Object raw = row.get(key);
|
||||||
|
if (raw instanceof List<?> list && list.size() > 10) {
|
||||||
|
return key + " 는 최대 10명까지 등록 가능합니다.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> ids = collectManagerUserIds(row);
|
||||||
|
if (ids.isEmpty()) return null;
|
||||||
|
Map<String, Object> vParams = new HashMap<>();
|
||||||
|
vParams.put("user_ids", ids);
|
||||||
|
vParams.put("company_code", companyCode);
|
||||||
|
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||||
|
if (valid == null || valid.size() != ids.size()) {
|
||||||
|
Set<String> invalid = new HashSet<>(ids);
|
||||||
|
if (valid != null) invalid.removeAll(valid);
|
||||||
|
return "유효하지 않은 사용자 ID: " + invalid;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> collectManagerUserIds(Map<String, Object> row) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||||
|
Object raw = row.get(key);
|
||||||
|
if (raw instanceof List<?> list) {
|
||||||
|
for (Object item : list) {
|
||||||
|
String uid = null;
|
||||||
|
if (item instanceof Map<?, ?> m) {
|
||||||
|
Object v = m.get("user_id");
|
||||||
|
if (v != null) uid = v.toString().trim();
|
||||||
|
} else if (item != null) {
|
||||||
|
uid = item.toString().trim();
|
||||||
|
}
|
||||||
|
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄업데이트 department mode — 기존값 + row override 머지.
|
||||||
|
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
|
||||||
|
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
|
||||||
|
*/
|
||||||
|
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
|
||||||
|
Map<String, Object> merged = new HashMap<>();
|
||||||
|
String[] textKeys = {
|
||||||
|
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
|
||||||
|
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
|
||||||
|
"sort_order", "status", "location"
|
||||||
|
};
|
||||||
|
for (String k : textKeys) merged.put(k, existing.get(k));
|
||||||
|
merged.put("start_date", stringifyDate(existing.get("start_date")));
|
||||||
|
merged.put("end_date", stringifyDate(existing.get("end_date")));
|
||||||
|
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||||
|
String k = e.getKey();
|
||||||
|
if ("dept_code".equals(k)) continue;
|
||||||
|
if (e.getValue() == null) continue;
|
||||||
|
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
|
||||||
|
merged.put(k, e.getValue());
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringifyDate(Object date) {
|
||||||
|
if (date == null) return null;
|
||||||
|
String s = date.toString();
|
||||||
|
return s.length() >= 10 ? s.substring(0, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
// 부서원 관리
|
// 부서원 관리
|
||||||
// ──────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE MENU_INFO
|
||||||
|
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
|
||||||
|
|
||||||
|
-- 솔루션 전용 메뉴 마킹
|
||||||
|
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
|
||||||
|
WHERE MENU_URL IN (
|
||||||
|
'/admin/sysMng/subdomainList',
|
||||||
|
'/admin/userMng/companyList',
|
||||||
|
'/admin/audit-log'
|
||||||
|
);
|
||||||
@@ -58,6 +58,9 @@
|
|||||||
AND RMA.READ_YN = 'Y'
|
AND RMA.READ_YN = 'Y'
|
||||||
)
|
)
|
||||||
</if>
|
</if>
|
||||||
|
<if test='is_management_host == false'>
|
||||||
|
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||||
|
</if>
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@@ -105,6 +108,9 @@
|
|||||||
AND RMA.READ_YN = 'Y'
|
AND RMA.READ_YN = 'Y'
|
||||||
)
|
)
|
||||||
</if>
|
</if>
|
||||||
|
<if test='is_management_host == false'>
|
||||||
|
AND S.IS_SOLUTION_ONLY = FALSE
|
||||||
|
</if>
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
V.LEV
|
V.LEV
|
||||||
@@ -187,6 +193,9 @@
|
|||||||
AND MENU.COMPANY_CODE = #{company_code}
|
AND MENU.COMPANY_CODE = #{company_code}
|
||||||
</otherwise>
|
</otherwise>
|
||||||
</choose>
|
</choose>
|
||||||
|
<if test='is_management_host == false'>
|
||||||
|
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||||
|
</if>
|
||||||
|
|
||||||
UNION ALL
|
UNION ALL
|
||||||
|
|
||||||
@@ -212,6 +221,9 @@
|
|||||||
ON S.PARENT_OBJ_ID = V.OBJID
|
ON S.PARENT_OBJ_ID = V.OBJID
|
||||||
WHERE S.OBJID != ALL(V.PATH)
|
WHERE S.OBJID != ALL(V.PATH)
|
||||||
AND S.STATUS = 'active'
|
AND S.STATUS = 'active'
|
||||||
|
<if test='is_management_host == false'>
|
||||||
|
AND S.IS_SOLUTION_ONLY = FALSE
|
||||||
|
</if>
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
V.LEV
|
V.LEV
|
||||||
|
|||||||
@@ -667,15 +667,15 @@
|
|||||||
SET
|
SET
|
||||||
PROPERTIES = JSONB_SET(
|
PROPERTIES = JSONB_SET(
|
||||||
JSONB_SET(
|
JSONB_SET(
|
||||||
SL.PROPERTIES,
|
SL.PROPERTIES::JSONB,
|
||||||
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
|
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
|
||||||
),
|
),
|
||||||
'{componentType}', TO_JSONB(#{component_id}::TEXT)
|
'{componentType}', TO_JSONB(#{component_id}::TEXT)
|
||||||
)
|
)::TEXT
|
||||||
FROM SCREEN_DEFINITIONS SD
|
FROM SCREEN_DEFINITIONS SD
|
||||||
WHERE SL.SCREEN_ID = SD.SCREEN_ID
|
WHERE SL.SCREEN_ID = SD.SCREEN_ID
|
||||||
AND SL.PROPERTIES->>'tableName' = #{table_name}
|
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
|
||||||
AND SL.PROPERTIES->>'columnName' = #{column_name}
|
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
|
||||||
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
|
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
|
||||||
|
|
||||||
|
작성일: 2026-05-15
|
||||||
|
작성자: johngreen
|
||||||
|
관련:
|
||||||
|
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
|
||||||
|
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
|
||||||
|
|
||||||
|
## 목적
|
||||||
|
|
||||||
|
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
|
||||||
|
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
|
||||||
|
|
||||||
|
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
|
||||||
|
|
||||||
|
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
|
||||||
|
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
|
||||||
|
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
|
||||||
|
|
||||||
|
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
|
||||||
|
|
||||||
|
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
|
||||||
|
`CL.CODE_CATEGORY` → `CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
|
||||||
|
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
|
||||||
|
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
|
||||||
|
|
||||||
|
```
|
||||||
|
ERROR: column cl.code_info does not exist
|
||||||
|
```
|
||||||
|
|
||||||
|
본 089 마이그레이션이 `CODE_CATEGORY` → `CODE_INFO` 로 컬럼명을 안전하게 변경한다.
|
||||||
|
|
||||||
|
## 스키마
|
||||||
|
|
||||||
|
### MENU_INFO (V023)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 제약 | 설명 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
|
||||||
|
|
||||||
|
### TABLE_TYPE_COLUMNS (V024)
|
||||||
|
|
||||||
|
| 변경 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `CODE_CATEGORY` → `CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- =================================================================
|
||||||
|
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
|
||||||
|
-- =================================================================
|
||||||
|
|
||||||
|
ALTER TABLE MENU_INFO
|
||||||
|
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||||
|
|
||||||
|
UPDATE MENU_INFO
|
||||||
|
SET IS_SOLUTION_ONLY = TRUE
|
||||||
|
WHERE IS_SOLUTION_ONLY = FALSE
|
||||||
|
AND MENU_URL IN (
|
||||||
|
'/admin/sysMng/subdomainList',
|
||||||
|
'/admin/userMng/companyList',
|
||||||
|
'/admin/audit-log'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =================================================================
|
||||||
|
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
|
||||||
|
-- =================================================================
|
||||||
|
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
|
||||||
|
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'table_type_columns'
|
||||||
|
AND column_name = 'code_category'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'table_type_columns'
|
||||||
|
AND column_name = 'code_info'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||||
|
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 멱등성
|
||||||
|
|
||||||
|
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
|
||||||
|
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
|
||||||
|
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
|
||||||
|
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
|
||||||
|
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
|
||||||
|
|
||||||
|
## 적용 방법
|
||||||
|
|
||||||
|
부팅 시 자동 적용 — 별도 작업 불필요.
|
||||||
|
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
|
||||||
|
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
|
||||||
|
|
||||||
|
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
|
||||||
|
```bash
|
||||||
|
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
|
||||||
|
-- 위 SQL 본문 붙여넣기
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- V023
|
||||||
|
SELECT COLUMN_NAME FROM information_schema.columns
|
||||||
|
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
|
||||||
|
-- → 1 row
|
||||||
|
|
||||||
|
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
|
||||||
|
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
|
||||||
|
-- → 모두 IS_SOLUTION_ONLY = TRUE
|
||||||
|
|
||||||
|
-- V024
|
||||||
|
SELECT COLUMN_NAME FROM information_schema.columns
|
||||||
|
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
|
||||||
|
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 영향 범위
|
||||||
|
|
||||||
|
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
|
||||||
|
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
|
||||||
|
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
|
||||||
|
만약 필요하면:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
|
||||||
|
```
|
||||||
|
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
|
||||||
|
|
||||||
|
작성일: 2026-05-15
|
||||||
|
작성자: johngreen
|
||||||
|
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
|
||||||
|
|
||||||
|
## 증상
|
||||||
|
|
||||||
|
```
|
||||||
|
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
|
||||||
|
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
|
||||||
|
```
|
||||||
|
|
||||||
|
## 원인
|
||||||
|
|
||||||
|
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
|
||||||
|
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
|
||||||
|
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
|
||||||
|
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
|
||||||
|
|
||||||
|
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
|
||||||
|
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
|
||||||
|
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
|
||||||
|
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
|
||||||
|
|
||||||
|
## 조치
|
||||||
|
|
||||||
|
### (1) 중복 행 정리
|
||||||
|
|
||||||
|
각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
|
||||||
|
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM TABLE_TYPE_COLUMNS
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||||
|
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||||
|
id::bigint DESC
|
||||||
|
) AS rn
|
||||||
|
FROM TABLE_TYPE_COLUMNS
|
||||||
|
) r
|
||||||
|
WHERE r.rn > 1
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
실측(2026-05-15) 중복:
|
||||||
|
|
||||||
|
| DB | 중복 그룹 | 삭제될 row |
|
||||||
|
|---|---|---|
|
||||||
|
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
|
||||||
|
| `siflex_invyone` | 0 | 0 |
|
||||||
|
| `test01_invyone` | 0 | 0 |
|
||||||
|
| `test02_invyone` | 0 | 0 |
|
||||||
|
|
||||||
|
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
|
||||||
|
|
||||||
|
### (2) UNIQUE INDEX 추가
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
|
||||||
|
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
|
||||||
|
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
|
||||||
|
|
||||||
|
## 적용 방법
|
||||||
|
|
||||||
|
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
|
||||||
|
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
|
||||||
|
테넌트 DB 에 차례로 실행된다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 중복 없음
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT 1 FROM TABLE_TYPE_COLUMNS
|
||||||
|
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
|
||||||
|
) d;
|
||||||
|
-- → 0
|
||||||
|
|
||||||
|
-- 인덱스 존재
|
||||||
|
SELECT indexname FROM pg_indexes
|
||||||
|
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
|
||||||
|
-- → 1 row
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저 검증:
|
||||||
|
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
|
||||||
|
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
|
||||||
|
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
|
||||||
|
|
||||||
|
## 영향 범위
|
||||||
|
|
||||||
|
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
|
||||||
|
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
|
||||||
|
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
|
||||||
|
|
||||||
|
## 롤백
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
|
||||||
|
```
|
||||||
|
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
|
||||||
|
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
|
||||||
|
|
||||||
|
작성일: 2026-05-16
|
||||||
|
작성자: johngreen
|
||||||
|
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
|
||||||
|
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
|
||||||
|
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
|
||||||
|
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
|
||||||
|
|
||||||
|
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
|
||||||
|
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
|
||||||
|
|
||||||
|
## 매핑
|
||||||
|
|
||||||
|
| Legacy | → | Standard | 사유 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `category` | → | `code` | commonCode 통합 의도와 일치 |
|
||||||
|
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
|
||||||
|
| `radio` | → | `code` | enum 선택 |
|
||||||
|
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
|
||||||
|
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
|
||||||
|
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
|
||||||
|
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
|
||||||
|
|
||||||
|
## 영향 범위 (실측 2026-05-16)
|
||||||
|
|
||||||
|
| DB | 갱신 row |
|
||||||
|
|---|---|
|
||||||
|
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
|
||||||
|
| `siflex_invyone` | 0 (테이블 비어있음) |
|
||||||
|
| `test01_invyone` | 0 |
|
||||||
|
| `test02_invyone` | 0 |
|
||||||
|
|
||||||
|
## SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE TABLE_TYPE_COLUMNS
|
||||||
|
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||||
|
WHEN 'category' THEN 'code'
|
||||||
|
WHEN 'select' THEN 'code'
|
||||||
|
WHEN 'radio' THEN 'code'
|
||||||
|
WHEN 'checkbox' THEN 'code'
|
||||||
|
WHEN 'boolean' THEN 'code'
|
||||||
|
WHEN 'textarea' THEN 'text'
|
||||||
|
WHEN 'datetime' THEN 'date'
|
||||||
|
END,
|
||||||
|
UPDATED_DATE = NOW()
|
||||||
|
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 멱등성
|
||||||
|
|
||||||
|
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
|
||||||
|
|
||||||
|
## 적용 방법
|
||||||
|
|
||||||
|
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
|
||||||
|
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 화이트리스트 밖 row 0 이어야 함
|
||||||
|
SELECT input_type, COUNT(*) FROM table_type_columns
|
||||||
|
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
|
||||||
|
GROUP BY 1;
|
||||||
|
-- → 0 rows
|
||||||
|
```
|
||||||
|
|
||||||
|
## 후속 cleanup (별도 PR 거리)
|
||||||
|
|
||||||
|
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
|
||||||
|
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
|
||||||
|
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
|
||||||
|
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
|
||||||
|
|
||||||
|
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -64,6 +65,7 @@ import {
|
|||||||
import { getCompanyList } from "@/lib/api/company";
|
import { getCompanyList } from "@/lib/api/company";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { Company } from "@/types/company";
|
import { Company } from "@/types/company";
|
||||||
|
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||||
|
|
||||||
const RESOURCE_TYPE_CONFIG: Record<
|
const RESOURCE_TYPE_CONFIG: Record<
|
||||||
string,
|
string,
|
||||||
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditLogPage() {
|
export default function AuditLogPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [hostBlocked, setHostBlocked] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!isManagementHost(window.location.hostname)) {
|
||||||
|
setHostBlocked(true);
|
||||||
|
router.replace("/main");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isSuperAdmin = user?.company_code === "*";
|
const isSuperAdmin = user?.company_code === "*";
|
||||||
|
|
||||||
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
|
|||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hostBlocked) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { getCompaniesStats } from "@/lib/api/provisioning";
|
import { getCompaniesStats } from "@/lib/api/provisioning";
|
||||||
@@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio
|
|||||||
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
|
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
|
||||||
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
|
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
|
||||||
import { toCsvString, downloadCsv } from "@/lib/csvExport";
|
import { toCsvString, downloadCsv } from "@/lib/csvExport";
|
||||||
|
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
|
|||||||
*
|
*
|
||||||
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
|
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
|
||||||
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
|
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
|
||||||
|
*
|
||||||
|
* 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능.
|
||||||
|
* 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트.
|
||||||
|
* 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절.
|
||||||
*/
|
*/
|
||||||
export default function SubdomainListPage() {
|
export default function SubdomainListPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [hostBlocked, setHostBlocked] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!isManagementHost(window.location.hostname)) {
|
||||||
|
setHostBlocked(true);
|
||||||
|
router.replace("/main");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
|
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
|
||||||
@@ -51,6 +67,7 @@ export default function SubdomainListPage() {
|
|||||||
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
|
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
|
||||||
queryKey: ["companies-stats"],
|
queryKey: ["companies-stats"],
|
||||||
queryFn: getCompaniesStats,
|
queryFn: getCompaniesStats,
|
||||||
|
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
|
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
|
||||||
const hasProvisioning = Array.isArray(query.state.data)
|
const hasProvisioning = Array.isArray(query.state.data)
|
||||||
@@ -95,6 +112,12 @@ export default function SubdomainListPage() {
|
|||||||
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
|
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
|
||||||
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
||||||
|
|
||||||
|
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
|
||||||
|
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
|
||||||
|
if (hostBlocked) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Pencil,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
@@ -1385,8 +1386,8 @@ export default function TableManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3패널 메인 */}
|
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="relative flex flex-1 overflow-hidden">
|
||||||
{/* 좌측: 테이블 목록 (240px) */}
|
{/* 좌측: 테이블 목록 (240px) */}
|
||||||
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
|
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
@@ -1401,7 +1402,7 @@ export default function TableManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<div className="mt-2 flex items-center justify-between border-b pb-2">
|
<div className="mt-2 flex min-h-9 items-center justify-between border-b pb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
@@ -1458,7 +1459,7 @@ export default function TableManagementPage() {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
|
"group relative flex items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "bg-accent text-foreground"
|
? "bg-accent text-foreground"
|
||||||
: "text-foreground/80 hover:bg-accent/50",
|
: "text-foreground/80 hover:bg-accent/50",
|
||||||
@@ -1488,13 +1489,13 @@ export default function TableManagementPage() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"truncate text-[16px] leading-tight",
|
"truncate text-[13px] leading-tight",
|
||||||
isActive ? "font-bold" : "font-medium",
|
isActive ? "font-bold" : "font-medium",
|
||||||
)}>
|
)}>
|
||||||
{table.display_name || table.table_name}
|
{table.display_name || table.table_name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
|
<div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
|
||||||
{table.table_name}
|
{table.table_name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1551,26 +1552,24 @@ export default function TableManagementPage() {
|
|||||||
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
|
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="group flex items-center gap-1.5">
|
||||||
role="button"
|
<span className="text-[15px] font-bold tracking-tight">
|
||||||
tabIndex={0}
|
{tableLabel || (
|
||||||
onClick={() => {
|
<span className="text-muted-foreground/60">{selectedTable}</span>
|
||||||
setEditingHeaderValue(tableLabel);
|
)}
|
||||||
setEditingHeaderField("label");
|
</span>
|
||||||
}}
|
<button
|
||||||
onKeyDown={(e) => {
|
type="button"
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
onClick={() => {
|
||||||
e.preventDefault();
|
|
||||||
setEditingHeaderValue(tableLabel);
|
setEditingHeaderValue(tableLabel);
|
||||||
setEditingHeaderField("label");
|
setEditingHeaderField("label");
|
||||||
}
|
}}
|
||||||
}}
|
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||||
className="-mx-2 cursor-text rounded px-2 py-0.5 text-[15px] font-bold tracking-tight hover:bg-muted/60 transition-colors"
|
title="표시명 편집"
|
||||||
title="클릭하여 표시명 편집"
|
aria-label="표시명 편집"
|
||||||
>
|
>
|
||||||
{tableLabel || (
|
<Pencil className="h-3 w-3" />
|
||||||
<span className="text-muted-foreground/60">{selectedTable}</span>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* table_name (코드, 편집 불가) */}
|
{/* table_name (코드, 편집 불가) */}
|
||||||
@@ -1596,26 +1595,24 @@ export default function TableManagementPage() {
|
|||||||
className="mt-1 h-7 -mx-2 px-2 text-xs"
|
className="mt-1 h-7 -mx-2 px-2 text-xs"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="group mt-0.5 flex items-center gap-1.5">
|
||||||
role="button"
|
<span className="text-xs text-muted-foreground">
|
||||||
tabIndex={0}
|
{tableDescription || (
|
||||||
onClick={() => {
|
<span className="text-muted-foreground/50">+ 설명 추가</span>
|
||||||
setEditingHeaderValue(tableDescription);
|
)}
|
||||||
setEditingHeaderField("description");
|
</span>
|
||||||
}}
|
<button
|
||||||
onKeyDown={(e) => {
|
type="button"
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
onClick={() => {
|
||||||
e.preventDefault();
|
|
||||||
setEditingHeaderValue(tableDescription);
|
setEditingHeaderValue(tableDescription);
|
||||||
setEditingHeaderField("description");
|
setEditingHeaderField("description");
|
||||||
}
|
}}
|
||||||
}}
|
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||||
className="-mx-2 mt-0.5 cursor-text rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors"
|
title="설명 편집"
|
||||||
title="클릭하여 설명 편집"
|
aria-label="설명 편집"
|
||||||
>
|
>
|
||||||
{tableDescription || (
|
<Pencil className="h-2.5 w-2.5" />
|
||||||
<span className="text-muted-foreground/50">+ 설명 추가</span>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1655,7 +1652,7 @@ export default function TableManagementPage() {
|
|||||||
<ColumnGrid
|
<ColumnGrid
|
||||||
columns={columns}
|
columns={columns}
|
||||||
selectedColumn={selectedColumn}
|
selectedColumn={selectedColumn}
|
||||||
onSelectColumn={setSelectedColumn}
|
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||||
onColumnChange={(columnName, field, value) => {
|
onColumnChange={(columnName, field, value) => {
|
||||||
if (field === "is_unique") {
|
if (field === "is_unique") {
|
||||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||||
@@ -1690,10 +1687,14 @@ export default function TableManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
|
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
|
||||||
{selectedColumn && (
|
<div
|
||||||
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
|
className={cn(
|
||||||
<ColumnDetailPanel
|
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
|
||||||
|
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ColumnDetailPanel
|
||||||
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
|
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
referenceTableColumns={referenceTableColumns}
|
referenceTableColumns={referenceTableColumns}
|
||||||
@@ -1719,8 +1720,7 @@ export default function TableManagementPage() {
|
|||||||
codeInfoOptions={commonCodeOptions}
|
codeInfoOptions={commonCodeOptions}
|
||||||
referenceTableOptions={referenceTableOptions}
|
referenceTableOptions={referenceTableOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DDL 모달 컴포넌트들 */}
|
{/* DDL 모달 컴포넌트들 */}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||||
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
||||||
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
|
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
|
||||||
import { CompanyTable } from "@/components/admin/CompanyTable";
|
import { CompanyTable } from "@/components/admin/CompanyTable";
|
||||||
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|||||||
* 모든 회사 관리 기능을 통합하여 제공
|
* 모든 회사 관리 기능을 통합하여 제공
|
||||||
*/
|
*/
|
||||||
export default function CompanyPage() {
|
export default function CompanyPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [hostBlocked, setHostBlocked] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!isManagementHost(window.location.hostname)) {
|
||||||
|
setHostBlocked(true);
|
||||||
|
router.replace("/main");
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
// 데이터
|
// 데이터
|
||||||
companies,
|
companies,
|
||||||
@@ -51,6 +64,8 @@ export default function CompanyPage() {
|
|||||||
clearError,
|
clearError,
|
||||||
} = useCompanyManagement();
|
} = useCompanyManagement();
|
||||||
|
|
||||||
|
if (hostBlocked) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-background">
|
<div className="flex min-h-screen flex-col bg-background">
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpToLine,
|
ArrowUpToLine,
|
||||||
Building2,
|
Building2,
|
||||||
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
FileDown,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
@@ -28,6 +31,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -152,11 +158,15 @@ export default function DeptMngListPage() {
|
|||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
|
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
|
||||||
|
|
||||||
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
|
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
|
||||||
const [bulkOpen, setBulkOpen] = useState(false);
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
const [bulkText, setBulkText] = useState("");
|
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
|
||||||
const [bulkUploading, setBulkUploading] = useState(false);
|
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
|
||||||
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
|
const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
|
||||||
|
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
|
||||||
|
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
|
||||||
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
const [bulkFileName, setBulkFileName] = useState<string>("");
|
||||||
|
|
||||||
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
||||||
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
||||||
@@ -611,6 +621,251 @@ export default function DeptMngListPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
// 일괄등록 / 일괄업데이트 helpers
|
||||||
|
// ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BULK_HEADERS_CREATE: Record<string, string> = {
|
||||||
|
"부서명": "dept_name",
|
||||||
|
"상위부서코드": "parent_dept_code",
|
||||||
|
"부서유형": "dept_type",
|
||||||
|
"약칭": "short_name",
|
||||||
|
"조직체계": "org_system",
|
||||||
|
"정렬순서": "sort_order",
|
||||||
|
"사용여부": "status",
|
||||||
|
"시작일": "start_date",
|
||||||
|
"종료일": "end_date",
|
||||||
|
"결재관리자": "approval_managers",
|
||||||
|
"부서관리자": "dept_managers",
|
||||||
|
"조직장": "org_leaders",
|
||||||
|
};
|
||||||
|
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
|
||||||
|
"부서코드": "dept_code",
|
||||||
|
"부서명": "dept_name",
|
||||||
|
"상위부서코드": "parent_dept_code",
|
||||||
|
"부서유형": "dept_type",
|
||||||
|
"약칭": "short_name",
|
||||||
|
"조직체계": "org_system",
|
||||||
|
"정렬순서": "sort_order",
|
||||||
|
"사용여부": "status",
|
||||||
|
"시작일": "start_date",
|
||||||
|
"종료일": "end_date",
|
||||||
|
};
|
||||||
|
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
|
||||||
|
"부서코드": "dept_code",
|
||||||
|
"결재관리자": "approval_managers",
|
||||||
|
"부서관리자": "dept_managers",
|
||||||
|
"조직장": "org_leaders",
|
||||||
|
};
|
||||||
|
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
|
||||||
|
|
||||||
|
const currentHeaderMap = () =>
|
||||||
|
bulkTab === "create"
|
||||||
|
? BULK_HEADERS_CREATE
|
||||||
|
: bulkUpdateMode === "department"
|
||||||
|
? BULK_HEADERS_UPDATE_DEPT
|
||||||
|
: BULK_HEADERS_UPDATE_MGR;
|
||||||
|
|
||||||
|
const currentBulkAction = (): departmentAPI.BulkAction =>
|
||||||
|
bulkTab === "create"
|
||||||
|
? "create"
|
||||||
|
: bulkUpdateMode === "department"
|
||||||
|
? "update_department"
|
||||||
|
: "update_manager";
|
||||||
|
|
||||||
|
const resetBulkData = useCallback(() => {
|
||||||
|
setBulkRows([]);
|
||||||
|
setBulkPreviewRows([]);
|
||||||
|
setBulkSelected(new Set());
|
||||||
|
setBulkFileName("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openBulkModal = () => {
|
||||||
|
if (!selectedCompanyCode) {
|
||||||
|
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBulkTab("create");
|
||||||
|
setBulkUpdateMode("department");
|
||||||
|
resetBulkData();
|
||||||
|
setBulkOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
|
||||||
|
const downloadBulkTemplate = () => {
|
||||||
|
const action = currentBulkAction();
|
||||||
|
const headerMap =
|
||||||
|
action === "create"
|
||||||
|
? BULK_HEADERS_CREATE
|
||||||
|
: action === "update_department"
|
||||||
|
? BULK_HEADERS_UPDATE_DEPT
|
||||||
|
: BULK_HEADERS_UPDATE_MGR;
|
||||||
|
const columns = Object.keys(headerMap);
|
||||||
|
const example: Record<string, any> = {};
|
||||||
|
columns.forEach((c) => {
|
||||||
|
const snake = headerMap[c];
|
||||||
|
if (snake === "dept_name") example[c] = "경영지원본부";
|
||||||
|
else if (snake === "dept_code") example[c] = "DEPT_1";
|
||||||
|
else if (snake === "dept_type") example[c] = "dept";
|
||||||
|
else if (snake === "status") example[c] = "active";
|
||||||
|
else if (snake === "sort_order") example[c] = 10;
|
||||||
|
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
|
||||||
|
else example[c] = "";
|
||||||
|
});
|
||||||
|
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
|
||||||
|
ws["!cols"] = columns.map(() => ({ wch: 16 }));
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, "부서");
|
||||||
|
const fileName =
|
||||||
|
action === "create"
|
||||||
|
? "부서_일괄등록_템플릿.xlsx"
|
||||||
|
: action === "update_department"
|
||||||
|
? "부서정보_일괄업데이트_템플릿.xlsx"
|
||||||
|
: "부서관리자_일괄업데이트_템플릿.xlsx";
|
||||||
|
XLSX.writeFile(wb, fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
|
||||||
|
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setBulkFileName(file.name);
|
||||||
|
try {
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
const wb = XLSX.read(buf, { type: "array" });
|
||||||
|
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
|
||||||
|
const headerMap = currentHeaderMap();
|
||||||
|
const rows = raw
|
||||||
|
.map((row) => {
|
||||||
|
const out: Record<string, any> = {};
|
||||||
|
for (const [korean, snake] of Object.entries(headerMap)) {
|
||||||
|
const v = row[korean];
|
||||||
|
if (v === undefined || v === null || v === "") continue;
|
||||||
|
if (MANAGER_KEYS.has(snake)) {
|
||||||
|
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (ids.length > 0) out[snake] = ids;
|
||||||
|
} else if (snake === "sort_order") {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isNaN(n)) out[snake] = n;
|
||||||
|
} else {
|
||||||
|
out[snake] = String(v).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})
|
||||||
|
.filter((r) => Object.keys(r).length > 0);
|
||||||
|
setBulkRows(rows);
|
||||||
|
setBulkPreviewRows([]);
|
||||||
|
setBulkSelected(new Set());
|
||||||
|
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
// 동일 파일 재선택 가능하도록
|
||||||
|
e.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkPreview = async () => {
|
||||||
|
if (bulkRows.length === 0) return;
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
|
||||||
|
if (res.success && (res as any).data) {
|
||||||
|
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
|
||||||
|
setBulkPreviewRows(rows);
|
||||||
|
// 기본: ok 인 row 만 선택
|
||||||
|
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "미리보기 실패",
|
||||||
|
description: (res as any).error || (res as any).message || "오류",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkApply = async () => {
|
||||||
|
const okSelected = bulkPreviewRows.filter(
|
||||||
|
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
|
||||||
|
);
|
||||||
|
if (okSelected.length === 0) {
|
||||||
|
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = okSelected.map((r) => {
|
||||||
|
const { row_index, result, error_detail, ...rest } = r as any;
|
||||||
|
return rest as Record<string, any>;
|
||||||
|
});
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
const res =
|
||||||
|
bulkTab === "create"
|
||||||
|
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
|
||||||
|
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
|
||||||
|
if (res.success) {
|
||||||
|
const count =
|
||||||
|
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
|
||||||
|
toast({
|
||||||
|
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
|
||||||
|
description: `${count}건 처리됨`,
|
||||||
|
});
|
||||||
|
setBulkOpen(false);
|
||||||
|
resetBulkData();
|
||||||
|
await loadDepartments();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
|
||||||
|
description: (res as any).error || (res as any).message || "오류",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewColumns = useMemo(() => {
|
||||||
|
if (bulkTab === "create") {
|
||||||
|
return [
|
||||||
|
{ key: "dept_name", label: "부서명" },
|
||||||
|
{ key: "parent_dept_code", label: "상위부서코드" },
|
||||||
|
{ key: "dept_type", label: "유형" },
|
||||||
|
{ key: "sort_order", label: "순서" },
|
||||||
|
{ key: "approval_managers", label: "결재관리자", manager: true },
|
||||||
|
{ key: "dept_managers", label: "부서관리자", manager: true },
|
||||||
|
{ key: "org_leaders", label: "조직장", manager: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (bulkUpdateMode === "department") {
|
||||||
|
return [
|
||||||
|
{ key: "dept_code", label: "부서코드" },
|
||||||
|
{ key: "dept_name", label: "부서명" },
|
||||||
|
{ key: "parent_dept_code", label: "상위부서코드" },
|
||||||
|
{ key: "dept_type", label: "유형" },
|
||||||
|
{ key: "sort_order", label: "순서" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ key: "dept_code", label: "부서코드" },
|
||||||
|
{ key: "approval_managers", label: "결재관리자", manager: true },
|
||||||
|
{ key: "dept_managers", label: "부서관리자", manager: true },
|
||||||
|
{ key: "org_leaders", label: "조직장", manager: true },
|
||||||
|
];
|
||||||
|
}, [bulkTab, bulkUpdateMode]);
|
||||||
|
|
||||||
|
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
|
||||||
|
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
|
||||||
|
const allOkSelected =
|
||||||
|
bulkOkCount > 0 &&
|
||||||
|
bulkPreviewRows
|
||||||
|
.filter((r) => r.result === "ok")
|
||||||
|
.every((r) => bulkSelected.has(r.row_index));
|
||||||
|
|
||||||
const isDirty = originalDraft
|
const isDirty = originalDraft
|
||||||
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
|
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
|
||||||
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
|
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
|
||||||
@@ -636,14 +891,7 @@ export default function DeptMngListPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 gap-1.5 text-xs"
|
className="h-8 gap-1.5 text-xs"
|
||||||
onClick={() => {
|
onClick={openBulkModal}
|
||||||
if (!selectedCompanyCode) {
|
|
||||||
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBulkText("");
|
|
||||||
setBulkOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Upload className="h-3.5 w-3.5" />
|
||||||
일괄등록
|
일괄등록
|
||||||
@@ -1013,106 +1261,229 @@ export default function DeptMngListPage() {
|
|||||||
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
|
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 일괄등록 */}
|
{/* 일괄등록 / 일괄업데이트 */}
|
||||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||||
<DialogContent className="max-w-[640px]">
|
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>부서 일괄등록</DialogTitle>
|
<DialogTitle>부서 일괄등록 / 일괄업데이트</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
|
||||||
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</p>
|
|
||||||
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">경영지원본부,,dept</code></p>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={bulkText}
|
|
||||||
onChange={(e) => setBulkText(e.target.value)}
|
|
||||||
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
|
|
||||||
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setBulkOpen(false)}>취소</Button>
|
|
||||||
<Button
|
|
||||||
disabled={bulkUploading || !bulkText.trim()}
|
|
||||||
onClick={async () => {
|
|
||||||
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
||||||
if (lines.length === 0) return;
|
|
||||||
setBulkUploading(true);
|
|
||||||
const failures: { line: number; deptName: string; reason: string }[] = [];
|
|
||||||
let success = 0;
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
const cols = line.split(",").map((c) => c.trim());
|
|
||||||
const [dept_name, parent, dept_type] = cols;
|
|
||||||
if (!dept_name) {
|
|
||||||
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
|
||||||
dept_name,
|
|
||||||
parent_dept_code: parent || null,
|
|
||||||
dept_type: (dept_type || "dept") as any,
|
|
||||||
} as any);
|
|
||||||
if (res.success) success++;
|
|
||||||
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
|
|
||||||
} catch (e: any) {
|
|
||||||
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setBulkUploading(false);
|
|
||||||
toast({
|
|
||||||
title: `일괄등록 완료`,
|
|
||||||
description: `성공 ${success}건 / 실패 ${failures.length}건`,
|
|
||||||
variant: failures.length > 0 ? "destructive" : "default",
|
|
||||||
});
|
|
||||||
if (failures.length > 0) {
|
|
||||||
setBulkFailures(failures);
|
|
||||||
} else {
|
|
||||||
setBulkOpen(false);
|
|
||||||
}
|
|
||||||
await loadDepartments();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{bulkUploading ? "등록 중..." : "등록"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 일괄등록 실패 결과 */}
|
<Tabs
|
||||||
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
|
value={bulkTab}
|
||||||
<DialogContent className="max-w-[640px]">
|
onValueChange={(v) => {
|
||||||
<DialogHeader>
|
setBulkTab(v as "create" | "update");
|
||||||
<DialogTitle>일괄등록 실패 항목 ({bulkFailures.length}건)</DialogTitle>
|
resetBulkData();
|
||||||
</DialogHeader>
|
}}
|
||||||
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
<table className="w-full text-xs">
|
>
|
||||||
<thead className="bg-muted/50 sticky top-0">
|
<TabsList className="mb-2">
|
||||||
<tr>
|
<TabsTrigger value="create">일괄등록</TabsTrigger>
|
||||||
<th className="px-3 py-2 text-left font-semibold w-16">라인</th>
|
<TabsTrigger value="update">일괄업데이트</TabsTrigger>
|
||||||
<th className="px-3 py-2 text-left font-semibold">부서명</th>
|
</TabsList>
|
||||||
<th className="px-3 py-2 text-left font-semibold">사유</th>
|
|
||||||
</tr>
|
<TabsContent value="create" className="m-0 space-y-2">
|
||||||
</thead>
|
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||||
<tbody className="divide-y">
|
<p className="mb-1 font-semibold">신규 조직도를 일괄 등록합니다</p>
|
||||||
{bulkFailures.map((f, idx) => (
|
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||||
<tr key={idx}>
|
<li>[엑셀 템플릿] 을 다운로드해 작성 후 업로드하세요.</li>
|
||||||
<td className="px-3 py-1.5 font-mono">{f.line}</td>
|
<li>업로드 → [미리보기] 로 검증 → 정상 행만 선택해 [반영].</li>
|
||||||
<td className="px-3 py-1.5">{f.deptName}</td>
|
<li>부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</li>
|
||||||
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
|
<li>관리자 컬럼은 user_id 를 쉼표 (,) 로 구분해 입력하세요. 최대 10명/role.</li>
|
||||||
</tr>
|
<li>한 번에 최대 1000건까지 처리 가능.</li>
|
||||||
))}
|
</ul>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</TabsContent>
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
<TabsContent value="update" className="m-0 space-y-2">
|
||||||
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}>닫기</Button>
|
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||||
|
<p className="mb-1 font-semibold">기존 부서 정보 또는 관리자 매핑을 일괄 수정합니다</p>
|
||||||
|
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||||
|
<li>각 행에 <code className="rounded bg-background px-1 font-mono">부서코드(dept_code)</code> 필수.</li>
|
||||||
|
<li><b>부서 정보</b>: 부서명/유형/순서/날짜 등 변경. 빈 셀은 기존값 유지.</li>
|
||||||
|
<li><b>관리자 정보</b>: 결재/부서/조직장 매핑만 변경. 명시된 role 만 동기화.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 px-1">
|
||||||
|
<Label className="text-xs font-semibold">업로드 항목 선택</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={bulkUpdateMode}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setBulkUpdateMode(v as "department" | "manager");
|
||||||
|
resetBulkData();
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
|
||||||
|
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs">부서 정보</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
|
||||||
|
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs">관리자 정보</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 회사 + 파일 선택 (탭 공통) */}
|
||||||
|
<div className="mt-2 space-y-2 rounded-md border p-3">
|
||||||
|
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
|
||||||
|
<Label className="text-xs font-semibold">회사 선택</Label>
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="font-mono">{selectedCompanyCode}</span>
|
||||||
|
{selectedCompany?.company_name && (
|
||||||
|
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
|
||||||
|
<FileDown className="h-3.5 w-3.5" />
|
||||||
|
엑셀 템플릿
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
|
||||||
|
<Label className="text-xs font-semibold">파일 선택</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
onChange={handleBulkFile}
|
||||||
|
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
|
||||||
|
/>
|
||||||
|
{bulkFileName && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{bulkRows.length}건 로드됨
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 테이블 */}
|
||||||
|
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
|
||||||
|
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
|
||||||
|
<span className="font-semibold">
|
||||||
|
반영할 부서 목록 ({bulkSelected.size}/{bulkPreviewRows.length})
|
||||||
|
</span>
|
||||||
|
{bulkPreviewRows.length > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<span className="text-emerald-600 dark:text-emerald-400">정상 {bulkOkCount}건</span>
|
||||||
|
{" / "}
|
||||||
|
<span className="text-destructive">오류 {bulkErrCount}건</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[200px] flex-1 overflow-auto">
|
||||||
|
{bulkPreviewRows.length === 0 ? (
|
||||||
|
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<FileDown className="h-6 w-6 opacity-30" />
|
||||||
|
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 z-10 bg-muted/60">
|
||||||
|
<tr>
|
||||||
|
<th className="w-9 px-2 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
checked={allOkSelected}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
if (c) {
|
||||||
|
setBulkSelected(
|
||||||
|
new Set(
|
||||||
|
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setBulkSelected(new Set());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
{previewColumns.map((c) => (
|
||||||
|
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
|
||||||
|
))}
|
||||||
|
<th className="w-16 px-2 py-1.5 text-left font-semibold">결과</th>
|
||||||
|
<th className="px-2 py-1.5 text-left font-semibold">오류상세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{bulkPreviewRows.map((r) => {
|
||||||
|
const isErr = r.result === "error";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={r.row_index}
|
||||||
|
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<Checkbox
|
||||||
|
disabled={isErr}
|
||||||
|
checked={bulkSelected.has(r.row_index)}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
setBulkSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (c) next.add(r.row_index);
|
||||||
|
else next.delete(r.row_index);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{previewColumns.map((c) => {
|
||||||
|
const v = (r as any)[c.key];
|
||||||
|
const display = (c as any).manager
|
||||||
|
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
|
||||||
|
: v != null && v !== "" ? String(v) : "-";
|
||||||
|
return (
|
||||||
|
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
|
||||||
|
{display}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
{isErr ? (
|
||||||
|
<Badge variant="destructive" className="gap-1 text-[10px]">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
오류
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
정상
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
|
||||||
|
title={r.error_detail || ""}
|
||||||
|
>
|
||||||
|
{r.error_detail || ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBulkPreview}
|
||||||
|
disabled={bulkBusy || bulkRows.length === 0}
|
||||||
|
>
|
||||||
|
{bulkBusy ? "검증 중..." : "미리보기"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleBulkApply}
|
||||||
|
disabled={bulkBusy || bulkSelected.size === 0}
|
||||||
|
>
|
||||||
|
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
|
|||||||
return n;
|
return n;
|
||||||
}, [column]);
|
}, [column]);
|
||||||
|
|
||||||
if (!column) return null;
|
|
||||||
|
|
||||||
const refTableOpts = useMemo(() => {
|
const refTableOpts = useMemo(() => {
|
||||||
const hasKorean = (s: string) => /[가-힣]/.test(s);
|
const hasKorean = (s: string) => /[가-힣]/.test(s);
|
||||||
const raw = referenceTableOptions.length
|
const rawSource = referenceTableOptions.length
|
||||||
? [...referenceTableOptions]
|
? [...referenceTableOptions]
|
||||||
: [
|
: [
|
||||||
{ value: "none", label: "없음" },
|
{ value: "none", label: "없음" },
|
||||||
@@ -92,6 +90,14 @@ export function ColumnDetailPanel({
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const raw = rawSource.filter((o) => {
|
||||||
|
if (seen.has(o.value)) return false;
|
||||||
|
seen.add(o.value);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const noneOpt = raw.find((o) => o.value === "none");
|
const noneOpt = raw.find((o) => o.value === "none");
|
||||||
const rest = raw.filter((o) => o.value !== "none");
|
const rest = raw.filter((o) => o.value !== "none");
|
||||||
|
|
||||||
@@ -106,6 +112,10 @@ export function ColumnDetailPanel({
|
|||||||
return noneOpt ? [noneOpt, ...rest] : rest;
|
return noneOpt ? [noneOpt, ...rest] : rest;
|
||||||
}, [referenceTableOptions, tables]);
|
}, [referenceTableOptions, tables]);
|
||||||
|
|
||||||
|
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
|
||||||
|
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
|
||||||
|
if (!column) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
@@ -372,7 +382,10 @@ export function ColumnDetailPanel({
|
|||||||
<SelectValue placeholder="코드 선택" />
|
<SelectValue placeholder="코드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[{ value: "none", label: "선택 안함" }, ...codeInfoOptions].map((opt) => (
|
{[
|
||||||
|
{ value: "none", label: "선택 안함" },
|
||||||
|
...codeInfoOptions.filter((opt) => opt.value !== "none"),
|
||||||
|
].map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function ColumnGrid({
|
|||||||
|
|
||||||
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
|
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">
|
<div className="truncate text-xs font-medium">
|
||||||
{column.display_name && column.display_name !== column.column_name
|
{column.display_name && column.display_name !== column.column_name
|
||||||
? `${column.display_name} (${column.column_name})`
|
? `${column.display_name} (${column.column_name})`
|
||||||
: column.column_name}
|
: column.column_name}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
|
|||||||
import { getIconComponent } from "@/components/admin/MenuIconPicker";
|
import { getIconComponent } from "@/components/admin/MenuIconPicker";
|
||||||
import { animatedThemeChange } from "@/lib/themeTransition";
|
import { animatedThemeChange } from "@/lib/themeTransition";
|
||||||
|
|
||||||
|
// MANAGEMENT_ONLY_MENU_URLS — DB 컬럼 IS_SOLUTION_ONLY 로 이전 (PR #D).
|
||||||
|
// 백엔드 /api/admin/user-menus 가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 Set 불필요.
|
||||||
|
|
||||||
interface ExtendedUserInfo {
|
interface ExtendedUserInfo {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string;
|
user_name: string;
|
||||||
@@ -286,6 +289,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
// isMgmtSite / MANAGEMENT_ONLY_MENU_URLS — DB IS_SOLUTION_ONLY 컬럼으로 이전 (PR #D).
|
||||||
|
// 백엔드가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 상태 불필요.
|
||||||
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
|
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const { theme, setTheme: rawSetTheme } = useTheme();
|
const { theme, setTheme: rawSetTheme } = useTheme();
|
||||||
|
|
||||||
@@ -924,6 +930,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 솔루션 전용 메뉴 필터는 백엔드 IS_SOLUTION_ONLY 컬럼 + Host 헤더 기반 SQL 필터로 위임 (PR #D).
|
||||||
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
|
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
|
||||||
|
|
||||||
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
|
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
|
||||||
|
|||||||
@@ -191,3 +191,87 @@ export async function setPrimaryDepartment(deptCode: string, userId: string) {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
// 일괄등록 / 일괄업데이트
|
||||||
|
// ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BulkAction = "create" | "update_department" | "update_manager";
|
||||||
|
|
||||||
|
export interface BulkPreviewRow extends Record<string, any> {
|
||||||
|
row_index: number;
|
||||||
|
result: "ok" | "error";
|
||||||
|
error_detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄 미리보기 — read-only validation, write 없음.
|
||||||
|
* action 에 따라 create/update_department/update_manager 로 검증.
|
||||||
|
* 응답 rows 각 element 에 result(ok|error), error_detail 채워짐.
|
||||||
|
*/
|
||||||
|
export async function bulkPreviewDepartments(
|
||||||
|
companyCode: string,
|
||||||
|
action: BulkAction,
|
||||||
|
rows: Record<string, any>[],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { rows: BulkPreviewRow[]; ok_count: number; error_count: number };
|
||||||
|
message?: string;
|
||||||
|
}>(`/departments/companies/${companyCode}/departments/bulk/preview`, { action, rows });
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("일괄 미리보기 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄등록 적용 (트랜잭션, all-or-nothing).
|
||||||
|
* rows 는 보통 미리보기에서 ok 인 row 만 보냄.
|
||||||
|
*/
|
||||||
|
export async function bulkCreateDepartments(companyCode: string, rows: Record<string, any>[]) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { inserted: number };
|
||||||
|
message?: string;
|
||||||
|
}>(`/departments/companies/${companyCode}/departments/bulk/create`, { rows });
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("일괄등록 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일괄업데이트 적용 (트랜잭션). mode = department | manager.
|
||||||
|
* 각 row 에 dept_code 필수.
|
||||||
|
*/
|
||||||
|
export async function bulkUpdateDepartments(
|
||||||
|
companyCode: string,
|
||||||
|
mode: "department" | "manager",
|
||||||
|
rows: Record<string, any>[],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { updated: number };
|
||||||
|
message?: string;
|
||||||
|
}>(`/departments/companies/${companyCode}/departments/bulk/update`, { mode, rows });
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("일괄업데이트 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,3 +70,14 @@ export function extractTenantSubdomain(host: string): string | null {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "관리 호스트" 여부.
|
||||||
|
* solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인처럼
|
||||||
|
* 테넌트가 아닌 호스트만 true. 실제 고객 사이트(qnc, test02 등) 는 false.
|
||||||
|
*
|
||||||
|
* 프로비저닝 UI/API 노출 여부 판단에 사용. 백엔드 SuperAdminGuard.isTenantHost 와 같은 규칙.
|
||||||
|
*/
|
||||||
|
export function isManagementHost(host: string): boolean {
|
||||||
|
return extractTenantSubdomain(host) === null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -413,15 +413,19 @@ html:not(.dark) .v5-hdr{
|
|||||||
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}}
|
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}}
|
||||||
|
|
||||||
/* ===== SOLID TABS ===== */
|
/* ===== SOLID TABS ===== */
|
||||||
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto;
|
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:4px .5rem 0;gap:2px;overflow-x:auto;
|
||||||
background:var(--v5-surface-solid);
|
background:var(--v5-surface-solid);
|
||||||
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
|
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
|
||||||
scrollbar-width:none;-ms-overflow-style:none;}
|
scrollbar-width:none;-ms-overflow-style:none;}
|
||||||
.v5-tabs::-webkit-scrollbar{display:none;}
|
.v5-tabs::-webkit-scrollbar{display:none;}
|
||||||
|
/* Chrome 식 outline 탭: 비활성도 카드처럼 각각 outline. 활성 탭은 본문과 seamless + primary 강조선 */
|
||||||
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
|
||||||
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;}
|
color:var(--v5-text-muted);cursor:pointer;white-space:nowrap;transition:color .15s,border-color .15s,background .15s;
|
||||||
|
border:1px solid var(--v5-border);border-radius:8px 8px 0 0;margin-bottom:-1px;}
|
||||||
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
|
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
|
||||||
.v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:var(--v5-primary);background:var(--v5-surface);}
|
.v5-tab.on{color:var(--v5-primary);font-weight:600;
|
||||||
|
border-color:var(--v5-border);border-bottom-color:var(--v5-surface-hover);
|
||||||
|
background:var(--v5-surface-hover);box-shadow:0 -1px 0 var(--v5-primary) inset;}
|
||||||
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
|
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
|
||||||
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
|
||||||
.v5-tab:hover .v5-tab-x{opacity:1;}
|
.v5-tab:hover .v5-tab-x{opacity:1;}
|
||||||
|
|||||||
Reference in New Issue
Block a user