Merge branch 'main' into hjjeong

This commit is contained in:
hjjeong
2026-05-08 14:59:12 +09:00
22 changed files with 2096 additions and 643 deletions
@@ -19,35 +19,47 @@ public class DepartmentController {
private final DepartmentService departmentService;
/**
* 부서 목록 조회 (회사별)
* GET /api/departments/companies/{companyCode}/departments
* 부서 목록 조회 (회사별).
* 기본은 active 부서만. ?include_deleted=true 시 soft-delete 된 부서도 포함.
* GET /api/departments/companies/{companyCode}/departments[?include_deleted=true]
*/
@GetMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDepartments(
@PathVariable String companyCode,
@RequestAttribute("company_code") String userCompanyCode) {
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
return ResponseEntity.status(403)
.body(ApiResponse.error("해당 회사의 부서를 조회할 권한이 없습니다."));
}
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode);
List<Map<String, Object>> departments = departmentService.getDepartments(companyCode, includeDeleted);
return ResponseEntity.ok(ApiResponse.success(departments, "부서 목록 조회 성공"));
}
/**
* 부서 상세 조회
* GET /api/departments/{deptCode}
* 부서 상세 조회.
* - 기본: active 부서만 (DELETED_AT IS NULL)
* - ?include_deleted=true: soft-delete 된 부서도 조회 가능 (복구·이력 화면용)
* - 회사 격리: 본인 회사 부서만, SUPER_ADMIN 은 전체
* GET /api/departments/{deptCode}[?include_deleted=true]
*/
@GetMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getDepartment(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode,
@RequestParam(value = "include_deleted", required = false, defaultValue = "false") boolean includeDeleted) {
Map<String, Object> department = departmentService.getDepartment(deptCode);
Map<String, Object> department = includeDeleted
? departmentService.getDepartmentIncludingDeleted(deptCode)
: departmentService.getDepartment(deptCode);
if (department == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(department, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
return ResponseEntity.ok(ApiResponse.success(department, "부서 조회 성공"));
}
@@ -87,12 +99,21 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
try {
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
if (updated == null) {
@@ -105,39 +126,102 @@ public class DepartmentController {
}
/**
* 부서 삭제
* 부서 삭제 (soft-delete, V1 slim scope).
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
* - 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가
* - USER_DEPT 행은 보존되어 복구 시 멤버 그대로 살아남
* DELETE /api/departments/{deptCode}
*/
@DeleteMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Void>> deleteDepartment(
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
try {
int memberCount = departmentService.deleteDepartment(deptCode);
if (memberCount == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없니다."));
int result = departmentService.deleteDepartment(deptCode);
if (result == -1) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
}
String message = memberCount > 0
? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)"
: "부서가 삭제되었습니다.";
return ResponseEntity.ok(ApiResponse.success(null, message));
Map<String, Object> data = new java.util.HashMap<>();
data.put("soft_deleted", true);
data.put("dept_code", deptCode);
return ResponseEntity.ok(ApiResponse.success(data, "부서가 삭제되었습니다. (복구 가능)"));
} catch (IllegalStateException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/**
* 부서 복구 (V1 slim scope).
* - DELETED_AT = NULL 로 되돌림
* - 부모도 deleted 상태면 차단
* POST /api/departments/{deptCode}/restore
*/
@PostMapping("/{deptCode}/restore")
public ResponseEntity<ApiResponse<Map<String, Object>>> restoreDepartment(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode);
switch (result) {
case OK:
Map<String, Object> data = new java.util.HashMap<>();
data.put("dept_code", deptCode);
data.put("restored", true);
return ResponseEntity.ok(ApiResponse.success(data, "부서가 복구되었습니다."));
case NOT_FOUND:
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
case NOT_DELETED:
return ResponseEntity.status(400).body(ApiResponse.error("이미 활성 상태인 부서입니다."));
case PARENT_DELETED:
return ResponseEntity.status(400).body(ApiResponse.error("상위 부서가 삭제 상태입니다. 상위 부서를 먼저 복구해주세요."));
default:
return ResponseEntity.status(500).body(ApiResponse.error("복구 처리 중 오류"));
}
}
/**
* 부서원 목록 조회
* GET /api/departments/{deptCode}/members
*/
@GetMapping("/{deptCode}/members")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDeptMembers(
@PathVariable String deptCode) {
@PathVariable String deptCode,
@RequestAttribute("company_code") String userCompanyCode) {
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
List<Map<String, Object>> members = departmentService.getDeptMembers(deptCode);
return ResponseEntity.ok(ApiResponse.success(members, "부서원 목록 조회 성공"));
@@ -168,12 +252,21 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> addDeptMember(
@PathVariable String deptCode,
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode,
@RequestBody Map<String, Object> body) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
Object userIdObj = body.get("user_id");
if (userIdObj == null) userIdObj = body.get("user_id");
@@ -200,12 +293,21 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> removeDeptMember(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
boolean removed = departmentService.removeDeptMember(deptCode, userId);
if (!removed) {
return ResponseEntity.status(404).body(ApiResponse.error("해당 부서원을 찾을 수 없습니다."));
@@ -221,12 +323,21 @@ public class DepartmentController {
public ResponseEntity<ApiResponse<Void>> setPrimaryDept(
@PathVariable String deptCode,
@PathVariable String userId,
@RequestAttribute("role") String role) {
@RequestAttribute("role") String role,
@RequestAttribute("company_code") String userCompanyCode) {
if (!isAdmin(role)) {
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
}
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
if (existing == null) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
if (!canAccessDept(existing, userCompanyCode)) {
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
}
departmentService.setPrimaryDept(deptCode, userId);
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
}
@@ -242,4 +353,16 @@ public class DepartmentController {
private boolean isSuperAdmin(String companyCodeOrRole) {
return "*".equals(companyCodeOrRole) || "SUPER_ADMIN".equals(companyCodeOrRole);
}
/**
* 회사 격리 검증. SUPER_ADMIN ('*') 은 모든 회사 접근 가능.
* 일반 ADMIN/USER 는 자기 회사 + 글로벌 ('*') 부서만.
*/
private boolean canAccessDept(Map<String, Object> dept, String userCompanyCode) {
if (dept == null) return false;
if (isSuperAdmin(userCompanyCode)) return true;
String deptCompanyCode = dept.get("company_code") != null ? dept.get("company_code").toString() : null;
if (deptCompanyCode == null) return false;
return userCompanyCode.equals(deptCompanyCode) || "*".equals(deptCompanyCode);
}
}
@@ -41,7 +41,55 @@ public class StartupSchemaMigrator {
private static final List<String> MIGRATIONS = List.of(
// RUN_082: 첫 로그인 비밀번호 강제 변경 플래그
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE"
"ALTER TABLE USER_INFO ADD COLUMN IF NOT EXISTS FORCE_PASSWORD_CHANGE BOOLEAN DEFAULT FALSE",
// V017: 회사 관리 그룹 하위 관리자 메뉴 순서 재배열
// 조직 계층(회사→부서→사용자) + 권한 체계(메뉴→권한→권한그룹)
// 메타 DB 는 Flyway V017 로도 적용되지만 프로비저닝된 테넌트 DB 는
// 회사 생성 시점 스냅샷이 박혀있으므로 부팅 때 모든 활성 DB 에 동기화.
// SEQ 만 갱신 → 멱등.
// 타입 주의: SEQ 가 varchar 이므로 THEN 값도 문자열 리터럴로 줄 것
// (정수 리터럴이면 ELSE SEQ 와 CASE 타입 불일치 42804 발생).
"""
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN '100'
WHEN '부서관리' THEN '200'
WHEN '사용자관리' THEN '300'
WHEN '메뉴관리' THEN '400'
WHEN '권한관리' THEN '500'
WHEN '권한 그룹관리' THEN '600'
ELSE SEQ
END
WHERE MENU_TYPE = '0'
AND COMPANY_CODE = '*'
AND PARENT_OBJ_ID IS NOT NULL
AND PARENT_OBJ_ID <> '0'
AND MENU_NAME_KOR IN (
'회사관리', '부서관리', '사용자관리',
'메뉴관리', '권한관리', '권한 그룹관리'
)
""",
// V018 (1) 부서관리 V1 - DEPT_INFO 소프트삭제 컬럼.
// DELETE 동작이 hard 가 아니라 DELETED_AT = NOW() 로 전환됨.
// 메타 DB 는 Flyway V018 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL",
// V018 (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
"CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE) WHERE DELETED_AT IS NULL",
// V019: 부서관리 V1 - DEPT_INFO 미사용/중복 컬럼 정리.
// 메타 DB 는 Flyway V019 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
// DROP IF EXISTS 로 멱등성 보장.
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED",
"ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE"
);
@EventListener(ApplicationReadyEvent.class)
@@ -6,8 +6,10 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
@@ -18,8 +20,14 @@ public class DepartmentService extends BaseService {
// ──────────────────────────────────────────────────
public List<Map<String, Object>> getDepartments(String companyCode) {
return getDepartments(companyCode, false);
}
/** soft-delete 대응 — includeDeleted=true 면 DELETED_AT 부서도 포함 */
public List<Map<String, Object>> getDepartments(String companyCode, boolean includeDeleted) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
params.put("include_deleted", includeDeleted);
List<Map<String, Object>> departments = sqlSession.selectList("department.selectDepartments", params);
// member_count를 int로 변환
@@ -34,12 +42,20 @@ public class DepartmentService extends BaseService {
return departments;
}
/** active 부서만 반환. deleted 면 null. 복구 흐름은 getDepartmentIncludingDeleted 사용 */
public Map<String, Object> getDepartment(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", params);
}
/** deleted 부서까지 포함 — 복구 검증 / 부모 deleted 체크 등 internal 흐름용 */
public Map<String, Object> getDepartmentIncludingDeleted(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
}
@Transactional
public Map<String, Object> createDepartment(String companyCode, Map<String, Object> body) {
// 프론트엔드는 snake_case로 전송 (Node.js 호환)
@@ -65,10 +81,22 @@ public class DepartmentService extends BaseService {
? (String) company.get("company_name")
: companyCode;
// 부서 코드 생성
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
String deptCode = "DEPT_" + nextNumber;
// 부서 코드 결정 — body 에 dept_code 가 있고 형식 OK 이면 override, 없으면 자동생성
String requestedCode = trimString(bodyParam(body, "dept_code", "dept_code"));
String deptCode;
if (requestedCode != null && requestedCode.matches("^[A-Za-z0-9_]+$")) {
// 중복 체크
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", requestedCode));
if (existing != null) {
throw new IllegalArgumentException("부서 코드 \"" + requestedCode + "\" 가 이미 존재합니다.");
}
deptCode = requestedCode;
} else {
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
deptCode = "DEPT_" + nextNumber;
}
// 부서 생성 (전체 필드)
Map<String, Object> insertParams = new HashMap<>();
@@ -82,23 +110,15 @@ public class DepartmentService extends BaseService {
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
insertParams.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
insertParams.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
insertParams.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
insertParams.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
insertParams.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
insertParams.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
insertParams.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
insertParams.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
insertParams.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
insertParams.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
insertParams.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
insertParams.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드 (master_*, location_*, data_type, sales_yn)
insertParams.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
insertParams.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
insertParams.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
insertParams.put("data_type", bodyParam(body, "data_type", "data_type"));
insertParams.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
sqlSession.insert("department.insertDepartment", insertParams);
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
@@ -115,32 +135,28 @@ public class DepartmentService extends BaseService {
throw new IllegalArgumentException("부서명을 입력해주세요.");
}
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null);
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
params.put("dept_name", deptName);
params.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
params.put("parent_dept_code", newParent);
params.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
params.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
params.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
params.put("approval_manager", nullIfBlank(bodyParam(body, "approval_manager", "approval_manager")));
params.put("dept_manager", nullIfBlank(bodyParam(body, "dept_manager", "dept_manager")));
params.put("org_head", nullIfBlank(bodyParam(body, "org_head", "org_head")));
params.put("zipcode", nullIfBlank(bodyParam(body, "zipcode", "zipcode")));
params.put("address1", nullIfBlank(bodyParam(body, "address1", "address1")));
params.put("address2", nullIfBlank(bodyParam(body, "address2", "address2")));
params.put("start_date", nullIfBlank(bodyParam(body, "start_date", "start_date")));
params.put("end_date", nullIfBlank(bodyParam(body, "end_date", "end_date")));
params.put("erp_managed", bodyParam(body, "erp_managed", "erp_managed"));
params.put("show_in_chart", bodyParam(body, "show_in_chart", "show_in_chart"));
params.put("sort_order", bodyParam(body, "sort_order", "sort_order"));
params.put("status", bodyParam(body, "status", "status"));
// dept_info 추가 필드
params.put("master_sabun", nullIfBlank(bodyParam(body, "master_sabun", "master_sabun")));
params.put("master_user_id", nullIfBlank(bodyParam(body, "master_user_id", "master_user_id")));
// dept_info 추가 필드 (location 코드만 유지 — V019 정리 후)
params.put("location", nullIfBlank(bodyParam(body, "location", "location")));
params.put("location_name", nullIfBlank(bodyParam(body, "location_name", "location_name")));
params.put("data_type", bodyParam(body, "data_type", "data_type"));
params.put("sales_yn", bodyParam(body, "sales_yn", "sales_yn"));
int updated = sqlSession.update("department.updateDepartment", params);
if (updated == 0) {
@@ -153,32 +169,105 @@ public class DepartmentService extends BaseService {
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
}
/**
* 부서 soft-delete (V1 slim scope).
* - hard delete 가 아니라 DELETED_AT = NOW() 로 마킹
* - USER_DEPT 행은 보존 → 복구 시 멤버 그대로 살아남
* - 활성 자식 부서가 있으면 차단 (deleted 자식은 무시)
* - 반환: 0 = soft-delete 성공 (보존된 부서원 수는 복구 시점에 재조회)
* -1 = not found / already deleted
*/
@Transactional
public int deleteDepartment(String deptCode) {
// 하위 부서 확인
// 활성 하위 부서 확인 (deleted 자식은 자식 카운트에서 제외)
Map<String, Object> childParams = new HashMap<>();
childParams.put("dept_code", deptCode);
childParams.put("include_deleted", false);
Number childCountNum = sqlSession.selectOne("department.selectChildDeptCount", childParams);
int childCount = childCountNum != null ? childCountNum.intValue() : 0;
if (childCount > 0) {
throw new IllegalStateException("하위 부서가 있는 부서는 삭제할 수 없습니다. 먼저 하위 부서를 삭제해주세요.");
}
// 부서원 삭제
Map<String, Object> memberParams = new HashMap<>();
memberParams.put("dept_code", deptCode);
int memberCount = sqlSession.delete("department.deleteUserDeptByDeptCode", memberParams);
// 부서 삭제
// soft-delete: DELETED_AT = NOW(). USER_DEPT 보존
Map<String, Object> deptParams = new HashMap<>();
deptParams.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDepartment", deptParams);
if (deleted == 0) {
return -1; // not found
int updated = sqlSession.update("department.deleteDepartment", deptParams);
if (updated == 0) {
return -1; // not found 또는 이미 deleted
}
log.info("부서 삭제 성공: deptCode={}, 제외된 부서원 수={}", deptCode, memberCount);
return memberCount;
log.info("부서 soft-delete 성공: deptCode={} (USER_DEPT 행 보존)", deptCode);
return 0;
}
/**
* 부서 복구 (V1 slim scope).
* - DELETED_AT = NULL 로 되돌림
* - 부모가 있고 부모도 deleted 상태면 차단 (orphan 방지)
* - USER_DEPT 행은 soft-delete 시점부터 보존되어왔으므로 자동 복원됨
*/
@Transactional
public RestoreResult restoreDepartment(String deptCode) {
Map<String, Object> dept = getDepartmentIncludingDeleted(deptCode);
if (dept == null) {
return RestoreResult.NOT_FOUND;
}
if (dept.get("deleted_at") == null) {
return RestoreResult.NOT_DELETED;
}
// 부모 deleted 검증
Object parentObj = dept.get("parent_dept_code");
if (parentObj != null && !parentObj.toString().isBlank()) {
String parentCode = parentObj.toString();
Map<String, Object> parent = getDepartmentIncludingDeleted(parentCode);
if (parent != null && parent.get("deleted_at") != null) {
return RestoreResult.PARENT_DELETED;
}
}
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
int restored = sqlSession.update("department.restoreDepartment", params);
if (restored == 0) {
return RestoreResult.NOT_DELETED; // race: 동시 복구
}
log.info("부서 복구 성공: deptCode={}", deptCode);
return RestoreResult.OK;
}
public enum RestoreResult {
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
}
/**
* parent_dept_code 변경 시 사이클 검증.
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
* 체인에 deptCode 자체가 들어있다면 사이클이 생기므로 차단.
* (newParent == null 은 최상위로 만들기 — 항상 안전)
*/
private void verifyParentCycle(String deptCode, String newParent) {
if (newParent == null) return;
if (newParent.equals(deptCode)) {
throw new IllegalArgumentException("자기 자신을 상위 부서로 지정할 수 없습니다.");
}
Set<String> visited = new HashSet<>();
String cur = newParent;
while (cur != null && !visited.contains(cur)) {
if (deptCode.equals(cur)) {
throw new IllegalArgumentException("선택한 부서는 현재 부서의 하위 부서이므로 상위 부서로 지정할 수 없습니다.");
}
visited.add(cur);
Map<String, Object> p = sqlSession.selectOne(
"department.selectDepartmentByCodeIncludingDeleted",
Map.of("dept_code", cur)
);
if (p == null) break;
Object parent = p.get("parent_dept_code");
cur = parent != null ? parent.toString() : null;
}
}
// ──────────────────────────────────────────────────
@@ -234,14 +323,33 @@ public class DepartmentService extends BaseService {
@Transactional
public boolean removeDeptMember(String deptCode, String userId) {
Map<String, Object> params = new HashMap<>();
params.put("user_id", userId);
params.put("dept_code", deptCode);
int deleted = sqlSession.delete("department.deleteDeptMember", params);
// 1. 제거 전 — 이 row 가 primary 였는지 확인
Map<String, Object> existParams = new HashMap<>();
existParams.put("user_id", userId);
existParams.put("dept_code", deptCode);
Map<String, Object> existing = sqlSession.selectOne("department.selectExistingMember", existParams);
boolean wasPrimary = existing != null && Boolean.TRUE.equals(existing.get("is_primary"));
// 2. 제거
int deleted = sqlSession.delete("department.deleteDeptMember", existParams);
if (deleted == 0) {
return false;
}
log.info("부서원 제거 성공: userId={}, deptCode={}", userId, deptCode);
// 3. primary 였으면 다른 USER_DEPT row 중 하나 promote
if (wasPrimary) {
Map<String, Object> remaining = sqlSession.selectOne("department.selectFirstUserDept",
Map.of("user_id", userId));
if (remaining != null && remaining.get("dept_code") != null) {
Map<String, Object> promote = new HashMap<>();
promote.put("user_id", userId);
promote.put("dept_code", remaining.get("dept_code").toString());
sqlSession.update("department.setUserPrimaryDept", promote);
log.info("주 부서 자동 승격: userId={}, newPrimaryDept={}", userId, remaining.get("dept_code"));
}
}
log.info("부서원 제거 성공: userId={}, deptCode={}, wasPrimary={}", userId, deptCode, wasPrimary);
return true;
}
@@ -0,0 +1,39 @@
-- V017: 회사 관리 그룹 하위 관리자 메뉴(MENU_TYPE='0', COMPANY_CODE='*') 순서 재배열
--
-- 변경 후 순서:
-- 1) 회사관리 (조직 계층: 회사)
-- 2) 부서관리 (조직 계층: 부서)
-- 3) 사용자관리 (조직 계층: 사용자)
-- 4) 메뉴관리 (권한 체계: 메뉴)
-- 5) 권한관리 (권한 체계: 권한)
-- 6) 권한 그룹관리 (권한 체계: 권한 그룹)
--
-- 사유: 기존 순서(회사 → 사용자 → 메뉴 → 권한 → 권한그룹 → 부서)는 부서가 맨 끝으로
-- 빠져 조직 계층이 끊기고, 권한 그룹이 권한관리 뒤에 오는 등 그룹 일관성이 없었다.
-- 조직(회사→부서→사용자) + 권한(메뉴→권한→권한그룹) 두 묶음으로 정렬한다.
--
-- 멱등성: SEQ 만 갱신하므로 중복 실행 안전. MENU_NAME_KOR + MENU_TYPE + COMPANY_CODE
-- 로 식별하여 다른 회사/사용자 메뉴는 영향 없음.
--
-- 타입 주의: MENU_INFO.SEQ 컬럼은 character varying 이라 정수 리터럴을 그대로
-- 쓰면 CASE 가 ELSE SEQ(varchar) 와 타입 불일치(42804) 로 실패한다.
-- → THEN 값은 반드시 문자열 리터럴로 줄 것.
UPDATE MENU_INFO
SET SEQ = CASE MENU_NAME_KOR
WHEN '회사관리' THEN '100'
WHEN '부서관리' THEN '200'
WHEN '사용자관리' THEN '300'
WHEN '메뉴관리' THEN '400'
WHEN '권한관리' THEN '500'
WHEN '권한 그룹관리' THEN '600'
ELSE SEQ
END
WHERE MENU_TYPE = '0'
AND COMPANY_CODE = '*'
AND PARENT_OBJ_ID IS NOT NULL
AND PARENT_OBJ_ID <> '0'
AND MENU_NAME_KOR IN (
'회사관리', '부서관리', '사용자관리',
'메뉴관리', '권한관리', '권한 그룹관리'
);
@@ -0,0 +1,18 @@
-- V018: invyone 부서관리 V1 - soft-delete
-- 부서 삭제를 hard-delete → soft-delete 로 전환하기 위한 schema 변경.
-- Additive only: 기존 22 컬럼 / 11 endpoint 무변경.
-- 멱등: IF NOT EXISTS 가드. 중복 실행 안전.
--
-- 멀티테넌트: 메타 DB 는 본 Flyway 가, 활성 테넌트는 StartupSchemaMigrator 가
-- 동일 statement 를 부팅 시점에 적용.
--
-- 후속 작업 (Slice 2.1): mapper/department.xml 의 deleteDepartment 를 UPDATE 로 교체,
-- restoreDepartment 신규, list/byCode 는 DELETED_AT IS NULL 옵션 처리.
-- (1) DEPT_INFO 소프트삭제 컬럼
ALTER TABLE DEPT_INFO ADD COLUMN IF NOT EXISTS DELETED_AT TIMESTAMP NULL;
-- (2) DEPT_INFO 활성 부서 부분 인덱스 (DELETED_AT IS NULL 쿼리 가속)
CREATE INDEX IF NOT EXISTS IDX_DEPT_INFO_ACTIVE
ON DEPT_INFO (COMPANY_CODE, PARENT_DEPT_CODE)
WHERE DELETED_AT IS NULL;
@@ -0,0 +1,22 @@
-- V019: 부서관리 미사용/중복 컬럼 정리
-- 기준: 부서관리 모듈 내부에서만 사용 + 사용처 0 + 다른 컬럼과 중복.
-- DROP IF EXISTS 로 멱등성 보장.
--
-- 대상 컬럼 (8개):
-- MASTER_SABUN - 부서장 사번 (DEPT_MANAGER 와 중복)
-- MASTER_USER_ID - 부서장 user_id (DEPT_MANAGER 와 중복, UI 미노출)
-- ORG_HEAD - 조직장 (DEPT_MANAGER 와 중복, 한국 SaaS 표준은 부서장 1명)
-- LOCATION_NAME - 위치명 (LOCATION 코드만 유지)
-- SALES_YN - 영업조직 Y/N (ORG_SYSTEM='sales' 와 중복)
-- SHOW_IN_CHART - 조직도 표시 (V2 까지 dead 로직)
-- ERP_MANAGED - ERP 관리 (분기 로직 없음)
-- DATA_TYPE - real/temp (DEPT_TYPE='temp' 와 충돌)
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_SABUN;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS MASTER_USER_ID;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ORG_HEAD;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS LOCATION_NAME;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SALES_YN;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS SHOW_IN_CHART;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS ERP_MANAGED;
ALTER TABLE DEPT_INFO DROP COLUMN IF EXISTS DATA_TYPE;
@@ -728,14 +728,9 @@
DEPT_CODE
, PARENT_DEPT_CODE
, DEPT_NAME
, MASTER_SABUN
, MASTER_USER_ID
, LOCATION
, LOCATION_NAME
, CASE WHEN CREATED_DATE IS NOT NULL THEN TO_CHAR(CREATED_DATE, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') ELSE NULL END AS CREATED_DATE
, DATA_TYPE
, STATUS
, SALES_YN
, COMPANY_CODE
, COMPANY_NAME
FROM DEPT_INFO
@@ -746,8 +741,7 @@
</if>
<if test="search != null and search != ''">
AND (DEPT_NAME ILIKE '%' || #{search} || '%'
OR DEPT_CODE ILIKE '%' || #{search} || '%'
OR LOCATION_NAME ILIKE '%' || #{search} || '%')
OR DEPT_CODE ILIKE '%' || #{search} || '%')
</if>
ORDER BY PARENT_DEPT_CODE ASC NULLS FIRST, DEPT_NAME ASC
</select>
@@ -3,7 +3,7 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="department">
<!-- 부서 목록 조회 (회사별, 부서원 수 포함) -->
<!-- 부서 목록 조회 (회사별, 부서원 수 포함). soft-delete: 기본 active 만, include_deleted=true 시 deleted 포함 -->
<select id="selectDepartments" parameterType="map" resultType="map">
SELECT
D.DEPT_CODE,
@@ -15,29 +15,30 @@
D.ORG_SYSTEM,
D.APPROVAL_MANAGER,
D.DEPT_MANAGER,
D.ORG_HEAD,
D.ZIPCODE,
D.ADDRESS1,
D.ADDRESS2,
D.START_DATE,
D.END_DATE,
D.ERP_MANAGED,
D.SHOW_IN_CHART,
D.SORT_ORDER,
D.STATUS,
D.DELETED_AT,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
FROM DEPT_INFO D
LEFT JOIN USER_DEPT UD ON D.DEPT_CODE = UD.DEPT_CODE
WHERE (D.COMPANY_CODE = #{company_code} OR D.COMPANY_CODE = '*')
<if test="include_deleted == null or include_deleted == false">
AND D.DELETED_AT IS NULL
</if>
GROUP BY
D.DEPT_CODE, D.DEPT_NAME, D.COMPANY_CODE, D.PARENT_DEPT_CODE,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER, D.ORG_HEAD,
D.SHORT_NAME, D.DEPT_TYPE, D.ORG_SYSTEM, D.APPROVAL_MANAGER, D.DEPT_MANAGER,
D.ZIPCODE, D.ADDRESS1, D.ADDRESS2, D.START_DATE, D.END_DATE,
D.ERP_MANAGED, D.SHOW_IN_CHART, D.SORT_ORDER, D.STATUS
D.SORT_ORDER, D.STATUS, D.DELETED_AT
ORDER BY COALESCE(D.SORT_ORDER, 9999), D.DEPT_NAME
</select>
<!-- 부서 단건 조회 -->
<!-- 부서 단건 조회 (active 만) -->
<select id="selectDepartmentByCode" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
@@ -49,33 +50,58 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS
STATUS,
DELETED_AT
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
</select>
<!-- 부서 단건 조회 (deleted 포함) — 복구 검증·복구 처리용 -->
<select id="selectDepartmentByCodeIncludingDeleted" parameterType="map" resultType="map">
SELECT
DEPT_CODE,
DEPT_NAME,
COMPANY_CODE,
PARENT_DEPT_CODE,
SHORT_NAME,
DEPT_TYPE,
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
</select>
<!-- 중복 부서명 확인 -->
<!-- 중복 부서명 확인 (per-tenant, 활성 부서만, 공백/대소문자 무관) -->
<select id="selectDuplicateDeptName" parameterType="map" resultType="map">
SELECT DEPT_CODE, DEPT_NAME
FROM DEPT_INFO
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
AND DEPT_NAME = #{dept_name}
WHERE COMPANY_CODE = #{company_code}
AND DELETED_AT IS NULL
AND TRIM(LOWER(DEPT_NAME)) = TRIM(LOWER(#{dept_name}))
</select>
<!-- 회사명 조회 -->
<!-- 회사명 조회 (정확 매칭, '*' 글로벌 fallback 제거 — selectOne 에서 다중 row 충돌 방지) -->
<select id="selectCompanyName" parameterType="map" resultType="map">
SELECT COMPANY_NAME
FROM COMPANY_MNG
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
WHERE COMPANY_CODE = #{company_code}
LIMIT 1
</select>
<!-- 다음 부서 코드 번호 조회 (전역 카운트) -->
@@ -98,22 +124,14 @@
ORG_SYSTEM,
APPROVAL_MANAGER,
DEPT_MANAGER,
ORG_HEAD,
ZIPCODE,
ADDRESS1,
ADDRESS2,
START_DATE,
END_DATE,
ERP_MANAGED,
SHOW_IN_CHART,
SORT_ORDER,
STATUS,
MASTER_SABUN,
MASTER_USER_ID,
LOCATION,
LOCATION_NAME,
DATA_TYPE,
SALES_YN,
CREATED_DATE
) VALUES (
#{dept_code},
@@ -126,22 +144,14 @@
#{org_system},
#{approval_manager},
#{dept_manager},
#{org_head},
#{zipcode},
#{address1},
#{address2},
#{start_date}::date,
#{end_date}::date,
COALESCE(#{erp_managed}, 'Y'),
COALESCE(#{show_in_chart}, 'Y'),
COALESCE(#{sort_order}, 10),
COALESCE(#{status}, 'active'),
#{master_sabun},
#{master_user_id},
#{location},
#{location_name},
COALESCE(#{data_type}, 'real'),
COALESCE(#{sales_yn}, 'N'),
NOW()
)
</insert>
@@ -157,43 +167,48 @@
ORG_SYSTEM = #{org_system},
APPROVAL_MANAGER = #{approval_manager},
DEPT_MANAGER = #{dept_manager},
ORG_HEAD = #{org_head},
ZIPCODE = #{zipcode},
ADDRESS1 = #{address1},
ADDRESS2 = #{address2},
START_DATE = #{start_date}::date,
END_DATE = #{end_date}::date,
ERP_MANAGED = #{erp_managed},
SHOW_IN_CHART = #{show_in_chart},
SORT_ORDER = #{sort_order},
STATUS = #{status},
MASTER_SABUN = #{master_sabun},
MASTER_USER_ID = #{master_user_id},
LOCATION = #{location},
LOCATION_NAME = #{location_name},
DATA_TYPE = #{data_type},
SALES_YN = #{sales_yn}
LOCATION = #{location}
WHERE DEPT_CODE = #{dept_code}
</update>
<!-- 하위 부서 수 조회 -->
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
<select id="selectChildDeptCount" parameterType="map" resultType="int">
SELECT COUNT(*)
FROM DEPT_INFO
WHERE PARENT_DEPT_CODE = #{dept_code}
<if test="include_deleted == null or include_deleted == false">
AND DELETED_AT IS NULL
</if>
</select>
<!-- 부서 삭제 전 user_dept 삭제 -->
<!-- 부서 삭제 전 user_dept 삭제 (※ Slice 2.1 이후 deleteDepartment 에서는 사용 안 함 — 멤버 보존) -->
<delete id="deleteUserDeptByDeptCode" parameterType="map">
DELETE FROM USER_DEPT
WHERE DEPT_CODE = #{dept_code}
</delete>
<!-- 부서 삭제 -->
<delete id="deleteDepartment" parameterType="map">
DELETE FROM DEPT_INFO
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
<update id="deleteDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NOW()
WHERE DEPT_CODE = #{dept_code}
</delete>
AND DELETED_AT IS NULL
</update>
<!-- 부서 복구 (DELETED_AT = NULL). 호출 전에 부모 deleted 여부 service 에서 검증 -->
<update id="restoreDepartment" parameterType="map">
UPDATE DEPT_INFO
SET DELETED_AT = NULL
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NOT NULL
</update>
<!-- 부서원 목록 조회 -->
<select id="selectDeptMembers" parameterType="map" resultType="map">
@@ -239,14 +254,23 @@
WHERE USER_ID = #{user_id}
</select>
<!-- 기존 부서원 확인 -->
<!-- 기존 부서원 확인 (IS_PRIMARY 포함 — 제거 시 자동 승격 판단용) -->
<select id="selectExistingMember" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
SELECT USER_ID, DEPT_CODE, IS_PRIMARY
FROM USER_DEPT
WHERE USER_ID = #{user_id}
AND DEPT_CODE = #{dept_code}
</select>
<!-- 사용자의 USER_DEPT row 중 첫 번째 (primary 자동 승격용) -->
<select id="selectFirstUserDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
FROM USER_DEPT
WHERE USER_ID = #{user_id}
ORDER BY CREATED_DATE ASC
LIMIT 1
</select>
<!-- 사용자의 주 부서 확인 -->
<select id="selectUserPrimaryDept" parameterType="map" resultType="map">
SELECT USER_ID, DEPT_CODE
+16
View File
@@ -0,0 +1,16 @@
# Windows + Docker Desktop 전용 override.
#
# 배경: Docker Desktop (Windows + WSL2) 의 bind mount 가 host inotify 이벤트를
# 컨테이너로 전파하지 못해, Turbopack 의 file watcher 가 host 편집을 감지 못 함.
# webpack 은 WATCHPACK_POLLING=true 폴백을 지원하므로, Windows 에서만
# Turbopack 을 끄고 webpack 으로 폴백 → 자동 HMR 복원.
#
# 적용 범위: scripts/start/invyone-start-docker-all.bat 에서 명시적으로 -f 추가.
# Mac/Linux 사용자가 쓰는 다른 진입점에는 영향 없음.
#
# Trade-off: 첫 컴파일 약간 느려짐 (~10-30%). 그러나 수정 → 화면 반영이 1~3초로
# 단축되어 전체 개발 사이클은 압도적으로 빨라짐.
services:
frontend:
command: ["npm", "run", "dev:docker:nopack"]
@@ -1,115 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft } from "lucide-react";
import { DepartmentStructure } from "@/components/admin/department/DepartmentStructure";
import { DepartmentMembers } from "@/components/admin/department/DepartmentMembers";
import type { Department } from "@/types/department";
import { getCompanyList } from "@/lib/api/company";
/**
* 부서 관리 메인 페이지
* 좌측: 부서 구조, 우측: 부서 인원
*/
export default function DepartmentManagementPage() {
const params = useParams();
const router = useRouter();
const { companyCode } = params as { companyCode: string };
const [selectedDepartment, setSelectedDepartment] = useState<Department | null>(null);
const [activeTab, setActiveTab] = useState<string>("structure");
const [companyName, setCompanyName] = useState<string>("");
const [refreshTrigger, setRefreshTrigger] = useState(0);
// 부서원 변경 시 부서 구조 새로고침
const handleMemberChange = () => {
setRefreshTrigger((prev) => prev + 1);
};
// 회사 정보 로드
useEffect(() => {
const loadCompanyInfo = async () => {
const response = await getCompanyList();
if (response.success && response.data) {
const company = response.data.find((c) => c.company_code === companyCode);
if (company) {
setCompanyName(company.company_name);
}
}
};
loadCompanyInfo();
}, [companyCode]);
const handleBackToList = () => {
router.push("/admin/userMng/companyList");
};
return (
<div className="space-y-4">
{/* 상단 헤더: 회사 정보 + 뒤로가기 */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={handleBackToList} className="h-9 gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="bg-border h-6 w-px" />
<div>
<h2 className="text-xl font-semibold">{companyName || companyCode}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
</div>
{/* 탭 네비게이션 (모바일용) */}
<div className="lg:hidden">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="structure"> </TabsTrigger>
<TabsTrigger value="members"> </TabsTrigger>
</TabsList>
<TabsContent value="structure" className="mt-4">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</TabsContent>
<TabsContent value="members" className="mt-4">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</TabsContent>
</Tabs>
</div>
{/* 좌우 레이아웃 (데스크톱) */}
<div className="hidden h-full gap-6 lg:flex">
{/* 좌측: 부서 구조 (20%) */}
<div className="w-[20%] border-r pr-6">
<DepartmentStructure
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onSelectDepartment={setSelectedDepartment}
refreshTrigger={refreshTrigger}
/>
</div>
{/* 우측: 부서 인원 (80%) */}
<div className="w-[80%] pl-0">
<DepartmentMembers
companyCode={companyCode}
selectedDepartment={selectedDepartment}
onMemberChange={handleMemberChange}
/>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -17,9 +17,9 @@ interface CompanyTableProps {
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
const router = useRouter();
// 부서 관리 페이지로 이동
const handleManageDepartments = (company: Company) => {
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
// 부서 관리 페이지로 이동 (legacy deptMngList 가 캐노니컬 페이지)
const handleManageDepartments = (_company: Company) => {
router.push(`/admin/userMng/deptMngList`);
};
// 디스크 사용량 포맷팅 함수
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -31,6 +31,9 @@ export function DepartmentStructure({
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
// V1: soft-delete 된 부서 표시 토글
const [showDeleted, setShowDeleted] = useState(false);
// 부서 추가 모달
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
@@ -42,15 +45,15 @@ export function DepartmentStructure({
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드
// 부서 목록 로드 — showDeleted 도 의존성에 포함
useEffect(() => {
loadDepartments();
}, [companyCode, refreshTrigger]);
}, [companyCode, refreshTrigger, showDeleted]);
const loadDepartments = async () => {
setIsLoading(true);
try {
const response = await departmentAPI.getDepartments(companyCode);
const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted });
if (response.success && (response as any).data) {
setDepartments((response as any).data);
} else {
@@ -65,6 +68,34 @@ export function DepartmentStructure({
}
};
// V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기)
const handleRestoreDepartment = async (deptCode: string, deptName: string) => {
try {
const response = await departmentAPI.restoreDepartment(deptCode);
if (response.success) {
loadDepartments();
toast({
title: "부서 복구 완료",
description: `"${deptName}" 부서가 복구되었습니다.`,
variant: "default",
});
} else {
toast({
title: "복구 불가",
description: (response as any).error || "부서 복구에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서 복구 실패:", error);
toast({
title: "부서 복구 실패",
description: "복구 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 트리 구조 생성
const buildTree = (parentCode: string | null): Department[] => {
return departments
@@ -145,10 +176,13 @@ export function DepartmentStructure({
setDeptToDelete(null);
loadDepartments();
// 성공 메시지 Toast로 표시 (부서원 수 포함)
// V1 soft-delete: 복구 가능 안내 추가
const isSoft = (response as any)?.data?.soft_deleted === true;
toast({
title: "부서 삭제 완료",
description: (response as any).message || "부서가 삭제되었습니다.",
title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료",
description: isSoft
? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.`
: (response as any).message || "부서가 삭제되었습니다.",
variant: "default",
});
} else {
@@ -184,17 +218,18 @@ export function DepartmentStructure({
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
const isExpanded = expandedDepts.has(dept.dept_code);
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
const isDeleted = !!(dept as any).deleted_at;
return (
<div key={dept.dept_code}>
{/* 부서 항목 */}
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
<div
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : ""
}`}
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
style={{ marginLeft: `${level * 16}px` }}
>
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && onSelectDepartment(dept)}>
{/* 확장/축소 아이콘 */}
{hasChildren ? (
<button
@@ -218,32 +253,54 @@ export function DepartmentStructure({
<Users className="h-3 w-3" />
<span>{dept.member_count || 0}</span>
</div>
{/* deleted 배지 */}
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
{isDeleted ? (
<Button
variant="ghost"
size="icon"
className="text-primary h-6 w-6"
title="복구"
onClick={(e) => {
e.stopPropagation();
handleRestoreDepartment(dept.dept_code, dept.dept_name);
}}
>
<Undo2 className="h-3 w-3" />
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="하위 부서 추가"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
title="삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
@@ -259,10 +316,23 @@ export function DepartmentStructure({
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* V1: soft-delete 부서 표시 토글 */}
<Button
variant={showDeleted ? "secondary" : "ghost"}
size="sm"
className="h-9 gap-2 text-sm"
onClick={() => setShowDeleted((v) => !v)}
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
>
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
</Button>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 부서 트리 */}
@@ -338,7 +408,9 @@ export function DepartmentStructure({
<p className="text-sm">
<span className="font-semibold">{deptToDelete?.name}</span> ?
</p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
<p className="text-muted-foreground mt-2 text-xs">
. ( '삭제 부서 보기' ) .
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
@@ -71,8 +71,19 @@ export default function Step1Basic({
const r: any = await checkAvailability(payload);
if (payload.subdomain === state.subdomain) {
const sub = r?.subdomain;
// 우선순위: reserved > valid_format > available
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
setSubStatus(
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
!sub
? "idle"
: sub.reserved
? "reserved"
: !sub.valid_format
? "invalid"
: sub.available
? "available"
: "taken",
);
}
if (payload.dbPrefix === state.db_prefix) {
@@ -9,7 +9,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
*/
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
export function Field({
label,
@@ -228,6 +228,21 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
<XCircle size={13} />
</span>
);
if (status === "reserved")
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.72rem",
color: "var(--v5-red)",
fontWeight: 600,
}}
>
<XCircle size={13} /> ( )
</span>
);
return (
<span
style={{
@@ -247,7 +262,7 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
/** TextInput 의 status prop 과 매핑 */
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
if (a === "available") return "ok";
if (a === "taken" || a === "invalid") return "err";
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
return undefined;
}
@@ -0,0 +1,337 @@
"use client";
/**
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
*
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
*
* 사용 예:
* <DepartmentPicker
* companyCode="INVYONE"
* mode="single"
* value={parentDeptCode}
* open={isOpen}
* onSelect={(code) => setParentDeptCode(code as string)}
* onClose={() => setIsOpen(false)}
* excludeCodes={[currentDeptCode]}
* />
*
* 동작:
* - shadcn Dialog 안에 검색박스 + 트리뷰
* - 부모 클릭 시 자식 cascade 펼침
* - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치)
* - single: 클릭 즉시 onSelect → close
* - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect
* - excludeCodes 에 포함된 dept 는 disabled
* - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단
*
* V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정.
*/
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { Department } from "@/types/department";
import { getDepartments } from "@/lib/api/department";
export interface DepartmentPickerProps {
companyCode: string;
mode: "single" | "multi";
/** 현재 선택값. single 이면 string, multi 면 string[] */
value?: string | string[];
open: boolean;
onSelect: (code: string | string[]) => void;
onClose: () => void;
/** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */
excludeCodes?: string[];
/** soft-delete 된 부서도 보여줄지 (default false) */
includeDeleted?: boolean;
/** 모달 헤더 타이틀 (default: "부서 선택") */
title?: string;
}
export function DepartmentPicker({
companyCode,
mode,
value,
open,
onSelect,
onClose,
excludeCodes,
includeDeleted = false,
title = "부서 선택",
}: DepartmentPickerProps) {
const [departments, setDepartments] = useState<Department[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchTerm, setSearchTerm] = useState(""); // debounced
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
// value -> selected 동기화
useEffect(() => {
if (!open) return;
if (mode === "single") {
setSelected(new Set(typeof value === "string" && value ? [value] : []));
} else {
setSelected(new Set(Array.isArray(value) ? value : []));
}
}, [value, mode, open]);
// 검색어 debounce 200ms
useEffect(() => {
const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200);
return () => clearTimeout(t);
}, [searchInput]);
// 부서 목록 로드
useEffect(() => {
if (!open) return;
let cancelled = false;
setIsLoading(true);
getDepartments(companyCode, { includeDeleted })
.then((res: any) => {
if (cancelled) return;
if (res?.success && Array.isArray(res?.data)) {
setDepartments(res.data);
} else {
setDepartments([]);
}
})
.catch(() => !cancelled && setDepartments([]))
.finally(() => !cancelled && setIsLoading(false));
return () => {
cancelled = true;
};
}, [open, companyCode, includeDeleted]);
// 코드 -> 부서 맵 (검색·자식 조회용)
const byCode = useMemo(() => {
const m = new Map<string, Department>();
for (const d of departments) m.set(d.dept_code, d);
return m;
}, [departments]);
// 검색 매칭 (이름·코드 부분일치)
const isMatch = (d: Department): boolean => {
if (!searchTerm) return true;
return (
d.dept_name.toLowerCase().includes(searchTerm) ||
d.dept_code.toLowerCase().includes(searchTerm)
);
};
// 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible
const visibleCodes = useMemo(() => {
if (!searchTerm) return null; // null = 전체 visible
const visible = new Set<string>();
for (const d of departments) {
if (isMatch(d)) {
visible.add(d.dept_code);
// 조상 visible (부모-부모-...) — 사이클 차단
const visited = new Set<string>([d.dept_code]);
let parentCode = d.parent_dept_code;
while (parentCode && !visited.has(parentCode)) {
visited.add(parentCode);
visible.add(parentCode);
parentCode = byCode.get(parentCode)?.parent_dept_code ?? null;
}
}
}
return visible;
}, [searchTerm, departments, byCode]);
// 부모 코드 → 자식 정렬 리스트
const childrenOf = (parentCode: string | null): Department[] => {
return departments
.filter((d) => (d.parent_dept_code ?? null) === parentCode)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
};
// 부모 + 자식 (재귀) cascade 코드들
const collectDescendants = (rootCode: string): string[] => {
const result: string[] = [];
const visited = new Set<string>();
const dfs = (code: string) => {
if (visited.has(code)) return;
visited.add(code);
result.push(code);
for (const child of childrenOf(code)) {
dfs(child.dept_code);
}
};
dfs(rootCode);
return result;
};
const toggleExpand = (code: string) => {
const next = new Set(expanded);
if (next.has(code)) next.delete(code);
else next.add(code);
setExpanded(next);
};
const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code));
const isDeleted = (d: Department) => Boolean((d as any).deleted_at);
const handleNodeClick = (d: Department) => {
if (isExcluded(d.dept_code)) return;
if (mode === "single") {
onSelect(d.dept_code);
onClose();
return;
}
// multi: 자기 + 자손 모두 토글
const next = new Set(selected);
const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c));
const allSelected = codes.every((c) => next.has(c));
if (allSelected) {
for (const c of codes) next.delete(c);
} else {
for (const c of codes) next.add(c);
}
setSelected(next);
};
const handleConfirmMulti = () => {
onSelect(Array.from(selected));
onClose();
};
// 트리 렌더 (재귀, visited Set 사이클 차단)
const renderTree = (parentCode: string | null, level: number, visited: Set<string>): React.ReactNode => {
const list = childrenOf(parentCode);
return list.map((d) => {
if (visited.has(d.dept_code)) return null; // 사이클 차단
const nextVisited = new Set(visited);
nextVisited.add(d.dept_code);
const hasChildren = childrenOf(d.dept_code).length > 0;
const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren);
const isSel = selected.has(d.dept_code);
const excluded = isExcluded(d.dept_code);
const deleted = isDeleted(d);
// 검색 시: visible 아닌 노드는 숨김
if (visibleCodes && !visibleCodes.has(d.dept_code)) return null;
return (
<div key={d.dept_code}>
<div
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
excluded
? "cursor-not-allowed opacity-40"
: "hover:bg-muted cursor-pointer"
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
style={{ paddingLeft: `${level * 16 + 6}px` }}
onClick={() => handleNodeClick(d)}
>
{/* expand/collapse */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(d.dept_code);
}}
className="flex h-4 w-4 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<div className="h-4 w-4" />
)}
{/* multi: 체크 표시 */}
{mode === "multi" && (
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
}`}
>
{isSel && <Check className="h-3 w-3" />}
</div>
)}
{/* 부서명 + 코드 */}
<div className="flex flex-1 flex-col leading-tight">
<span className="font-medium">{d.dept_name}</span>
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
</div>
{deleted && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>
)}
</div>
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
</div>
);
});
};
// 루트 — parent_dept_code 가 null/빈문자열인 부서들
const rootList = useMemo(() => childrenOf(null), [departments]);
const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code);
const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="부서명 또는 코드로 검색"
className="pl-8 pr-8"
autoFocus
/>
{searchInput && (
<button
onClick={() => setSearchInput("")}
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
title="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 트리 */}
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : !hasAny ? (
<div className="text-muted-foreground py-8 text-center text-sm"> .</div>
) : noResults ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
renderTree(null, 0, new Set())
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
{mode === "multi" && (
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
({selected.size})
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DepartmentPicker;
@@ -195,11 +195,6 @@ const DYNAMIC_ADMIN_PATTERNS: Array<{
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
extractParams: (m) => ({ diagramId: m[1] }),
},
{
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
extractParams: (m) => ({ companyCode: m[1] }),
},
{
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
+37 -5
View File
@@ -6,12 +6,18 @@ import { apiClient } from "./client";
import { Department, DepartmentMember, DepartmentFormData } from "@/types/department";
/**
* 부서 목록 조회 (회사별)
* 부서 목록 조회 (회사별).
* options.includeDeleted=true 시 soft-delete 된 부서도 포함.
*/
export async function getDepartments(companyCode: string) {
export async function getDepartments(
companyCode: string,
options?: { includeDeleted?: boolean },
) {
try {
const url = `/departments/companies/${companyCode}/departments`;
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url);
const response = await apiClient.get<{ success: boolean; data: Department[] }>(url, {
params: options?.includeDeleted ? { include_deleted: true } : undefined,
});
return response.data;
} catch (error: any) {
console.error("부서 목록 조회 실패:", error);
@@ -67,11 +73,16 @@ export async function updateDepartment(deptCode: string, data: DepartmentFormDat
}
/**
* 부서 삭제
* 부서 삭제 (V1: soft-delete).
* 응답 호환: 기존 { success, message } 에 data.soft_deleted=true 필드 추가.
*/
export async function deleteDepartment(deptCode: string) {
try {
const response = await apiClient.delete<{ success: boolean }>(`/departments/${deptCode}`);
const response = await apiClient.delete<{
success: boolean;
message?: string;
data?: { soft_deleted?: boolean; dept_code?: string };
}>(`/departments/${deptCode}`);
return response.data;
} catch (error: any) {
console.error("부서 삭제 실패:", error);
@@ -79,6 +90,27 @@ export async function deleteDepartment(deptCode: string) {
}
}
/**
* 부서 복구 (V1 신규 — soft-delete 된 부서 되살리기).
* 부모가 deleted 면 차단 (400) → "상위 부서를 먼저 복구해주세요" 메시지.
*/
export async function restoreDepartment(deptCode: string) {
try {
const response = await apiClient.post<{
success: boolean;
message?: string;
data?: { dept_code?: string; restored?: boolean };
}>(`/departments/${deptCode}/restore`);
return response.data;
} catch (error: any) {
console.error("부서 복구 실패:", error);
return {
success: false,
error: error.response?.data?.message || error.message,
};
}
}
/**
* 부서원 목록 조회
*/
+1
View File
@@ -8,6 +8,7 @@
"scripts": {
"dev": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -p 9771",
"dev:docker": "next dev --turbopack -p 3000",
"dev:docker:nopack": "next dev -p 3000",
"build": "next build",
"build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build",
"start": "next start",
+5 -20
View File
@@ -2,19 +2,14 @@
* 부서 관리 관련 타입 정의
*/
// 부서 정보 (dept_info 테이블 1:1 매핑)
// 부서 정보 (dept_info 테이블 1:1 매핑) — V019 정리 후
export interface Department {
dept_code: string; // 부서 코드 (PK)
parent_dept_code?: string | null; // 상위 부서 코드
dept_name: string; // 부서명
master_sabun?: string | null; // 부서장 사번
master_user_id?: string | null; // 부서장 사용자ID
location?: string | null; // 위치코드
location_name?: string | null; // 위치명
location?: string | null; // 위치코드 (UI hide, V2 매핑용 컬럼만 유지)
created_date?: string | null; // 생성일시
data_type?: string | null; // 데이터 구분 (real/temp)
status?: "active" | "inactive" | null; // 사용여부
sales_yn?: "Y" | "N" | null; // 영업조직 여부
company_name?: string | null; // 회사명
company_code: string; // 회사 코드
short_name?: string | null; // 부서약칭
@@ -22,17 +17,15 @@ export interface Department {
org_system?: string | null; // 조직체계
approval_manager?: string | null; // 결재관리자 user_id
dept_manager?: string | null; // 부서관리자 user_id
org_head?: string | null; // 조직장 user_id
zipcode?: string | null;
address1?: string | null;
address2?: string | null;
start_date?: string | null; // YYYY-MM-DD
end_date?: string | null; // YYYY-MM-DD
erp_managed?: "Y" | "N" | null;
show_in_chart?: "Y" | "N" | null;
sort_order?: number | null;
created_at?: string;
updated_at?: string;
deleted_at?: string | null; // V1: soft-delete 시각. NULL=active, 값 있음=휴지통
// UI용 추가 필드
children?: Department[];
member_count?: number;
@@ -59,7 +52,7 @@ export interface UserDepartmentMapping {
created_at?: string;
}
// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1
// 부서 등록/수정 폼 데이터 — dept_info 스키마 1:1 (V019 정리 후)
export interface DepartmentFormData {
dept_name: string; // 부서명 (필수)
parent_dept_code?: string | null;
@@ -68,23 +61,15 @@ export interface DepartmentFormData {
org_system?: string | null;
approval_manager?: string | null;
dept_manager?: string | null;
org_head?: string | null;
zipcode?: string | null;
address1?: string | null;
address2?: string | null;
start_date?: string | null;
end_date?: string | null;
erp_managed?: "Y" | "N" | null;
show_in_chart?: "Y" | "N" | null;
sort_order?: number | null;
status?: "active" | "inactive" | null;
// dept_info 추가 필드
master_sabun?: string | null;
master_user_id?: string | null;
// dept_info 추가 필드 (location 코드만 유지)
location?: string | null;
location_name?: string | null;
data_type?: string | null;
sales_yn?: "Y" | "N" | null;
dept_code?: string | null; // 일괄등록용 (자동 부여 시 미전달)
}
@@ -0,0 +1,427 @@
# Invyone 부서관리 — SRM_SAMYANGFOOD 대비 부족한 점 분석
> **V1 적용 완료 (2026-05-04, slim scope)**
> Phase 1 (DELETED_AT schema) + Slice 2.1 (soft-delete + 복구) + Slice 2.2 (DepartmentPicker) 적용 완료. 메타 + 3 테넌트 (test01/test02/siflex) 자동 동기화. 변경이력·직책 모델은 V2 행 (사용자 결정).
>
> 적용 우선순위 표 매핑:
> - ✅ #7 Soft-delete (DELETE → DELETED_AT 마킹 + 복구 endpoint + 토글 UI)
> - ✅ #2 부서 선택 팝업 — 컴포넌트 자체 (V1.1 에서 다른 화면 마이그레이션)
> - ⏸️ #1 변경이력 — V2 (감사 요구사항 발생 시)
> - ⏸️ #3 직책 — V2 (결재 모듈 도입 시 함께)
> - 그 외 #4·5·6·8·9·10 모두 V2+
- 작성일: 2026-05-04
- 비교 대상
- **invyone** : `C:\Dev\projects\invyone` (Spring Boot + Next.js 멀티테넌트 로우코드)
- **SRM_SAMYANGFOOD** : `C:\Dev\projects\SRM_SAMYANGFOOD` (Spring + JSP 레거시 SAP 연동 SRM)
- 비교 범위 : 부서관리(부서/조직/Dept) 모듈 한정 — 백엔드/스키마/프론트/연동/감사
---
## 0. 한 줄 요약
Invyone 은 **단일 회사 단위의 부서 CRUD/계층/멤버 할당** 은 잘 작동하지만, 한국형 ERP/SAP 연동 환경에서 표준이 된 **8 가지 축** (결재 라인 통합, 회계/Cost Center 연계, 변경 이력, 외부 시스템 동기화, 직책 모델, 부서 선택 팝업, 법인 분리, 본격적 단계 표현) 이 모두 빠져있다. 현재 코드는 **MVP 수준**이고, ERP 시장 진입 전 보강이 필요.
---
## 1. 두 시스템 스냅샷
### 1.1 SRM_SAMYANGFOOD 부서관리 핵심
- **컨트롤러** : `com.st_ones.everadmin.system.dept.web.DEPT01_Controller` (212줄)
- **서비스** : `DEPT01_Service` (148줄)
- **메인 테이블** : `STOCOGDP` — 부서 마스터, `STOCOGDT` — 디테일
- **외부 동기화 테이블** : `M_DEPT` (그룹웨어/SAP-인접) ← Quartz 배치 `GwDept` 가 정기 풀링
- **UI 화면 4종**
- `DEPT01_010` — 본부/부/팀/파트 4-pane 드릴다운 그리드 (gridT/M/B/DP)
- `DEPT01_020` — 9-단계 TreeGrid + in-cell edit + 자식 카운트
- `DEPT01_020P01` — 부서 상세 팝업 (depth 선택 + insert/update merge)
- `DEPT01_030` — 다른 화면 재사용용 부서 선택 트리 (multi/single)
### 1.2 Invyone 부서관리 핵심
- **컨트롤러** : `com.erp.controller.DepartmentController``/api/departments`
- **서비스** : `com.erp.service.DepartmentService` (BaseService 상속, sqlSession 직접)
- **매퍼** : `mapper/department.xml` (namespace: `department`)
- **테이블** : `DEPT_INFO` (22 컬럼), `USER_DEPT` (사용자↔부서 m:n + IS_PRIMARY)
- **UI 화면 2종**
- `frontend/app/(main)/admin/userMng/deptMngList/page.tsx` (legacy 단독 페이지)
- `frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx` (메인) — `DepartmentStructure` + `DepartmentMembers` 컴포넌트
---
## 2. 갭 — Tier 별 정리
### 🔴 Tier 1 — 운영 표면화 (즉시 막힘)
#### 2.1 결재 라인 통합 부재
| | SRM | Invyone |
|---|---|---|
| 결재 참여 토글 | `STOCOGDP.APPROVE_USE_FLAG` | ❌ 없음 |
| 결재 라인 모델 | AUTH 시스템과 연결 | 없음 |
| 결재 매니저 | TEAM_LEADER_USER_ID + 별도 결재 체인 | `approval_manager` 한 명 필드만, 결재 흐름 미연결 |
**영향** : 결재 모듈을 붙이는 순간 부서 모델을 다시 손대야 한다.
#### 2.2 변경 이력 (Audit Trail) — 백엔드 0%
- 화면(`DepartmentStructure.tsx` 등) 에 `변경이력` 모달 스켈레톤만 있고 백엔드 테이블·쿼리 모두 부재.
- `DEPT_INFO` 컬럼 : `CREATED_DATE` 만, **UPDATED_DATE / UPDATED_BY 없음**.
- SRM 도 완벽하진 않지만 `REG_DATE / MOD_DATE` 는 있음.
**영향** : "누가 언제 부서명 바꿨냐" 추적 불가 → 감사·컴플라이언스 실패.
#### 2.3 부서 선택 재사용 컴포넌트 부재
- SRM `DEPT01_030` : 다른 화면에서 부서 골라야 할 때 띄우는 트리 팝업 (single/multi-select, 부모 클릭 시 자식 cascade).
- Invyone : `searchUsers` (사용자 검색) 만 있음. 부서 picker 가 없어서 다른 화면에서 부서 참조하려면 매번 자체 셀렉트 만들어야 함.
#### 2.4 회계 코드 / Cost Center 미연계
- SRM : `ACC_CODE`, `INDEPT`(내부부서), `DIVISION_YN`.
- Invyone : 없음. 회계/원가 모듈을 붙일 때 부서 schema 재설계 필요.
---
### 🟠 Tier 2 — 한국형 ERP 표준 누락
#### 2.5 직책 (Position) 모델 부재
| | SRM | Invyone |
|---|---|---|
| 부서장 | `TEAM_LEADER_USER_ID` | `dept_manager`, `org_head` (각 1명) |
| 사용자↔부서 관계 | DEPT_CD + 직급 별도 | `USER_DEPT.IS_PRIMARY` boolean 만 |
**영향** : "팀의 PL 3명, 팀장 1명, 부장 1명" 같은 한국 조직 모델 표현 불가.
#### 2.6 DEPT_TYPE 표현력 부족
| 시스템 | enum 값 |
|---|---|
| SRM | `100=Division / 200=Department / 300=Team / 400=SubTeam` + `LVL` 숫자 |
| Invyone | `dept / team / temp` 3종 (`temp` 의미도 모호) |
**영향** : 한국 조직의 "본부 → 실 → 부 → 팀 → 파트" 5단계 구분이 안 들어감.
#### 2.7 외부 시스템 동기화 인프라 부재
- SRM : `GwDept` Quartz 배치 → 그룹웨어 `M_DEPT` 풀링 → 재귀 CTE 트리 재구성 → 회사 코드별(BUYER_CD: 100/300/500/700/800) `STOCOGDP` 클리어 후 일괄 적재.
- Invyone : CSV 한 줄씩 import 외 정기 동기화 없음.
**영향** : ERP 마이그레이션 / SAP 연동 고객 받으면 즉각 장애.
**참고** : 로우코드 플랫폼 철학상 의도적 미구현일 수 있음 → 그러나 ERP 도메인 진입 시 필요.
#### 2.8 법인 / 사업자 정보 분산
- SRM : 부서 자체에 `DEPT_IRS_NUM`(사업자등록번호), `DEPT_CEO_NM`(대표자) — **부서 = 별도 법인 단위 운영 가능**.
- Invyone : 사업자번호·대표자는 `COMPANY_MNG` 에만. **한 회사 안에 복수 법인** (계열사·자회사) 표현 불가 → 회사를 나누는 수밖에 없음.
---
### 🟡 Tier 3 — UX / 조회 효율
| # | 항목 | SRM | Invyone |
|---|---|---|---|
| 2.9 | 자식 노드 카운트 표시 | 트리 노드별 자식 수 표시 | ❌ 펼쳐봐야 알 수 있음 |
| 2.10 | 4-단계 그리드 드릴다운 화면 | `DEPT01_010` 4-pane | ❌ 단일 트리만 |
| 2.11 | In-cell 편집 | TreeGrid 안에서 직접 | ❌ 모달/사이드폼만 |
| 2.12 | 드래그앤드롭 재배열 | (명시적은 없으나 SEQ + tree edit) | ❌ `sort_order` 필드 있음, UI 없음 |
| 2.13 | Org Chart 시각화 | (없음) | ❌ `show_in_chart` 필드 있음 → **죽은 필드** |
| 2.14 | 개인정보 마스킹 | `J*n D*e` 마스킹 | ❌ 없음 |
---
### 🟢 Tier 4 — 모델링 / 성능 / 안정성
#### 2.15 Denormalized 계층 path 부재
- SRM : `ITEM_CLS1` ~ `ITEM_CLS9` + `LVL` 9-단계 비정규화 → breadcrumb · 검색 · 정렬 한 방.
- Invyone : `parent_dept_code` 만 → 깊은 트리는 클라이언트 재귀 join.
- 트레이드오프 : SRM 갱신 비용↑ 조회 비용↓ / Invyone 그 반대.
- **5단계 이상 깊은 조직** 에서는 SRM 모델이 유리.
#### 2.16 Hard Delete (soft-delete 부재)
- SRM : `DEL_FLAG='1'` 논리 삭제 → 이력·복구 가능.
- Invyone : `DELETE` 가 진짜 row 제거 + `USER_DEPT` 강제 정리 → 잘못 지우면 복구 안 됨.
#### 2.17 부서 폐지 / 합병 절차 부재
- SRM : DEL_FLAG + 직원 재배치 + 결재 라인 재구성.
- Invyone : 멤버 자동 제거 후 hard delete — "이 부서 직원들 어디로?" 안내 없음.
---
## 3. 우선순위 권장 (보강 로드맵)
| 순위 | 작업 | 작업량 | 막힘 위험 | 기대 효과 |
|---|---|---|---|---|
| 1 | **변경 이력** : `UPDATED_DATE/BY` + `DEPT_HISTORY` 테이블 + 모달 백엔드 | S | 즉시 감사 실패 | stub → 동작 |
| 2 | **부서 선택 팝업** 컴포넌트 (DEPT01_030 류) | M | 다른 화면 만들 때마다 막힘 | 재사용성 폭발 |
| 3 | **직책 모델** : `USER_DEPT.ROLE_IN_DEPT` (팀장/팀원/매니저...) | S | 권한·결재 모듈에서 필수 | 진짜 조직 표현 |
| 4 | **DEPT_TYPE 확장** : 5-단계 + `LVL` 숫자 | S | 데이터 입력 시 표현 부족 | 단계별 정렬·필터 |
| 5 | **회계 코드** : `ACC_CODE` + 매핑 UI | M | 회계 모듈 붙이는 순간 막힘 | 비용 집계 가능 |
| 6 | **결재 워크플로우** 통합 (`APPROVE_USE_FLAG` + 결재 라인 모델) | L | 결재 모듈 별도 | 한국형 ERP 진입 |
| 7 | **Soft-delete** : `DELETED_AT` + 복구 흐름 | S | 잘못된 hard delete 위험 | 데이터 안정성 |
| 8 | **Org chart 화면** (`show_in_chart` 플래그 활용) | M | 죽은 필드 살리기 | 시각화 |
| 9 | **외부 동기화** : SAP/그룹웨어 API 풀러 | L | 마이그레이션 시 | 이관 가능 |
| 10 | **UX 개선** : 자식 카운트 / 드래그앤드롭 / 드릴다운 그리드 | M~L | UX 차원 | 사용성 |
---
## 4. 추가 관찰
### 4.1 Invyone 강점 (잊지 말 것)
- 멀티테넌시 (`COMPANY_CODE='*'` 글로벌 + 회사별 격리) — SRM 의 `GATE_CD + BUYER_CD` 보다 깔끔
- 사용자 다중 부서 + IS_PRIMARY — SRM 은 사용자 1↔1 부서 기본
- React 컴포넌트화 (`DepartmentStructure` + `DepartmentMembers`) — 재사용성·테스트 용이
- 트랜잭셔널 일관성 + 명확한 cascade (자식 부서 있으면 삭제 거부)
### 4.2 Invyone 의 "이상한" 부분
- `DEPT_TYPE='temp'` — 의미 미정의, 일회성 부서?
- `MASTER_SABUN` (legacy 사번) 컬럼 — 어디서 쓰는지 불분명
- `ERP_MANAGED='Y'` 기본값 — Y/N 인데 실제 분기 로직 안 보임
- `DATA_TYPE='real'|'temp'``DEPT_TYPE='temp'` 와 의미 충돌 가능성
### 4.3 모델링 결정 : 단일 회사 vs. 다법인
Invyone 이 향후 **한 회사 안에서 복수 법인** 운영 (그룹사·계열사 모델) 을 지원할지가 분기점.
- 지원 안 함 : 현재 모델 유지, 회사를 더 만들면 됨 (사업자번호는 COMPANY_MNG 에)
- 지원 : 부서에 `legal_entity_code` 추가 + 사업자 정보 분리 → 큰 마이그레이션
### 4.4 Tier 1~2 만 했을 때의 결과
대략 1~2 sprint 분량. 이걸로 일반 한국 중견기업 ERP 부서관리 표준은 **충분히 커버**.
---
## 5. 다음 액션
- [ ] 본 문서를 stake-holder (사용자/내부) 와 함께 검토 → 우선순위 합의
- [ ] Tier 1 (변경이력 + 부서 선택 팝업 + 직책 모델 + DEPT_TYPE 확장) 1차 설계 문서 작성
- [ ] 마이그레이션 (V018 ~) 초안
- [ ] Tier 1 구현 → Tier 2 → ...
---
## 부록 A. 참고 코드 위치
### Invyone
```
backend-spring/src/main/java/com/erp/controller/DepartmentController.java
backend-spring/src/main/java/com/erp/service/DepartmentService.java
backend-spring/src/main/resources/mapper/department.xml (namespace: department)
frontend/app/(main)/admin/userMng/deptMngList/page.tsx
frontend/app/(main)/admin/userMng/companyList/[companyCode]/departments/page.tsx
frontend/lib/api/department.ts
frontend/types/department.ts
frontend/components/.../DepartmentStructure.tsx
frontend/components/.../DepartmentMembers.tsx
```
### SRM_SAMYANGFOOD
```
src/main/java/com/st_ones/everadmin/system/dept/web/DEPT01_Controller.java
src/main/java/com/st_ones/everadmin/system/dept/service/DEPT01_Service.java
src/main/java/com/st_ones/everadmin/system/dept/DEPT01_Mapper.java
src/main/java/com/st_ones/batch/gwDept/web/GwDept.java
src/main/java/com/st_ones/batch/gwDept/service/GwDept_Service.java
src/main/java/com/st_ones/batch/gwDept/DeptMapper.java
```
## 부록 B. 주요 컬럼 비교 매트릭스
| 의미 | SRM (`STOCOGDP`) | Invyone (`DEPT_INFO`) |
|---|---|---|
| PK | DEPT_CD | DEPT_CODE |
| 이름 | DEPT_NM | DEPT_NAME |
| 상위 | PARENT_DEPT_CD | PARENT_DEPT_CODE |
| 깊이 | LVL | ❌ 없음 |
| Path | ITEM_CLS1 ~ ITEM_CLS9 | ❌ 없음 |
| 단계타입 | DEPT_TYPE (4종) | DEPT_TYPE (3종) |
| 부서장 | TEAM_LEADER_USER_ID | DEPT_MANAGER, ORG_HEAD |
| 결재 토글 | APPROVE_USE_FLAG | ❌ 없음 |
| 회계코드 | ACC_CODE | ❌ 없음 |
| 사업자번호 | DEPT_IRS_NUM | ❌ 부서엔 없음 (회사에만) |
| 대표자 | DEPT_CEO_NM | ❌ 부서엔 없음 |
| 내부부서 | INDEPT | ❌ 없음 |
| 사업부 플래그 | DIVISION_YN | ❌ 없음 |
| 정렬 | SEQ | SORT_ORDER |
| 소프트삭제 | DEL_FLAG | ❌ hard delete |
| 회사범위 | GATE_CD + BUYER_CD | COMPANY_CODE (+ '*' 글로벌) |
| 등록일 | REG_DATE | CREATED_DATE |
| 수정일 | MOD_DATE | ❌ 없음 |
| 시작/종료 | ❌ | START_DATE / END_DATE |
| 위치 | ❌ | LOCATION, LOCATION_NAME, ZIPCODE, ADDRESS1/2 |
| 매출부서 | ❌ | SALES_YN |
| 차트표시 | ❌ | SHOW_IN_CHART |
(Invyone 에만 있는 항목도 일부 있음 — 위치/주소/SALES_YN/SHOW_IN_CHART. 현재 활용도는 낮음.)
---
# 부록 C. KDSCont ↔ SRM_SAMYANGFOOD 부서관리 공통 패턴
(2026-05-04 추가) — KDSCont 도 함께 비교해 본 결과, 두 시스템의 공통점이 단순 우연이 아니라 **동일 SI 벤더의 ERP 프레임워크 표준 청사진** 임이 확인됨. 그래서 이 공통 패턴이 사실상 **한국 ERP/SI 시장의 부서관리 표준** 으로 봐도 됨.
## C.1 결정적 발견 — 같은 SI 벤더 산출물
| 항목 | KDSCont | SRM_SAMYANGFOOD |
|---|---|---|
| 패키지 root | `com.st_ones.eversrm` | `com.st_ones.everadmin` |
| 부서 테이블 | **STOCOGDP** | **STOCOGDP** (동일) |
| 화면 코드 체계 | MOGA0030/0031/0032 | DEPT01_010/020/030 |
| 베이스 클래스 | BaseController / BaseService | BaseController / BaseService |
| 매퍼 방식 | MyBatis XML + Mapper interface | MyBatis XML + Mapper interface |
**stones (st_ones)** 라는 동일 SI 의 ERP 프레임워크 위 다른 프로젝트. 두 시스템의 공통점 = **stones 표준** = 한국 ERP/SI 의 사실상 표준.
---
## C.2 데이터 모델 — 95% 일치하는 부분
### C.2.1 테이블 / PK 구조
```
STOCOGDP (부서 마스터)
PK : (GATE_CD, BUYER_CD, DEPT_CD)
├ 멀티테넌시 : GATE_CD (시스템 단위) + BUYER_CD (회사 단위)
└ 부서 식별 : DEPT_CD
```
### C.2.2 핵심 컬럼 (둘 다 동일)
| 컬럼 | 의미 | 비고 |
|---|---|---|
| `DEPT_CD` | 부서 코드 (PK) | **사용자 직접 입력** + 중복 체크 (auto-gen 아님) |
| `DEPT_NM` | 한국어 부서명 | KDSCont 는 `DEPT_NM_ENG` 영어 칼럼도 있음 |
| `PARENT_DEPT_CD` | 상위 부서 코드 | self-FK; level 1 은 BUYER_CD 가리킴 |
| `LVL` | 깊이 (0=회사 root) | KDSCont 0~4 / SRM 0~9 |
| `DEPT_TYPE` | 단계 enum (100~400) | ★ 핵심 — 100단위 4단계 |
| `SEQ` | 같은 부모 안에서 정렬 | |
| `ACC_CODE` | 회계/Cost Center 코드 | |
| `TEAM_LEADER_USER_ID` | 부서장 user_id (1명) | m:n 아님, 단일 |
| `DIVISION_YN` | 사업부 여부 | |
| `DEL_FLAG` | 소프트 삭제 ('0'/'1') | |
| `REG_DATE`, `MOD_DATE` | 감사 타임스탬프 | |
| `REG_USER_ID`, `MOD_USER_ID` | 감사 사용자 | |
| `ITEM_CLS1` ~ `ITEM_CLS{depth}` | 비정규화 path 세그먼트 | breadcrumb 용 |
### C.2.3 DEPT_TYPE 100/200/300/400 컨벤션 (★ stones 표준)
| 코드 | KDSCont | SRM | 한국 조직 의미 |
|---|---|---|---|
| 100 | 부 (Division) | Division | 본부/사업부 |
| 200 | 팀 (Team) | Department | 부 |
| 300 | 조 (Section) | Team | 팀 |
| 400 | 계 (Sub-section) | SubTeam | 파트 |
핵심 : **100단위 부여, 4-단계 cap, enum 기반 타입 분리**. 한국 SI 시장 사실상 표준.
### C.2.4 계층 표현 — 비정규화 path 컬럼
```
ITEM_CLS1=회사명 ITEM_CLS2=부 ITEM_CLS3=팀 ITEM_CLS4=조 ITEM_CLS5=계
ITEM_CLS_PATH_NM = "회사명 > 부 > 팀 > 조" (concat)
```
양쪽 모두 `parent_dept_cd` 만으로는 안 풀고 path 세그먼트를 박아둠 → 트리·검색·breadcrumb 한 방.
### C.2.5 멀티테넌시 — `GATE_CD + BUYER_CD` 복합
- `GATE_CD` : 시스템/오퍼레이션 단위 (super-user 만 관리)
- `BUYER_CD` : 회사 단위
- 모든 부서 쿼리는 `WHERE GATE_CD=? AND BUYER_CD=?` 필수.
---
## C.3 UI 패턴 — 동일한 화면 구성
### C.3.1 화면 2종 세트 (★)
| 화면 | KDSCont | SRM |
|---|---|---|
| 4-단계 그리드 드릴다운 | MOGA0030 | DEPT01_010 |
| 트리뷰 + in-cell edit | MOGA0031/0032 | DEPT01_020 |
| 부서 상세 팝업 | (그리드 cell 클릭) | DEPT01_020P01 |
| 부서 선택 재사용 팝업 | SP0067/0066 | DEPT01_030 |
### C.3.2 그리드 드릴다운
- 4 패널 grid: LV1 / LV2 / LV3 / LV4
- 라디오로 활성 grid 전환
- LV1 cell 클릭 → LV2 cascade 로드 → ...
### C.3.3 트리뷰 + 인라인 편집
- 회사 root (LV0) 부터 풀 트리 펼침
- ACC_CODE / 부서장 등 selected row 만 편집 → hidden grid 모아서 일괄 저장
### C.3.4 개인정보 마스킹 (★ 양쪽 동일)
- `GETUSERNAME(gateCd, userId, langCd)` 함수가 마스킹 처리
- 첫글자 + `*` + 마지막 N자 (예: `홍*동`, `J*n D*e`)
### C.3.5 사용자 입력 + 중복 체크 (auto-gen 아님)
- DEPT_CD 사용자 직접 입력 → save 시 unique 체크
- SAP/외부 시스템 코드 매핑을 위한 의도적 사용자 제어
---
## C.4 컨벤션 공통점 — invyone 과 다른 부분
| | stones (KDSCont · SRM) | invyone |
|---|---|---|
| 매퍼 | XML + Mapper interface (`@Mapper`) | XML namespace 만, **Mapper interface 금지** |
| DTO | Map + 일부 VO | Map<String, Object> 만 (DTO 금지) |
| 화면 코드 | 4-digit (MOGA0030 / DEPT01_010) | URL 기반 |
| Save 패턴 | MyBatis MERGE / UPSERT | INSERT / UPDATE 분리 |
| 다국어 | DEPT_NM + DEPT_NM_ENG | 미지원 |
| 부서 코드 | 사용자 입력 + 중복체크 | 자동 생성 (`DEPT_<n>`) |
---
## C.5 Invyone 에 시사하는 것
### C.5.1 받아들여야 할 것 (★ 한국 SI 표준)
- **DEPT_TYPE = 100/200/300/400** 4단계 enum (현재 invyone 은 dept/team/temp 3종 → 표현력 부족)
- **`LVL` 숫자 컬럼**
- **`ITEM_CLS1~5` 비정규화 path** + `ITEM_CLS_PATH_NM` 자동 갱신
- **`ACC_CODE`** (Cost Center)
- **`DIVISION_YN`**
- **`REG_USER_ID / MOD_USER_ID / MOD_DATE`** 감사 컬럼
- **`DEL_FLAG`** 소프트삭제 (현재 hard delete)
### C.5.2 UI 에서 받아들여야 할 것
- **4-단계 그리드 드릴다운** 화면 (현재 invyone 은 단일 트리만)
- **부서 선택 팝업** 컴포넌트 (재사용)
- **부서장 user picker + 개인정보 마스킹**
### C.5.3 받아들이지 말 것 (invyone 이 더 나은 부분)
- **GATE_CD + BUYER_CD 2단계 멀티테넌시** → invyone 의 단일 `COMPANY_CODE + '*'` 가 더 깔끔.
- **`@Mapper` interface** → invyone 의 sqlSession 직접 사용이 더 가볍고 CLAUDE.md 원칙과 부합.
- **사용자 직접 입력 DEPT_CD** → 외부 시스템 매핑 필요할 때 옵션으로만, 기본 자동생성 유지.
- **다국어 컬럼** → 진짜 글로벌 진출 전엔 over-engineering, 나중에.
### C.5.4 stones 모델에 없는데 invyone 이 이미 더 나은 부분
- 사용자 ↔ 부서 m:n + IS_PRIMARY (stones 는 1:1)
- 시작/종료 일자 (START_DATE/END_DATE)
- 위치 정보 (LOCATION/ADDRESS)
- React 컴포넌트화
---
## C.6 결론 — 우선순위 보강
본문 §3 의 우선순위에서 **확신도가 올라간 항목** (KDSCont 에서도 동일 패턴 확인):
1.**DEPT_TYPE 4-단계 enum (100/200/300/400)** — 한국 SI 표준
2.**ACC_CODE 회계 코드** — 한국 SI 표준
3.**변경 이력 (MOD_DATE / MOD_USER_ID)** — 한국 SI 표준
4.**DIVISION_YN** — 한국 SI 표준
5.**소프트 삭제 (DEL_FLAG)** — 한국 SI 표준
6.**부서장 user picker + 마스킹** — 한국 SI 표준
7.**`ITEM_CLS1~N` 비정규화 path** — 한국 SI 표준 (성능·breadcrumb 동시 해결)
특히 **`ITEM_CLS1~N` 비정규화 path** 는 양쪽 모두 채택한 만큼, invyone 도 이걸 따르면 깊은 트리 성능 + breadcrumb 표시를 한 번에 잡을 수 있음.
---
## C.7 참고 코드 위치
### KDSCont
```
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0030Controller.java (4-단계 grid)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0031Controller.java (트리뷰)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0032Controller.java (트리뷰 변형)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0020Controller.java (회사관리)
src/main/java/com/st_ones/eversrm/manager/org/web/MOGA0010Controller.java (게이트관리)
src/main/resources/mappers/com/st_ones/eversrm/manager/org/MOGA0030~32Mapper.xml
src/main/webapp/WEB-INF/views/eversrm/manager/org/MOGA0030~32.jsp
```
### SRM_SAMYANGFOOD
```
src/main/java/com/st_ones/everadmin/system/dept/web/DEPT01_Controller.java
src/main/java/com/st_ones/everadmin/system/dept/service/DEPT01_Service.java
src/main/java/com/st_ones/everadmin/system/dept/DEPT01_Mapper.java
src/main/java/com/st_ones/batch/gwDept/web/GwDept.java (그룹웨어 sync 배치)
```
+11 -2
View File
@@ -5,6 +5,9 @@ chcp 65001 >nul
pushd "%~dp0..\.."
set COMPOSE_FILE=docker\dev\docker-compose.invyone.yml
REM Windows 전용 override — Turbopack 끄고 webpack 으로 폴백해서 host 편집 자동 HMR 복원.
REM (Docker Desktop on Windows 의 bind mount inotify 미전파 이슈 회피)
set COMPOSE_WIN=docker\dev\docker-compose.windows.yml
where docker >nul 2>&1
if errorlevel 1 (
@@ -28,9 +31,15 @@ if not exist "%COMPOSE_FILE%" (
pause
exit /b 1
)
if not exist "%COMPOSE_WIN%" (
echo [invyone] Windows override 파일을 찾을 수 없음: %COMPOSE_WIN%
popd
pause
exit /b 1
)
echo [invyone] 도커 컨테이너 기동 중...
docker compose -f %COMPOSE_FILE% up -d
docker compose -f %COMPOSE_FILE% -f %COMPOSE_WIN% up -d
if errorlevel 1 (
echo [invyone] 기동 실패. 로그를 확인해주세요.
popd
@@ -40,7 +49,7 @@ if errorlevel 1 (
echo.
echo [invyone] 컨테이너 상태:
docker compose -f %COMPOSE_FILE% ps
docker compose -f %COMPOSE_FILE% -f %COMPOSE_WIN% ps
echo.
echo [invyone] 접속 URL: