feat(부서관리): 다중 결재/부서 관리자 + 조직장 (DEPT_MANAGERS 매핑 테이블)

- 마이그레이션 V022/RUN_088: DEPT_MANAGERS 신규 (role: approval/dept/org_leader, PK 3-tuple, FK CASCADE)
- StartupSchemaMigrator 에 V022 idempotent CREATE 추가 → 테넌트 DB 자동 동기화
- mapper.xml: SELECT 에 3 json_agg ::TEXT 컬럼 추가, insertDeptManagers + deleteDeptManagersByDeptAndRole 신규
- service: parseManagersJson + syncManagers (delete-all + insert-all, 최대 10명, 역할 한글 메시지)
- frontend: types 3 필드, DeptDetailDraft 확장, ManagerChipsField (chip+UserSearchModal 재사용), 조직장 Row 신규

기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼은 호환을 위해 유지.
This commit is contained in:
2026-05-14 15:19:50 +09:00
parent ecad2915ce
commit c350ebe86a
8 changed files with 449 additions and 18 deletions
@@ -68,6 +68,7 @@ public class DepartmentController {
/**
* 부서 생성
* POST /api/departments/companies/{companyCode}/departments
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
*/
@PostMapping("/companies/{companyCode}/departments")
public ResponseEntity<ApiResponse<Map<String, Object>>> createDepartment(
@@ -96,6 +97,7 @@ public class DepartmentController {
/**
* 부서 수정
* PUT /api/departments/{deptCode}
* body 에 approval_managers/dept_managers/org_leaders 배열 (각 element {user_id: 'xxx'}) 포함 가능. 최대 10명.
*/
@PutMapping("/{deptCode}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateDepartment(
@@ -183,7 +183,27 @@ public class StartupSchemaMigrator {
// conditional 매핑(when/then/default) 규칙 저장용.
// direct/fixed 매핑은 NULL. 메타 DB 는 Flyway V021 로도 적용되지만
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB"
"ALTER TABLE BATCH_MAPPINGS ADD COLUMN IF NOT EXISTS MAPPING_CONFIG JSONB",
// V022 / RUN_088: 부서별 다중 관리자(결재/부서/조직장) 매핑 테이블.
// 기존 DEPT_INFO.APPROVAL_MANAGER/DEPT_MANAGER 단일 컬럼 → 매핑 테이블로 다중화.
// role: 'approval' | 'dept' | 'org_leader'. 부서 hard-delete 시 CASCADE 로 정리.
// 메타 DB 는 Flyway V022 로도 적용되지만 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
"""
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
)
""",
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
);
@EventListener(ApplicationReadyEvent.class)
@@ -43,6 +43,10 @@ public class DepartmentService extends BaseService {
} else {
dept.put("member_count", 0);
}
// dept_managers JSON 컬럼들 (String) → List<Map> 으로 파싱
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return departments;
}
@@ -51,14 +55,26 @@ public class DepartmentService extends BaseService {
public Map<String, Object> getDepartment(String deptCode) {
Map<String, Object> params = new HashMap<>();
params.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", params);
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCode", params);
if (dept != null) {
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return dept;
}
/** 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);
Map<String, Object> dept = sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params);
if (dept != null) {
parseManagersJson(dept, "approval_managers");
parseManagersJson(dept, "dept_managers");
parseManagersJson(dept, "org_leaders");
}
return dept;
}
@Transactional
@@ -134,11 +150,15 @@ public class DepartmentService extends BaseService {
insertParams.put("location", nullIfBlank(bodyParam(body, "location", "location")));
sqlSession.insert("department.insertDepartment", insertParams);
syncManagers(deptCode, body, "approval");
syncManagers(deptCode, body, "dept");
syncManagers(deptCode, body, "org_leader");
log.info("부서 생성 성공: deptCode={}, deptName={}", deptCode, deptName);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
return getDepartment(deptCode);
}
@Transactional
@@ -201,10 +221,12 @@ public class DepartmentService extends BaseService {
return null;
}
syncManagers(deptCode, body, "approval");
syncManagers(deptCode, body, "dept");
syncManagers(deptCode, body, "org_leader");
log.info("부서 수정 성공: deptCode={}", deptCode);
Map<String, Object> findParams = new HashMap<>();
findParams.put("dept_code", deptCode);
return sqlSession.selectOne("department.selectDepartmentByCode", findParams);
return getDepartment(deptCode);
}
/**
@@ -477,6 +499,80 @@ public class DepartmentService extends BaseService {
return value;
}
// ── 관리자 매핑 sync ────────────────────────────────
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
private void parseManagersJson(Map<String, Object> dept, String key) {
Object raw = dept.get(key);
if (raw == null) {
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
return;
}
try {
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> parsed = JSON_MAPPER.readValue(raw.toString(),
new com.fasterxml.jackson.core.type.TypeReference<java.util.List<Map<String, Object>>>() {});
dept.put(key, parsed);
} catch (Exception e) {
dept.put(key, new java.util.ArrayList<Map<String, Object>>());
}
}
/**
* 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴.
* body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders".
* 각 값은 List&lt;Map&gt; 형태이며 각 element 에서 "user_id" 만 추출.
* 최대 10명 검증 + 빈 user_id 무시.
*/
private void syncManagers(String deptCode, Map<String, Object> body, String role) {
String bodyKey = switch (role) {
case "approval" -> "approval_managers";
case "dept" -> "dept_managers";
case "org_leader" -> "org_leaders";
default -> throw new IllegalArgumentException("Unknown role: " + role);
};
Object raw = body.get(bodyKey);
java.util.List<String> userIds = new java.util.ArrayList<>();
if (raw instanceof java.util.List<?> list) {
for (Object item : list) {
String uid = null;
if (item instanceof Map<?, ?> m) {
Object v = m.get("user_id");
if (v != null) uid = v.toString().trim();
} else if (item != null) {
uid = item.toString().trim();
}
if (uid != null && !uid.isEmpty() && !userIds.contains(uid)) {
userIds.add(uid);
}
}
}
if (userIds.size() > 10) {
String roleLabel = switch (role) {
case "approval" -> "결재 관리자";
case "dept" -> "부서 관리자";
case "org_leader" -> "조직장";
default -> role;
};
throw new IllegalArgumentException(roleLabel + " 는 최대 10명까지 등록 가능합니다.");
}
// delete-all
Map<String, Object> delParams = new HashMap<>();
delParams.put("dept_code", deptCode);
delParams.put("role", role);
sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams);
// insert-all
if (!userIds.isEmpty()) {
Map<String, Object> insParams = new HashMap<>();
insParams.put("dept_code", deptCode);
insParams.put("role", role);
insParams.put("user_ids", userIds);
sqlSession.insert("department.insertDeptManagers", insParams);
}
}
// ── 중복 예외 클래스 ────────────────────────────────
public static class DuplicateDeptNameException extends RuntimeException {
@@ -0,0 +1,22 @@
-- =================================================================
-- V022: DEPT_MANAGERS 테이블 (다중 결재/부서/조직장 매핑)
-- =================================================================
-- 기존 DEPT_INFO.APPROVAL_MANAGER / DEPT_MANAGER 단일 컬럼을 매핑 테이블로 다중화.
-- role: 'approval' | 'dept' | 'org_leader'. 부서 삭제(hard) 시 CASCADE 로 정리.
-- 멱등: IF NOT EXISTS 로 재실행 안전.
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
@@ -23,7 +23,13 @@
D.SORT_ORDER,
D.STATUS,
D.DELETED_AT,
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT
COUNT(DISTINCT UD.USER_ID) AS MEMBER_COUNT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = D.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
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 = '*')
@@ -61,7 +67,13 @@
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
DELETED_AT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
AND DELETED_AT IS NULL
@@ -86,7 +98,13 @@
END_DATE,
SORT_ORDER,
STATUS,
DELETED_AT
DELETED_AT,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'approval')::TEXT AS APPROVAL_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'dept')::TEXT AS DEPT_MANAGERS,
(SELECT COALESCE(json_agg(json_build_object('user_id', m.USER_ID) ORDER BY m.SORT_ORDER, m.USER_ID), '[]'::json)
FROM DEPT_MANAGERS m WHERE m.DEPT_CODE = DEPT_INFO.DEPT_CODE AND m.ROLE = 'org_leader')::TEXT AS ORG_LEADERS
FROM DEPT_INFO
WHERE DEPT_CODE = #{dept_code}
</select>
@@ -306,4 +324,19 @@
AND DEPT_CODE = #{dept_code}
</update>
<!-- 부서별 관리자 매핑 (role 단위 sync 용) — 전체 삭제 -->
<delete id="deleteDeptManagersByDeptAndRole" parameterType="map">
DELETE FROM DEPT_MANAGERS
WHERE DEPT_CODE = #{dept_code}
AND ROLE = #{role}
</delete>
<!-- 부서별 관리자 매핑 — bulk insert. parameterType=map, list 와 role 전달. -->
<insert id="insertDeptManagers" parameterType="map">
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES
<foreach collection="user_ids" item="uid" index="idx" separator=",">
(#{dept_code}, #{uid}, #{role}, #{idx} + 1)
</foreach>
</insert>
</mapper>
+133
View File
@@ -0,0 +1,133 @@
# 088 마이그레이션 — DEPT_MANAGERS 테이블 추가 (다중 관리자 + 조직장)
작성일: 2026-05-14
작성자: johngreen
관련: RPS 더존 ERP UJA1040 레퍼런스 대비 누락 기능 (A 단계 — 다중 관리자 + 조직장)
## 목적
부서별로 결재 관리자 / 부서 관리자 / 조직장을 각각 **다중 등록 (최대 10명)** 할 수 있도록 매핑 테이블 신설.
- 기존 `DEPT_INFO.APPROVAL_MANAGER` / `DEPT_INFO.DEPT_MANAGER` 컬럼은 단일 `user_id` 만 저장 가능
- 신규 `DEPT_MANAGERS` 매핑 테이블이 SoT(source of truth). `ROLE` 컬럼으로 3 종류 구분
- `approval` = 결재 관리자 (자동 결재라인 등록 시 호출)
- `dept` = 부서 관리자 (행정 책임자)
- `org_leader` = 조직장 (본인 부서 + 하위 부서의 경비/근태 조회·승인 권한)
- 기존 단일 컬럼은 **호환 위해 일단 유지**. 향후 cleanup PR 에서 제거 예정
## 스키마
### DEPT_MANAGERS (신규)
| 컬럼 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `DEPT_CODE` | VARCHAR(1024) | NOT NULL, FK → DEPT_INFO ON DELETE CASCADE | 부서 코드 |
| `USER_ID` | VARCHAR(50) | NOT NULL | 사용자 ID |
| `ROLE` | VARCHAR(20) | NOT NULL, CHECK | `approval` \| `dept` \| `org_leader` |
| `SORT_ORDER` | INTEGER | NOT NULL DEFAULT 1 | 표시 순서 |
| `CREATED_AT` | TIMESTAMP | NOT NULL DEFAULT NOW() | 등록 시각 |
PK: `(DEPT_CODE, USER_ID, ROLE)` — 같은 사용자가 같은 부서에 같은 role 로 중복 등록 차단.
인덱스: `(DEPT_CODE, ROLE, SORT_ORDER)` — 부서별 role 조회 + 정렬 가속.
## SQL
```sql
-- =================================================================
-- 088: DEPT_MANAGERS 테이블 (idempotent)
-- =================================================================
CREATE TABLE IF NOT EXISTS DEPT_MANAGERS (
DEPT_CODE VARCHAR(1024) NOT NULL,
USER_ID VARCHAR(50) NOT NULL,
ROLE VARCHAR(20) NOT NULL,
SORT_ORDER INTEGER NOT NULL DEFAULT 1,
CREATED_AT TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (DEPT_CODE, USER_ID, ROLE),
CONSTRAINT chk_dept_managers_role
CHECK (ROLE IN ('approval', 'dept', 'org_leader')),
CONSTRAINT fk_dept_managers_dept
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_dept_managers_role
ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER);
```
부팅 시 `StartupSchemaMigrator` 가 메타 DB + 모든 활성 테넌트 DB 에 동일 DDL 을 `IF NOT EXISTS` 로 적용하므로 일반적으로는 별도 수동 실행이 필요 없음.
## 사전 점검
```sql
-- A. 테이블 사전 상태
SELECT table_name FROM information_schema.tables WHERE table_name = 'dept_managers';
-- 빈 결과여야 정상. 이미 있으면 CREATE 의 IF NOT EXISTS 가 안전.
-- B. DEPT_INFO 행수 (FK 영향 범위)
SELECT COUNT(*) FROM DEPT_INFO;
```
## 사후 검증
```sql
-- C. 테이블 추가 확인
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'dept_managers'
ORDER BY ordinal_position;
-- 기대: 5 행 (DEPT_CODE/USER_ID/ROLE/SORT_ORDER/CREATED_AT)
-- D. CHECK 제약 확인
SELECT constraint_name, check_clause FROM information_schema.check_constraints
WHERE constraint_name = 'chk_dept_managers_role';
-- 기대: ROLE IN ('approval', 'dept', 'org_leader')
-- E. FK 동작 확인 (테스트)
BEGIN;
INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE)
VALUES ('NON_EXISTENT_DEPT', 'tester', 'approval');
-- 기대: FK 위반 에러 (foreign key constraint "fk_dept_managers_dept")
ROLLBACK;
```
## 실행
```bash
# 1) 메타 DB
psql -h <host> -U postgres -d invyone -f RUN_088.sql
# 2) 각 테넌트 DB (StartupSchemaMigrator 가 부팅 시 자동 적용하므로 통상 생략 가능)
for db in $(psql -tA -d invyone -c "SELECT db_name FROM company_mng WHERE db_status='active'"); do
echo "=== $db ==="
psql -h <host> -U postgres -d "$db" -f RUN_088.sql
done
```
## 롤백
```sql
-- DEPT_MANAGERS 테이블 제거 (저장된 다중 관리자 매핑 함께 삭제됨)
DROP INDEX IF EXISTS idx_dept_managers_role;
DROP TABLE IF EXISTS DEPT_MANAGERS;
```
롤백 후엔 백엔드/프론트가 단일 `APPROVAL_MANAGER` / `DEPT_MANAGER` 컬럼만 사용하는 이전 동작으로 자연스럽게 복귀 (호환 컬럼 유지하기 때문).
## 적용 환경 체크리스트
- [ ] 로컬 docker `naengangi-pg` (관련 없음 — invyone DB 는 wace/운영에만 존재)
- [ ] wace 개발서버 PostgreSQL
- [ ] 운영 메타 DB (`invyone`)
- [ ] 운영 각 테넌트 DB (loop or 부팅 시 자동)
## 관련 코드
- Flyway: `backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql`
- StartupSchemaMigrator: `backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` (마지막 항목으로 추가)
- Mapper: `backend-spring/src/main/resources/mapper/department.xml`
- `selectDepartments` / `selectDepartmentByCode` 의 SELECT 절에 `APPROVAL_MANAGERS`/`DEPT_MANAGERS`/`ORG_LEADERS` json_agg 컬럼 추가
- 신규 query: `insertDeptManagers`, `deleteDeptManagersByDept`
- Service: `DepartmentService.java`
- `createDepartment` / `updateDepartment` 가 body 의 `approval_managers[]`/`dept_managers[]`/`org_leaders[]` 배열을 `DEPT_MANAGERS` 에 sync (트랜잭션, 최대 10명 검증)
- Frontend: `frontend/app/(main)/admin/userMng/deptMngList/page.tsx`
- BasicInfoForm 에 다중 chip UI + ManagerPicker 모달
@@ -93,6 +93,10 @@ interface DeptDetailDraft {
start_date: string;
end_date: string;
sort_order: number;
// 다중 관리자 (chip UI 용)
approval_managers: string[];
dept_managers: string[];
org_leaders: string[];
}
const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
@@ -113,6 +117,9 @@ const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
start_date: "",
end_date: "",
sort_order: 10,
approval_managers: [],
dept_managers: [],
org_leaders: [],
});
export default function DeptMngListPage() {
@@ -306,6 +313,9 @@ export default function DeptMngListPage() {
end_date: (dept.end_date ?? "").slice(0, 10),
sort_order: dept.sort_order ?? 10,
status: (dept.status as "active" | "inactive") ?? "active",
approval_managers: ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean),
dept_managers: ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean),
org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean),
};
setDraft(loaded);
setOriginalDraft(loaded);
@@ -345,6 +355,10 @@ export default function DeptMngListPage() {
sort_order: d.sort_order ?? 10,
status: d.status ?? "active",
location: d.location ?? "",
// 다중 관리자 보존 (서버 응답 형식 그대로 다시 전달)
approval_managers: (d.approval_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
dept_managers: (d.dept_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
org_leaders: (d.org_leaders || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
...overrides,
}), []);
@@ -494,6 +508,10 @@ export default function DeptMngListPage() {
status: draft.status,
// dept_info 추가 필드 (location 코드만 유지)
location: draft.location,
// 다중 관리자 — backend 가 {user_id} 객체 배열 받음
approval_managers: draft.approval_managers.map((uid) => ({ user_id: uid })),
dept_managers: draft.dept_managers.map((uid) => ({ user_id: uid })),
org_leaders: draft.org_leaders.map((uid) => ({ user_id: uid })),
} as any;
try {
@@ -1366,17 +1384,27 @@ function BasicInfoForm({
</Row>
<Row label="결재 관리자" hint>
<PickerField
value={draft.approval_manager}
onChange={(v) => update("approval_manager", v)}
placeholder="사용자 이름을 입력해주세요."
<ManagerChipsField
userIds={draft.approval_managers}
onChange={(ids) => update("approval_managers", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
<Row label="부서 관리자">
<PickerField
value={draft.dept_manager}
onChange={(v) => update("dept_manager", v)}
placeholder="사용자 이름을 입력해주세요."
<ManagerChipsField
userIds={draft.dept_managers}
onChange={(ids) => update("dept_managers", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
<Row label="조직장" hint>
<ManagerChipsField
userIds={draft.org_leaders}
onChange={(ids) => update("org_leaders", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
@@ -1515,6 +1543,99 @@ function PickerField({
);
}
function ManagerChipsField({
userIds,
onChange,
companyCode,
max,
}: {
userIds: string[];
onChange: (ids: string[]) => void;
companyCode: string;
max: number;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [resolvedNames, setResolvedNames] = useState<Record<string, string>>({});
useEffect(() => {
const unknown = userIds.filter((id) => !resolvedNames[id]);
if (unknown.length === 0 || !companyCode) return;
let cancelled = false;
(async () => {
const updates: Record<string, string> = {};
for (const id of unknown) {
try {
const res = await departmentAPI.searchUsers(companyCode, id);
if (res.success && Array.isArray((res as any).data)) {
const found = (res as any).data.find((u: any) => u.user_id === id);
if (found) updates[id] = found.user_name || id;
}
} catch { /* ignore */ }
}
if (!cancelled && Object.keys(updates).length > 0) {
setResolvedNames((prev) => ({ ...prev, ...updates }));
}
})();
return () => { cancelled = true; };
}, [userIds, companyCode]);
const handleRemove = (id: string) => onChange(userIds.filter((x) => x !== id));
const handleAdd = (id: string) => {
if (userIds.includes(id)) return;
if (userIds.length >= max) return;
onChange([...userIds, id]);
};
return (
<>
<div className="flex flex-wrap items-center gap-1.5">
{userIds.map((id) => (
<div
key={id}
className="flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="font-medium">{resolvedNames[id] || id}</span>
<span className="text-muted-foreground text-[10px]">({id})</span>
<button
type="button"
onClick={() => handleRemove(id)}
className="text-muted-foreground hover:text-destructive ml-0.5"
title="제거"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{userIds.length < max && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setPickerOpen(true)}
disabled={!companyCode}
>
<Plus className="h-3 w-3" />
</Button>
)}
{userIds.length >= max && (
<span className="text-muted-foreground text-[10px]"> {max}</span>
)}
</div>
<UserSearchModal
open={pickerOpen}
companyCode={companyCode}
existingMemberIds={new Set(userIds)}
onAdd={async (userId) => {
handleAdd(userId);
}}
onClose={() => setPickerOpen(false)}
/>
</>
);
}
// ───────────────────────────────────────────────────────
// 사용자 검색 모달
// ───────────────────────────────────────────────────────
+4
View File
@@ -26,6 +26,10 @@ export interface Department {
created_at?: string;
updated_at?: string;
deleted_at?: string | null; // V1: soft-delete 시각. NULL=active, 값 있음=휴지통
// 다중 관리자 매핑 (서버 응답: dept_managers 테이블에서 role 별로 json_agg)
approval_managers?: { user_id: string }[];
dept_managers?: { user_id: string }[];
org_leaders?: { user_id: string }[];
// UI용 추가 필드
children?: Department[];
member_count?: number;