Merge origin/main into gbpark-node — johngreen 11 commits 받음

This commit is contained in:
DDD1542
2026-05-18 11:47:41 +09:00
32 changed files with 1850 additions and 200 deletions
@@ -5,9 +5,15 @@ import java.util.Set;
public final class 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(
"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;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -30,13 +32,17 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
params.putIfAbsent("user_lang", "ko");
params.put("is_management_screen",
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), "관리자 메뉴 목록 조회 성공"));
}
@@ -49,11 +55,15 @@ public class AdminController {
@RequestAttribute("company_code") String companyCode,
@RequestAttribute("role") String role,
@RequestAttribute("user_id") String userId,
@RequestParam Map<String, Object> params) {
@RequestParam Map<String, Object> params,
HttpServletRequest request) {
params.put("company_code", companyCode);
params.put("user_type", role);
params.put("user_id", userId);
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), "사용자 메뉴 목록 조회 성공"));
}
@@ -1,7 +1,9 @@
package com.erp.controller;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.SuperAdminGuard;
import com.erp.service.CompanyManagementService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
@@ -16,6 +18,7 @@ import java.util.Map;
@Slf4j
public class CompanyManagementController {
private final SuperAdminGuard guard;
private final CompanyManagementService companyManagementService;
/**
@@ -24,9 +27,12 @@ public class CompanyManagementController {
*/
@DeleteMapping("/{companyCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
HttpServletRequest request,
@PathVariable String companyCode,
@RequestBody(required = false) Map<String, Object> body) {
guard.enforce(request);
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
if (body != null) {
@@ -52,7 +58,11 @@ public class CompanyManagementController {
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
*/
@GetMapping("/disk-usage/all")
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
HttpServletRequest request) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
return ResponseEntity.ok(ApiResponse.success(data));
@@ -68,7 +78,11 @@ public class CompanyManagementController {
*/
@GetMapping("/{companyCode}/disk-usage")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
HttpServletRequest request,
@PathVariable String companyCode) {
guard.enforce(request);
try {
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
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).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
import com.erp.tenant.DbContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Cross-tenant 어드민 엔드포인트 진입 가드.
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
public static boolean isMetaContext() {
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")
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -92,6 +98,12 @@ public class CrossTenantController {
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
HttpServletRequest request,
@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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -173,6 +185,12 @@ public class CrossTenantController {
Map<String, Object> queryParams,
String mapperId,
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
public ResponseEntity<Map<String, Object>> listDepartments(
HttpServletRequest request,
@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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(errorBody("super_admin_required", request.getRequestURI()));
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.RoleService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
private final CrossTenantExecutor executor;
private final RoleService roleService;
private final CompanyAuditLogService auditLogService;
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
}
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, "권한 그룹 생성 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
if (g != null) return g;
String targetCompany = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
Map<String, Object> params = new HashMap<>(body);
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
}
return roleService.updateRoleGroup(params);
});
auditLogService.log(targetCompany, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
if (g != null) return g;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> p = new HashMap<>();
p.put("objid", id);
roleService.deleteRoleGroup(p);
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
id,
auditDetails(request, id));
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
}
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.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) {
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
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 의 동일 헬퍼 미러. */
private String asYn(Object raw) {
if (raw == null) return null;
@@ -1,6 +1,7 @@
package com.erp.crosstenant;
import com.erp.dto.ApiResponse;
import com.erp.provisioning.CompanyAuditLogService;
import com.erp.service.AdminService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
private final CrossTenantExecutor executor;
private final AdminService adminService;
private final CompanyAuditLogService auditLogService;
// ── 등록 / 수정 ─────────────────────────────────────────────────────
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
if (guard != null) return guard;
String targetCompanyCode = (String) body.get("company_code");
String actorId = (String) request.getAttribute("user_id");
try {
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
() -> 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, "사용자 저장 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
if (guard != null) return guard;
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(companyCode, () -> {
Map<String, Object> existing = adminService.getUserInfo(userId);
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
}
adminService.changeUserStatus(userId, "inactive");
});
auditLogService.log(companyCode, actorId,
CompanyAuditLogService.ACTION_CT_USER_DELETE,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
} catch (IllegalArgumentException e) {
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 userId = (String) body.get("user_id");
String actorId = (String) request.getAttribute("user_id");
try {
executor.runInCompany(targetCompanyCode, () ->
adminService.resetUserPassword(userId));
auditLogService.log(targetCompanyCode, actorId,
CompanyAuditLogService.ACTION_CT_PW_RESET,
userId,
auditDetails(request, userId));
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
/** Void 응답용 가드 (제네릭만 다름). */
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)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
}
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
)
""",
"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)
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
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 ObjectMapper objectMapper;
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom;
import java.util.Arrays;
@@ -40,13 +37,7 @@ public class ProvisioningController {
private final ProvisioningRegistry registry;
private final SqlSession sqlSession;
private final CompanyStatsService statsService;
/**
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
*/
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
private final SuperAdminGuard guard;
@GetMapping("/table-groups")
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
@@ -208,23 +199,11 @@ public class ProvisioningController {
}
// ------------------------------------------------------------------
// 권한 체크
//
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
// CompanyMgmtController 와 동일한 가드를 공유.
// ------------------------------------------------------------------
private void enforceSuperAdmin(HttpServletRequest request) {
String userType = (String) request.getAttribute("user_type");
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");
guard.enforce(request);
}
// --- Validation helpers ---
@@ -1,5 +1,6 @@
package com.erp.provisioning;
import com.erp.tenant.ReservedSubdomains;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.util.regex.Pattern;
/**
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
*
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
*
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
@Slf4j
public class SuperAdminGuard {
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
@Value("${tenant.provisioning.require-super-admin:false}")
private boolean requireSuperAdmin;
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");
if ("SUPER_ADMIN".equals(userType)) return;
if (!requireSuperAdmin && userType == null) {
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
String userId = (String) request.getAttribute("user_id");
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
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<>();
params.put("code_info", body.get("code_info"));
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.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
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'
)
</if>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -105,6 +108,9 @@
AND RMA.READ_YN = 'Y'
)
</if>
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -187,6 +193,9 @@
AND MENU.COMPANY_CODE = #{company_code}
</otherwise>
</choose>
<if test='is_management_host == false'>
AND MENU.IS_SOLUTION_ONLY = FALSE
</if>
UNION ALL
@@ -212,6 +221,9 @@
ON S.PARENT_OBJ_ID = V.OBJID
WHERE S.OBJID != ALL(V.PATH)
AND S.STATUS = 'active'
<if test='is_management_host == false'>
AND S.IS_SOLUTION_ONLY = FALSE
</if>
)
SELECT
V.LEV
@@ -667,15 +667,15 @@
SET
PROPERTIES = JSONB_SET(
JSONB_SET(
SL.PROPERTIES,
SL.PROPERTIES::JSONB,
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
),
'{componentType}', TO_JSONB(#{component_id}::TEXT)
)
)::TEXT
FROM SCREEN_DEFINITIONS SD
WHERE SL.SCREEN_ID = SD.SCREEN_ID
AND SL.PROPERTIES->>'tableName' = #{table_name}
AND SL.PROPERTIES->>'columnName' = #{column_name}
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
</update>
+143
View File
@@ -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.
+109
View File
@@ -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 동일) 이라
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
+81
View File
@@ -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";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -64,6 +65,7 @@ import {
import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company";
import { isManagementHost } from "@/lib/tenant/subdomain";
const RESOURCE_TYPE_CONFIG: Record<
string,
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
}
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 isSuperAdmin = user?.company_code === "*";
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
setDetailOpen(true);
};
if (hostBlocked) return null;
return (
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
<div className="flex items-center justify-between">
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
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 AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
import { toCsvString, downloadCsv } from "@/lib/csvExport";
import { isManagementHost } from "@/lib/tenant/subdomain";
const PAGE_SIZE = 10;
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
*
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
*
* 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능.
* 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트.
* 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절.
*/
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 [q, setQ] = useState("");
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({
queryKey: ["companies-stats"],
queryFn: getCompaniesStats,
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
refetchInterval: (query) => {
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
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 inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
if (hostBlocked) {
return null;
}
return (
<div
style={{
@@ -20,6 +20,7 @@ import {
Check,
ChevronsUpDown,
Loader2,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
@@ -1385,8 +1386,8 @@ export default function TableManagementPage() {
</div>
</div>
{/* 3패널 메인 */}
<div className="flex flex-1 overflow-hidden">
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
<div className="relative flex flex-1 overflow-hidden">
{/* 좌측: 테이블 목록 (240px) */}
<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>
{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">
<Checkbox
checked={
@@ -1458,7 +1459,7 @@ export default function TableManagementPage() {
)}
<div
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
? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50",
@@ -1488,13 +1489,13 @@ export default function TableManagementPage() {
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1">
<span className={cn(
"truncate text-[16px] leading-tight",
"truncate text-[13px] leading-tight",
isActive ? "font-bold" : "font-medium",
)}>
{table.display_name || table.table_name}
</span>
</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}
</div>
</div>
@@ -1551,26 +1552,24 @@ export default function TableManagementPage() {
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
/>
) : (
<div
role="button"
tabIndex={0}
onClick={() => {
setEditingHeaderValue(tableLabel);
setEditingHeaderField("label");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div className="group flex items-center gap-1.5">
<span className="text-[15px] font-bold tracking-tight">
{tableLabel || (
<span className="text-muted-foreground/60">{selectedTable}</span>
)}
</span>
<button
type="button"
onClick={() => {
setEditingHeaderValue(tableLabel);
setEditingHeaderField("label");
}
}}
className="-mx-2 cursor-text rounded px-2 py-0.5 text-[15px] font-bold tracking-tight hover:bg-muted/60 transition-colors"
title="클릭하여 표시명 편집"
>
{tableLabel || (
<span className="text-muted-foreground/60">{selectedTable}</span>
)}
}}
className="text-muted-foreground/50 hover:text-foreground transition-colors"
title="표시명 편집"
aria-label="표시명 편집"
>
<Pencil className="h-3 w-3" />
</button>
</div>
)}
{/* table_name (코드, 편집 불가) */}
@@ -1596,26 +1595,24 @@ export default function TableManagementPage() {
className="mt-1 h-7 -mx-2 px-2 text-xs"
/>
) : (
<div
role="button"
tabIndex={0}
onClick={() => {
setEditingHeaderValue(tableDescription);
setEditingHeaderField("description");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
<div className="group mt-0.5 flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{tableDescription || (
<span className="text-muted-foreground/50">+ </span>
)}
</span>
<button
type="button"
onClick={() => {
setEditingHeaderValue(tableDescription);
setEditingHeaderField("description");
}
}}
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="클릭하여 설명 편집"
>
{tableDescription || (
<span className="text-muted-foreground/50">+ </span>
)}
}}
className="text-muted-foreground/50 hover:text-foreground transition-colors"
title="설명 편집"
aria-label="설명 편집"
>
<Pencil className="h-2.5 w-2.5" />
</button>
</div>
)}
</div>
@@ -1655,7 +1652,7 @@ export default function TableManagementPage() {
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={setSelectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
@@ -1690,10 +1687,14 @@ export default function TableManagementPage() {
)}
</div>
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
{selectedColumn && (
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
<ColumnDetailPanel
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
<div
className={cn(
"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}
tables={tables}
referenceTableColumns={referenceTableColumns}
@@ -1719,8 +1720,7 @@ export default function TableManagementPage() {
codeInfoOptions={commonCodeOptions}
referenceTableOptions={referenceTableOptions}
/>
</div>
)}
</div>
</div>
{/* DDL 모달 컴포넌트들 */}
@@ -1,5 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isManagementHost } from "@/lib/tenant/subdomain";
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
import { CompanyTable } from "@/components/admin/CompanyTable";
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
* 모든 회사 관리 기능을 통합하여 제공
*/
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 {
// 데이터
companies,
@@ -51,6 +64,8 @@ export default function CompanyPage() {
clearError,
} = useCompanyManagement();
if (hostBlocked) return null;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as XLSX from "xlsx";
import {
ArrowDownToLine,
ArrowUpToLine,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
@@ -12,6 +14,7 @@ import {
ChevronsUpDown,
Eye,
EyeOff,
FileDown,
Folder,
FolderOpen,
FolderTree,
@@ -28,6 +31,7 @@ import {
Upload,
Users,
X,
XCircle,
} from "lucide-react";
import {
DropdownMenu,
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
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 { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
@@ -152,11 +158,15 @@ export default function DeptMngListPage() {
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkText, setBulkText] = useState("");
const [bulkUploading, setBulkUploading] = useState(false);
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
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);
@@ -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
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
@@ -636,14 +891,7 @@ export default function DeptMngListPage() {
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkText("");
setBulkOpen(true);
}}
onClick={openBulkModal}
>
<Upload className="h-3.5 w-3.5" />
@@ -1013,106 +1261,229 @@ export default function DeptMngListPage() {
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
/>
{/* 일괄등록 */}
{/* 일괄등록 / 일괄업데이트 */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="max-w-[640px]">
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogTitle> / </DialogTitle>
</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>
{/* 일괄등록 실패 결과 */}
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
<DialogContent className="max-w-[640px]">
<DialogHeader>
<DialogTitle> ({bulkFailures.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-semibold w-16"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkFailures.map((f, idx) => (
<tr key={idx}>
<td className="px-3 py-1.5 font-mono">{f.line}</td>
<td className="px-3 py-1.5">{f.deptName}</td>
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}></Button>
<Tabs
value={bulkTab}
onValueChange={(v) => {
setBulkTab(v as "create" | "update");
resetBulkData();
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mb-2">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="update"></TabsTrigger>
</TabsList>
<TabsContent value="create" className="m-0 space-y-2">
<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>[ 릿] .</li>
<li> [] [].</li>
<li> (DEPT_n).</li>
<li> user_id (,) . 10/role.</li>
<li> 1000 .</li>
</ul>
</div>
</TabsContent>
<TabsContent value="update" className="m-0 space-y-2">
<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>
</DialogContent>
</Dialog>
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
return n;
}, [column]);
if (!column) return null;
const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length
const rawSource = referenceTableOptions.length
? [...referenceTableOptions]
: [
{ 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 rest = raw.filter((o) => o.value !== "none");
@@ -106,6 +112,10 @@ export function ColumnDetailPanel({
return noneOpt ? [noneOpt, ...rest] : rest;
}, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
if (!column) return null;
return (
<div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */}
@@ -372,7 +382,10 @@ export function ColumnDetailPanel({
<SelectValue placeholder="코드 선택" />
</SelectTrigger>
<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}>
{opt.label}
</SelectItem>
@@ -144,7 +144,7 @@ export function ColumnGrid({
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
<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.column_name})`
: column.column_name}
+7
View File
@@ -73,6 +73,9 @@ import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
import { getIconComponent } from "@/components/admin/MenuIconPicker";
import { animatedThemeChange } from "@/lib/themeTransition";
// MANAGEMENT_ONLY_MENU_URLS — DB 컬럼 IS_SOLUTION_ONLY 로 이전 (PR #D).
// 백엔드 /api/admin/user-menus 가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 Set 불필요.
interface ExtendedUserInfo {
user_id: string;
user_name: string;
@@ -286,6 +289,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
const [settingsOpen, setSettingsOpen] = useState(false);
// isMgmtSite / MANAGEMENT_ONLY_MENU_URLS — DB IS_SOLUTION_ONLY 컬럼으로 이전 (PR #D).
// 백엔드가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 상태 불필요.
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
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) : [];
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
+84
View File
@@ -191,3 +191,87 @@ export async function setPrimaryDepartment(deptCode: string, userId: string) {
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,
};
}
}
+11
View File
@@ -70,3 +70,14 @@ export function extractTenantSubdomain(host: string): string | 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;
}
+7 -3
View File
@@ -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)}}
/* ===== 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);
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
scrollbar-width:none;-ms-overflow-style: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;
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.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);
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;}