Merge branch 'main' into hjjeong
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -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;
|
||||
+22
@@ -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
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 배치)
|
||||
```
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user