security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를 4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다. # 보안 (plane 격리) PR #A — controller/CompanyManagementController 인증 누락 패치 /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제 + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용. PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그 CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러 (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두 테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종 (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가. SuperAdminGuard.isTenantHost 가시성 public static 으로 승격. PR #B — 프론트 솔루션 전용 admin 페이지 가드 admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별: subdomainList / companyList / audit-log. 각 페이지에 isManagementHost useEffect 가드 + redirect 추가. 사이드바도 같이 숨김. PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터 V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹. admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus 가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS 하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지. StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용. # 부서관리 후속 (이전 PR #18/#19 follow-up) DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분. 이번 격리 작업과 무관하지만 같이 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -49,11 +51,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,13 @@ 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"
|
||||
);
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -187,6 +187,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 +215,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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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">
|
||||
<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="bg-muted/50 sticky top-0">
|
||||
<thead className="sticky top-0 z-10 bg-muted/60">
|
||||
<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>
|
||||
<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">
|
||||
{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>
|
||||
{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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}>닫기</Button>
|
||||
</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>
|
||||
|
||||
@@ -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) : [];
|
||||
|
||||
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user