feat(부서관리): V1 슬림 스코프 + 트리 컨텍스트 메뉴 UX 리디자인
백엔드: - V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름 - V019 미사용 컬럼 cleanup (V1 슬림 스코프) - DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드 (자기 자신/자손을 부모로 지정 시도 차단) - DepartmentController, mapper 갱신 프론트: - 부서관리 페이지(deptMngList) UX 리디자인 - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제) - 헤더 breadcrumb 으로 부서 위치 상시 표시 - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화) - 빈 상태 placeholder + X 닫기 동작 - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치) - 부서유형 row 좁은 화면 가로 오버플로 fix - DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단) - 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer) - companyList/[companyCode]/departments 구버전 페이지 삭제 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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("관리자 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
int memberCount = departmentService.deleteDepartment(deptCode);
|
||||
if (memberCount == -1) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
Map<String, Object> existing = departmentService.getDepartmentIncludingDeleted(deptCode);
|
||||
if (existing == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
String message = memberCount > 0
|
||||
? "부서가 삭제되었습니다. (부서원 " + memberCount + "명 제외됨)"
|
||||
: "부서가 삭제되었습니다.";
|
||||
return ResponseEntity.ok(ApiResponse.success(null, message));
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
int result = departmentService.deleteDepartment(deptCode);
|
||||
if (result == -1) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,15 +48,17 @@ public class StartupSchemaMigrator {
|
||||
// 메타 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
|
||||
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'
|
||||
@@ -67,7 +69,27 @@ public class StartupSchemaMigrator {
|
||||
'회사관리', '부서관리', '사용자관리',
|
||||
'메뉴관리', '권한관리', '권한 그룹관리'
|
||||
)
|
||||
"""
|
||||
""",
|
||||
|
||||
// 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;
|
||||
|
||||
// 부서 코드 생성
|
||||
// 부서 코드 결정 — 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;
|
||||
String deptCode = "DEPT_" + nextNumber;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -14,15 +14,19 @@
|
||||
--
|
||||
-- 멱등성: 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
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 +253,33 @@ 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">
|
||||
{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);
|
||||
@@ -237,6 +291,7 @@ export function DepartmentStructure({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
title="삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
||||
@@ -244,6 +299,8 @@ export function DepartmentStructure({
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,11 +316,24 @@ export function DepartmentStructure({
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">부서 구조</h3>
|
||||
<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>
|
||||
|
||||
{/* 부서 트리 */}
|
||||
<div className="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서원 목록 조회
|
||||
*/
|
||||
|
||||
@@ -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; // 일괄등록용 (자동 부여 시 미전달)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user