fix(부서관리): 25개 버그 일괄 수정 + 데이터 무결성 강화 #4
@@ -113,6 +113,10 @@ public class DepartmentController {
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> updated = departmentService.updateDepartment(deptCode, body);
|
||||
@@ -120,6 +124,8 @@ public class DepartmentController {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(updated, "부서가 수정되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
@@ -149,6 +155,10 @@ public class DepartmentController {
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없거나 이미 삭제된 부서입니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
try {
|
||||
int result = departmentService.deleteDepartment(deptCode);
|
||||
@@ -187,8 +197,17 @@ public class DepartmentController {
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
DepartmentService.RestoreResult result = departmentService.restoreDepartment(deptCode);
|
||||
DepartmentService.RestoreResult result;
|
||||
try {
|
||||
result = departmentService.restoreDepartment(deptCode);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
switch (result) {
|
||||
case OK:
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
@@ -234,8 +253,17 @@ public class DepartmentController {
|
||||
@GetMapping("/companies/{companyCode}/users/search")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> searchUsers(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestParam(required = false) String search) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 사용자를 검색할 권한이 없습니다."));
|
||||
}
|
||||
|
||||
if (search == null || search.isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("검색어를 입력해주세요."));
|
||||
}
|
||||
@@ -266,10 +294,13 @@ public class DepartmentController {
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
// 프론트엔드는 snake_case(user_id)로 전송 (Node.js 호환)
|
||||
Object userIdObj = body.get("user_id");
|
||||
if (userIdObj == null) userIdObj = body.get("user_id");
|
||||
Object userIdObj = body.get("user_id") != null ? body.get("user_id") : body.get("userId");
|
||||
if (userIdObj == null || userIdObj.toString().isBlank()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("사용자 ID를 입력해주세요."));
|
||||
}
|
||||
@@ -307,6 +338,10 @@ public class DepartmentController {
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
boolean removed = departmentService.removeDeptMember(deptCode, userId);
|
||||
if (!removed) {
|
||||
@@ -337,9 +372,17 @@ public class DepartmentController {
|
||||
if (!canAccessDept(existing, userCompanyCode)) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("부서를 찾을 수 없습니다."));
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null ? existing.get("company_code").toString() : null;
|
||||
if ("*".equals(deptCompanyCode) && !isSuperAdmin(userCompanyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("글로벌 부서는 SUPER_ADMIN 만 수정/삭제할 수 있습니다."));
|
||||
}
|
||||
|
||||
departmentService.setPrimaryDept(deptCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
||||
try {
|
||||
departmentService.setPrimaryDept(deptCode, userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "주 부서가 설정되었습니다."));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -81,21 +81,29 @@ 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 {
|
||||
// parent_dept_code cross-tenant / 존재 / 삭제 검증
|
||||
Object parentObj = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
||||
String parentCode = parentObj != null ? parentObj.toString() : null;
|
||||
validateParent(parentCode, companyCode);
|
||||
|
||||
// 부서 코드 자동 생성 — 사용자 입력 받지 않음 (정책 변경 2026-05-08)
|
||||
// 재시도 로직 (race condition 대비, 최대 3회)
|
||||
String deptCode = null;
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
Map<String, Object> codeResult = sqlSession.selectOne("department.selectNextDeptNumber", null);
|
||||
long nextNumber = codeResult != null ? ((Number) codeResult.get("next_number")).longValue() : 1L;
|
||||
deptCode = "DEPT_" + nextNumber;
|
||||
long nextNumber = codeResult != null && codeResult.get("next_number") != null
|
||||
? ((Number) codeResult.get("next_number")).longValue()
|
||||
: 1L;
|
||||
String candidate = "DEPT_" + nextNumber;
|
||||
Map<String, Object> existing = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", candidate));
|
||||
if (existing == null) {
|
||||
deptCode = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (deptCode == null) {
|
||||
throw new IllegalStateException("부서 코드 생성 실패 (동시 생성 충돌). 잠시 후 다시 시도해주세요.");
|
||||
}
|
||||
|
||||
// 부서 생성 (전체 필드)
|
||||
@@ -104,7 +112,7 @@ public class DepartmentService extends BaseService {
|
||||
insertParams.put("dept_name", deptName);
|
||||
insertParams.put("company_code", companyCode);
|
||||
insertParams.put("company_name", companyName);
|
||||
insertParams.put("parent_dept_code", nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code")));
|
||||
insertParams.put("parent_dept_code", parentCode);
|
||||
insertParams.put("short_name", nullIfBlank(bodyParam(body, "short_name", "short_name")));
|
||||
insertParams.put("dept_type", bodyParam(body, "dept_type", "dept_type"));
|
||||
insertParams.put("org_system", nullIfBlank(bodyParam(body, "org_system", "org_system")));
|
||||
@@ -135,9 +143,34 @@ public class DepartmentService extends BaseService {
|
||||
throw new IllegalArgumentException("부서명을 입력해주세요.");
|
||||
}
|
||||
|
||||
// 본인 dept 의 company_code 조회 (validateParent + 중복명 검증에 사용)
|
||||
Map<String, Object> existingDept = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", deptCode)
|
||||
);
|
||||
String deptCompanyCode = existingDept != null && existingDept.get("company_code") != null
|
||||
? existingDept.get("company_code").toString()
|
||||
: null;
|
||||
|
||||
// 사이클 가드 — 자기 자신/자손을 부모로 지정하려는 시도 차단
|
||||
Object newParent = nullIfBlank(bodyParam(body, "parent_dept_code", "parent_dept_code"));
|
||||
verifyParentCycle(deptCode, newParent != null ? newParent.toString() : null);
|
||||
String newParentCode = newParent != null ? newParent.toString() : null;
|
||||
// parent_dept_code cross-tenant / 존재 / 삭제 검증
|
||||
if (deptCompanyCode != null) {
|
||||
validateParent(newParentCode, deptCompanyCode);
|
||||
}
|
||||
verifyParentCycle(deptCode, newParentCode);
|
||||
|
||||
// 부서명 중복 검증 — 본인 dept_code 는 제외
|
||||
if (deptCompanyCode != null) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", deptCompanyCode);
|
||||
dupParams.put("dept_name", deptName);
|
||||
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||
throw new DuplicateDeptNameException("\"" + deptName + "\" 부서가 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
@@ -227,6 +260,19 @@ public class DepartmentService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// 동일 이름의 active 부서 중복 검증 (복구 시점)
|
||||
Object companyCodeObj = dept.get("company_code");
|
||||
Object deptNameObj = dept.get("dept_name");
|
||||
if (companyCodeObj != null && deptNameObj != null) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCodeObj.toString());
|
||||
dupParams.put("dept_name", deptNameObj.toString());
|
||||
Map<String, Object> duplicate = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (duplicate != null && !deptCode.equals(duplicate.get("dept_code"))) {
|
||||
throw new IllegalArgumentException("동일한 이름의 활성 부서가 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("dept_code", deptCode);
|
||||
int restored = sqlSession.update("department.restoreDepartment", params);
|
||||
@@ -242,6 +288,28 @@ public class DepartmentService extends BaseService {
|
||||
OK, NOT_FOUND, NOT_DELETED, PARENT_DELETED
|
||||
}
|
||||
|
||||
/**
|
||||
* parent_dept_code 가 (a) 존재하고 (b) 같은 회사이며 (c) deleted 가 아닌지 검증.
|
||||
* null/blank 면 검증 스킵 (최상위 부서).
|
||||
*/
|
||||
private void validateParent(String parentCode, String companyCode) {
|
||||
if (parentCode == null || parentCode.isBlank()) return;
|
||||
Map<String, Object> parent = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted",
|
||||
Map.of("dept_code", parentCode)
|
||||
);
|
||||
if (parent == null) {
|
||||
throw new IllegalArgumentException("상위 부서를 찾을 수 없습니다: " + parentCode);
|
||||
}
|
||||
if (parent.get("deleted_at") != null) {
|
||||
throw new IllegalArgumentException("삭제된 부서를 상위로 지정할 수 없습니다: " + parentCode);
|
||||
}
|
||||
Object parentCompany = parent.get("company_code");
|
||||
if (parentCompany == null || (!companyCode.equals(parentCompany.toString()) && !"*".equals(parentCompany.toString()))) {
|
||||
throw new IllegalArgumentException("다른 회사의 부서를 상위로 지정할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* parent_dept_code 변경 시 사이클 검증.
|
||||
* deptCode 의 새 부모로 newParent 를 지정하려고 할 때, newParent 또는 그 ancestor
|
||||
@@ -355,6 +423,15 @@ public class DepartmentService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public void setPrimaryDept(String deptCode, String userId) {
|
||||
// 멤버십 검증 — 미소속 부서로 호출 시 데이터 손상 방지
|
||||
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);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("해당 부서의 부서원이 아닙니다. 먼저 부서원으로 추가해주세요.");
|
||||
}
|
||||
|
||||
// 다른 부서의 주 부서 해제
|
||||
Map<String, Object> clearParams = new HashMap<>();
|
||||
clearParams.put("user_id", userId);
|
||||
@@ -385,10 +462,13 @@ public class DepartmentService extends BaseService {
|
||||
return val != null ? val : body.get(camelCase);
|
||||
}
|
||||
|
||||
/** 빈 문자열을 null 로 치환 — DATE 컬럼에 '' 바인딩 시 pg cast 에러 나는 걸 방지 */
|
||||
/** 빈 문자열 또는 공백만 있는 문자열을 null 로 치환. 그 외엔 trim 한 값을 반환 */
|
||||
private Object nullIfBlank(Object value) {
|
||||
if (value == null) return null;
|
||||
if (value instanceof String s && s.trim().isEmpty()) return null;
|
||||
if (value instanceof String s) {
|
||||
String trimmed = s.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@
|
||||
STATUS = #{status},
|
||||
LOCATION = #{location}
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
AND DELETED_AT IS NULL
|
||||
</update>
|
||||
|
||||
<!-- 하위 부서 수 조회 (기본 active 자식만, include_deleted=true 시 deleted 자식도 카운트) -->
|
||||
@@ -188,12 +189,6 @@
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 부서 삭제 전 user_dept 삭제 (※ Slice 2.1 이후 deleteDepartment 에서는 사용 안 함 — 멤버 보존) -->
|
||||
<delete id="deleteUserDeptByDeptCode" parameterType="map">
|
||||
DELETE FROM USER_DEPT
|
||||
WHERE DEPT_CODE = #{dept_code}
|
||||
</delete>
|
||||
|
||||
<!-- 부서 삭제 (soft-delete: DELETED_AT = NOW()). USER_DEPT 보존 — 복구 시 멤버 그대로 살아남 -->
|
||||
<update id="deleteDepartment" parameterType="map">
|
||||
UPDATE DEPT_INFO
|
||||
@@ -223,7 +218,7 @@
|
||||
D.DEPT_NAME,
|
||||
UD.IS_PRIMARY
|
||||
FROM USER_DEPT UD
|
||||
JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||
LEFT JOIN USER_INFO U ON UD.USER_ID = U.USER_ID
|
||||
JOIN DEPT_INFO D ON UD.DEPT_CODE = D.DEPT_CODE
|
||||
WHERE UD.DEPT_CODE = #{dept_code}
|
||||
ORDER BY UD.IS_PRIMARY DESC, U.USER_NAME
|
||||
@@ -240,8 +235,8 @@
|
||||
FROM USER_INFO
|
||||
WHERE (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND (
|
||||
USER_ID ILIKE #{search}
|
||||
OR USER_NAME ILIKE #{search}
|
||||
USER_ID ILIKE #{search} ESCAPE '\'
|
||||
OR USER_NAME ILIKE #{search} ESCAPE '\'
|
||||
)
|
||||
ORDER BY USER_NAME
|
||||
LIMIT 20
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 085 마이그레이션 — 부서관리 데이터 무결성 제약 추가
|
||||
|
||||
작성일: 2026-05-08
|
||||
작성자: johngreen
|
||||
관련: 부서관리 페이지 버그 수정 (notes/johngreen/2026-05-08-부서관리-버그-정리.md)
|
||||
|
||||
## 목적
|
||||
|
||||
부서명/부서원 중복을 DB 레벨에서 방어. race condition 시에도 데이터 무결성 보장.
|
||||
|
||||
## 추가 제약
|
||||
|
||||
| 제약 | 대상 | 용도 |
|
||||
|---|---|---|
|
||||
| `idx_dept_info_company_name_unique` | DEPT_INFO `(COMPANY_CODE, LOWER(TRIM(DEPT_NAME))) WHERE DELETED_AT IS NULL` | 회사 내 동일 이름 active 부서 중복 방지 |
|
||||
| `idx_user_dept_user_dept_unique` | USER_DEPT `(USER_ID, DEPT_CODE)` | 동일 사용자가 같은 부서 중복 등록 방지 |
|
||||
|
||||
(USER_DEPT 에 이미 PK 또는 UNIQUE 가 있으면 IF NOT EXISTS 로 안전 적용)
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- 085: 부서관리 무결성 제약
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_dept_info_company_name_unique
|
||||
ON DEPT_INFO (COMPANY_CODE, LOWER(TRIM(DEPT_NAME)))
|
||||
WHERE DELETED_AT IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_dept_user_dept_unique
|
||||
ON USER_DEPT (USER_ID, DEPT_CODE);
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
각 테넌트 DB 에 적용 필요. 운영 DB 와 모든 회사 DB 에 한 번씩.
|
||||
|
||||
```bash
|
||||
# 메타 DB
|
||||
psql -h <host> -U postgres -d invyone -f RUN_085.sql
|
||||
|
||||
# 각 테넌트 DB (예시)
|
||||
for db in $(psql -tA -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
|
||||
psql -h <host> -U postgres -d "$db" -f RUN_085.sql
|
||||
done
|
||||
```
|
||||
|
||||
## 사전 점검
|
||||
|
||||
기존 데이터에 중복이 있으면 인덱스 생성이 실패합니다. 사전 확인:
|
||||
|
||||
```sql
|
||||
-- 부서명 중복 확인
|
||||
SELECT COMPANY_CODE, LOWER(TRIM(DEPT_NAME)), COUNT(*)
|
||||
FROM DEPT_INFO
|
||||
WHERE DELETED_AT IS NULL
|
||||
GROUP BY COMPANY_CODE, LOWER(TRIM(DEPT_NAME))
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- USER_DEPT 중복 확인
|
||||
SELECT USER_ID, DEPT_CODE, COUNT(*)
|
||||
FROM USER_DEPT
|
||||
GROUP BY USER_ID, DEPT_CODE
|
||||
HAVING COUNT(*) > 1;
|
||||
```
|
||||
|
||||
중복이 있다면 수동 정리 후 인덱스 생성.
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS idx_dept_info_company_name_unique;
|
||||
DROP INDEX IF EXISTS idx_user_dept_user_dept_unique;
|
||||
```
|
||||
@@ -110,7 +110,7 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
|
||||
zipcode: "",
|
||||
address1: "",
|
||||
address2: "",
|
||||
start_date: new Date().toISOString().slice(0, 10),
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
sort_order: 10,
|
||||
});
|
||||
@@ -143,12 +143,13 @@ export default function DeptMngListPage() {
|
||||
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
|
||||
const [members, setMembers] = useState<DepartmentMember[]>([]);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
|
||||
|
||||
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkUploading, setBulkUploading] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
|
||||
|
||||
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
||||
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
||||
@@ -221,21 +222,40 @@ export default function DeptMngListPage() {
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
||||
if (cancelled) return;
|
||||
if (res.success && (res as any).data) setMembers((res as any).data);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, selectedCode, isNewMode]);
|
||||
|
||||
// ── 트리 구성 ────────────────────────────────────────
|
||||
const filteredDepts = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return departments;
|
||||
const kw = searchKeyword.toLowerCase();
|
||||
return departments.filter(
|
||||
(d) =>
|
||||
d.dept_name?.toLowerCase().includes(kw) ||
|
||||
d.dept_code?.toLowerCase().includes(kw),
|
||||
// 1차: 직접 매칭된 부서들
|
||||
const directMatches = new Set(
|
||||
departments
|
||||
.filter((d) => d.dept_name?.toLowerCase().includes(kw) || d.dept_code?.toLowerCase().includes(kw))
|
||||
.map((d) => d.dept_code),
|
||||
);
|
||||
// 2차: 매칭된 노드의 모든 조상도 포함 (트리 구조 유지)
|
||||
const visible = new Set<string>(directMatches);
|
||||
const byCode = new Map(departments.map((d) => [d.dept_code, d]));
|
||||
for (const code of directMatches) {
|
||||
let cur: string | null | undefined = byCode.get(code)?.parent_dept_code ?? null;
|
||||
const visited = new Set<string>([code]);
|
||||
while (cur && !visited.has(cur)) {
|
||||
visited.add(cur);
|
||||
visible.add(cur);
|
||||
cur = byCode.get(cur)?.parent_dept_code ?? null;
|
||||
}
|
||||
}
|
||||
return departments.filter((d) => visible.has(d.dept_code));
|
||||
}, [departments, searchKeyword]);
|
||||
|
||||
const childrenOf = useCallback(
|
||||
@@ -247,7 +267,7 @@ export default function DeptMngListPage() {
|
||||
);
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedSet(new Set(filteredDepts.map((d) => d.dept_code)));
|
||||
setExpandedSet(new Set(departments.map((d) => d.dept_code)));
|
||||
};
|
||||
const collapseAll = () => setExpandedSet(new Set());
|
||||
|
||||
@@ -414,6 +434,7 @@ export default function DeptMngListPage() {
|
||||
await loadDepartments();
|
||||
} catch (err: any) {
|
||||
toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" });
|
||||
await loadDepartments(); // 부분 업데이트 가능성 있어 DB 상태로 동기화
|
||||
}
|
||||
};
|
||||
|
||||
@@ -501,15 +522,16 @@ export default function DeptMngListPage() {
|
||||
|
||||
// ── 삭제 ─────────────────────────────────────────────
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCode) return;
|
||||
const target = pendingDeleteDept;
|
||||
if (!target) return;
|
||||
try {
|
||||
const res = await departmentAPI.deleteDepartment(selectedCode);
|
||||
const res = await departmentAPI.deleteDepartment(target.code);
|
||||
if (res.success) {
|
||||
const softDeleted = (res as any).data?.soft_deleted;
|
||||
toast({
|
||||
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
|
||||
description: softDeleted
|
||||
? `"${draft.dept_name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
|
||||
? `"${target.name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
|
||||
: undefined,
|
||||
});
|
||||
await loadDepartments();
|
||||
@@ -519,6 +541,7 @@ export default function DeptMngListPage() {
|
||||
}
|
||||
} finally {
|
||||
setDeleteConfirmOpen(false);
|
||||
setPendingDeleteDept(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -594,14 +617,9 @@ export default function DeptMngListPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
if (!selectedCode) {
|
||||
toast({ title: "부서를 먼저 선택하세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setHistoryOpen(true);
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs opacity-60"
|
||||
disabled
|
||||
title="변경이력 기능은 준비 중입니다"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
변경이력
|
||||
@@ -655,7 +673,16 @@ export default function DeptMngListPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode}>
|
||||
<Select
|
||||
value={selectedCompanyCode}
|
||||
onValueChange={(v) => {
|
||||
setSelectedCompanyCode(v);
|
||||
setSelectedCode(null);
|
||||
setIsNewMode(false);
|
||||
setDraft(emptyDraft(v));
|
||||
setOriginalDraft(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
@@ -799,7 +826,12 @@ export default function DeptMngListPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
onClick={() => {
|
||||
if (selectedCode) {
|
||||
setPendingDeleteDept({ code: selectedCode, name: draft.dept_name });
|
||||
setDeleteConfirmOpen(true);
|
||||
}
|
||||
}}
|
||||
disabled={isNewMode}
|
||||
>
|
||||
삭제
|
||||
@@ -808,7 +840,10 @@ export default function DeptMngListPage() {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClearDetail}
|
||||
onClick={() => {
|
||||
if (isDirty && !window.confirm("저장되지 않은 변경사항이 있습니다. 폐기하시겠습니까?")) return;
|
||||
handleClearDetail();
|
||||
}}
|
||||
title="상세 닫기"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -858,7 +893,18 @@ export default function DeptMngListPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MembersPanel members={members} />
|
||||
<MembersPanel
|
||||
members={members}
|
||||
deptCode={selectedCode!}
|
||||
companyCode={selectedCompany?.company_code ?? ""}
|
||||
onChanged={async () => {
|
||||
if (selectedCode) {
|
||||
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
||||
if (res.success && (res as any).data) setMembers((res as any).data);
|
||||
}
|
||||
await loadDepartments();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -878,17 +924,23 @@ export default function DeptMngListPage() {
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<Dialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={(o) => {
|
||||
setDeleteConfirmOpen(o);
|
||||
if (!o) setPendingDeleteDept(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>부서 삭제</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{draft.dept_name}</span> 부서를 삭제하시겠습니까?
|
||||
<span className="font-semibold">{pendingDeleteDept?.name ?? draft.dept_name}</span> 부서를 삭제하시겠습니까?
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">부서원은 보존됩니다. 휴지통(상단 '삭제 보기' 토글)에서 복구할 수 있습니다.</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>취소</Button>
|
||||
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setPendingDeleteDept(null); }}>취소</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -915,6 +967,7 @@ export default function DeptMngListPage() {
|
||||
<DepartmentPicker
|
||||
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
|
||||
mode="single"
|
||||
allowRoot
|
||||
value={moveTargetDept?.parent_dept_code || ""}
|
||||
open={!!moveTargetDept}
|
||||
onSelect={(code) =>
|
||||
@@ -935,14 +988,15 @@ export default function DeptMngListPage() {
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
||||
<p className="text-muted-foreground">
|
||||
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서코드,부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
||||
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">D001,경영지원본부,,dept</code></p>
|
||||
<p className="mt-1 text-muted-foreground">부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</p>
|
||||
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">경영지원본부,,dept</code></p>
|
||||
</div>
|
||||
<textarea
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
placeholder={"D001,경영지원본부,,dept\nD002,인사팀,D001,team"}
|
||||
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
|
||||
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
@@ -954,36 +1008,39 @@ export default function DeptMngListPage() {
|
||||
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return;
|
||||
setBulkUploading(true);
|
||||
const failures: { line: number; deptName: string; reason: string }[] = [];
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const cols = line.split(",").map((c) => c.trim());
|
||||
const [dept_code, dept_name, parent, dept_type] = cols;
|
||||
if (!dept_code || !dept_name) {
|
||||
failed++;
|
||||
const [dept_name, parent, dept_type] = cols;
|
||||
if (!dept_name) {
|
||||
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
||||
...emptyDraft(selectedCompanyCode),
|
||||
dept_code,
|
||||
dept_name,
|
||||
parent_dept_code: parent || null,
|
||||
dept_type: (dept_type || "dept") as any,
|
||||
} as any);
|
||||
if (res.success) success++;
|
||||
else failed++;
|
||||
} catch {
|
||||
failed++;
|
||||
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
|
||||
} catch (e: any) {
|
||||
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
|
||||
}
|
||||
}
|
||||
setBulkUploading(false);
|
||||
toast({
|
||||
title: `일괄등록 완료`,
|
||||
description: `성공 ${success}건 / 실패 ${failed}건`,
|
||||
variant: failed > 0 ? "destructive" : "default",
|
||||
description: `성공 ${success}건 / 실패 ${failures.length}건`,
|
||||
variant: failures.length > 0 ? "destructive" : "default",
|
||||
});
|
||||
setBulkOpen(false);
|
||||
if (failures.length > 0) {
|
||||
setBulkFailures(failures);
|
||||
} else {
|
||||
setBulkOpen(false);
|
||||
}
|
||||
await loadDepartments();
|
||||
}}
|
||||
>
|
||||
@@ -993,38 +1050,38 @@ export default function DeptMngListPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 변경이력 */}
|
||||
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<DialogContent className="max-w-[720px]">
|
||||
{/* 일괄등록 실패 결과 */}
|
||||
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
|
||||
<DialogContent className="max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
변경이력 {selectedCode && <span className="text-muted-foreground text-sm font-normal">- {draft.dept_name} ({selectedCode})</span>}
|
||||
</DialogTitle>
|
||||
<DialogTitle>일괄등록 실패 항목 ({bulkFailures.length}건)</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold">변경일시</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">작업자</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">변경 항목</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">이전 값 → 신규 값</th>
|
||||
<th className="px-3 py-2 text-left font-semibold w-16">라인</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">부서명</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-12 text-center text-muted-foreground">
|
||||
변경이력 데이터를 불러오는 중이거나, 등록된 이력이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
{bulkFailures.map((f, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-3 py-1.5 font-mono">{f.line}</td>
|
||||
<td className="px-3 py-1.5">{f.deptName}</td>
|
||||
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setHistoryOpen(false)}>닫기</Button>
|
||||
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1254,13 +1311,11 @@ function BasicInfoForm({
|
||||
</Row>
|
||||
|
||||
<Row label="부서코드">
|
||||
<Input
|
||||
value={draft.dept_code}
|
||||
onChange={(e) => update("dept_code", e.target.value)}
|
||||
placeholder="저장 시 자동 부여 (DEPT_n)"
|
||||
className="h-8 text-sm"
|
||||
readOnly={!!draft.dept_code}
|
||||
/>
|
||||
{draft.dept_code ? (
|
||||
<span className="font-mono text-sm">{draft.dept_code}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">저장 시 자동 부여됩니다 (DEPT_n)</span>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
<Row label="부서유형">
|
||||
@@ -1463,14 +1518,195 @@ function PickerField({
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// 사용자 검색 모달
|
||||
// ───────────────────────────────────────────────────────
|
||||
function UserSearchModal({
|
||||
open,
|
||||
companyCode,
|
||||
existingMemberIds,
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
companyCode: string;
|
||||
existingMemberIds: Set<string>;
|
||||
onAdd: (userId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Record<string, any>[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await departmentAPI.searchUsers(companyCode, query.trim());
|
||||
if (res.success && (res as any).data) setResults((res as any).data);
|
||||
else setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, companyCode]);
|
||||
|
||||
const handleAdd = async (userId: string) => {
|
||||
setAddingId(userId);
|
||||
try {
|
||||
await onAdd(userId);
|
||||
onClose();
|
||||
} finally {
|
||||
setAddingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>부서원 추가</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="이름 또는 아이디로 검색..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border bg-card">
|
||||
{searching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">검색 중...</div>
|
||||
) : results.length === 0 && query.trim() ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">사용자를 찾을 수 없음</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">검색어를 입력하세요</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{results.map((u) => {
|
||||
const alreadyMember = existingMemberIds.has(u.user_id);
|
||||
return (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-2.5",
|
||||
alreadyMember ? "opacity-50" : "cursor-pointer hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => !alreadyMember && !addingId && handleAdd(u.user_id)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">{u.user_name}</span>
|
||||
<span className="text-xs text-muted-foreground">({u.user_id})</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
|
||||
{u.position_name && <span>{u.position_name}</span>}
|
||||
{u.email && <span>{u.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{alreadyMember ? (
|
||||
<span className="text-[11px] text-muted-foreground">이미 부서원</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled={addingId === u.user_id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAdd(u.user_id);
|
||||
}}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// 부서원 패널
|
||||
// ───────────────────────────────────────────────────────
|
||||
function MembersPanel({ members }: { members: DepartmentMember[] }) {
|
||||
function MembersPanel({
|
||||
members,
|
||||
deptCode,
|
||||
companyCode,
|
||||
onChanged,
|
||||
}: {
|
||||
members: DepartmentMember[];
|
||||
deptCode: string;
|
||||
companyCode: string;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const existingMemberIds = useMemo(() => new Set(members.map((m) => m.user_id)), [members]);
|
||||
|
||||
const handleAdd = async (userId: string) => {
|
||||
const res = await departmentAPI.addDepartmentMember(deptCode, userId);
|
||||
if (res.success) {
|
||||
toast({ title: "부서원이 추가되었습니다" });
|
||||
await onChanged();
|
||||
} else {
|
||||
toast({ title: "추가 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (userId: string, userName: string) => {
|
||||
if (!window.confirm(`${userName}을(를) 부서에서 제거하시겠습니까?`)) return;
|
||||
const res = await departmentAPI.removeDepartmentMember(deptCode, userId);
|
||||
if (res.success) {
|
||||
toast({ title: "부서원이 제거되었습니다" });
|
||||
await onChanged();
|
||||
} else {
|
||||
toast({ title: "제거 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (userId: string) => {
|
||||
const res = await departmentAPI.setPrimaryDepartment(deptCode, userId);
|
||||
if (res.success) {
|
||||
toast({ title: "주부서로 설정되었습니다" });
|
||||
await onChanged();
|
||||
} else {
|
||||
toast({ title: "주부서 설정 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">부서원 {members.length}명</div>
|
||||
<Button size="sm" className="h-7 gap-1.5 text-xs" onClick={() => setAddModalOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
부서원 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y rounded-md border bg-card">
|
||||
{members.length === 0 ? (
|
||||
@@ -1494,10 +1730,41 @@ function MembersPanel({ members }: { members: DepartmentMember[] }) {
|
||||
{m.phone && <span>{m.phone}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!m.is_primary && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 gap-1 px-2 text-[11px]"
|
||||
onClick={() => handleSetPrimary(m.user_id)}
|
||||
title="주부서로 설정"
|
||||
>
|
||||
<Star className="h-3 w-3" />
|
||||
주부서로 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(m.user_id, m.user_name)}
|
||||
title="부서원 제거"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserSearchModal
|
||||
open={addModalOpen}
|
||||
companyCode={companyCode}
|
||||
existingMemberIds={existingMemberIds}
|
||||
onAdd={handleAdd}
|
||||
onClose={() => setAddModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface DepartmentPickerProps {
|
||||
includeDeleted?: boolean;
|
||||
/** 모달 헤더 타이틀 (default: "부서 선택") */
|
||||
title?: string;
|
||||
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
|
||||
allowRoot?: boolean;
|
||||
}
|
||||
|
||||
export function DepartmentPicker({
|
||||
@@ -62,6 +64,7 @@ export function DepartmentPicker({
|
||||
excludeCodes,
|
||||
includeDeleted = false,
|
||||
title = "부서 선택",
|
||||
allowRoot = false,
|
||||
}: DepartmentPickerProps) {
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -307,6 +310,18 @@ export function DepartmentPicker({
|
||||
|
||||
{/* 트리 */}
|
||||
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
|
||||
{mode === "single" && allowRoot && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect("");
|
||||
onClose();
|
||||
}}
|
||||
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
📂 (최상위 — 부모 없음)
|
||||
</button>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
||||
) : !hasAny ? (
|
||||
|
||||
Reference in New Issue
Block a user