From c350ebe86ac0a6b670302a2f754bdcb3da6bc27a Mon Sep 17 00:00:00 2001 From: johngreen Date: Thu, 14 May 2026 15:19:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=B6=80=EC=84=9C=EA=B4=80=EB=A6=AC):=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EA=B2=B0=EC=9E=AC/=EB=B6=80=EC=84=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20+=20=EC=A1=B0=EC=A7=81=EC=9E=A5?= =?UTF-8?q?=20(DEPT=5FMANAGERS=20=EB=A7=A4=ED=95=91=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마이그레이션 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 단일 컬럼은 호환을 위해 유지. --- .../erp/controller/DepartmentController.java | 2 + .../erp/migration/StartupSchemaMigrator.java | 22 ++- .../com/erp/service/DepartmentService.java | 108 +++++++++++++- .../migration/V022__create_dept_managers.sql | 22 +++ .../src/main/resources/mapper/department.xml | 39 ++++- db/migrations/RUN_088_MIGRATION.md | 133 +++++++++++++++++ .../(main)/admin/userMng/deptMngList/page.tsx | 137 +++++++++++++++++- frontend/types/department.ts | 4 + 8 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql create mode 100644 db/migrations/RUN_088_MIGRATION.md diff --git a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java index 48d90235..44bb42ff 100644 --- a/backend-spring/src/main/java/com/erp/controller/DepartmentController.java +++ b/backend-spring/src/main/java/com/erp/controller/DepartmentController.java @@ -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>> 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>> updateDepartment( diff --git a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java index bbb32623..125f92ec 100644 --- a/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java +++ b/backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java @@ -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) diff --git a/backend-spring/src/main/java/com/erp/service/DepartmentService.java b/backend-spring/src/main/java/com/erp/service/DepartmentService.java index 57e993f8..36019109 100644 --- a/backend-spring/src/main/java/com/erp/service/DepartmentService.java +++ b/backend-spring/src/main/java/com/erp/service/DepartmentService.java @@ -43,6 +43,10 @@ public class DepartmentService extends BaseService { } else { dept.put("member_count", 0); } + // dept_managers JSON 컬럼들 (String) → List 으로 파싱 + 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 getDepartment(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); - return sqlSession.selectOne("department.selectDepartmentByCode", params); + Map 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 getDepartmentIncludingDeleted(String deptCode) { Map params = new HashMap<>(); params.put("dept_code", deptCode); - return sqlSession.selectOne("department.selectDepartmentByCodeIncludingDeleted", params); + Map 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 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 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 dept, String key) { + Object raw = dept.get(key); + if (raw == null) { + dept.put(key, new java.util.ArrayList>()); + return; + } + try { + @SuppressWarnings("unchecked") + java.util.List> parsed = JSON_MAPPER.readValue(raw.toString(), + new com.fasterxml.jackson.core.type.TypeReference>>() {}); + dept.put(key, parsed); + } catch (Exception e) { + dept.put(key, new java.util.ArrayList>()); + } + } + + /** + * 부서 관리자 role 단위 sync — 항상 delete-all + insert-all 패턴. + * body 의 키는 (role 별): "approval_managers" / "dept_managers" / "org_leaders". + * 각 값은 List<Map> 형태이며 각 element 에서 "user_id" 만 추출. + * 최대 10명 검증 + 빈 user_id 무시. + */ + private void syncManagers(String deptCode, Map 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 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 delParams = new HashMap<>(); + delParams.put("dept_code", deptCode); + delParams.put("role", role); + sqlSession.delete("department.deleteDeptManagersByDeptAndRole", delParams); + // insert-all + if (!userIds.isEmpty()) { + Map 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 { diff --git a/backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql b/backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql new file mode 100644 index 00000000..f44f1db1 --- /dev/null +++ b/backend-spring/src/main/resources/db/migration/V022__create_dept_managers.sql @@ -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); diff --git a/backend-spring/src/main/resources/mapper/department.xml b/backend-spring/src/main/resources/mapper/department.xml index 1c492644..f5168e25 100644 --- a/backend-spring/src/main/resources/mapper/department.xml +++ b/backend-spring/src/main/resources/mapper/department.xml @@ -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} @@ -306,4 +324,19 @@ AND DEPT_CODE = #{dept_code} + + + DELETE FROM DEPT_MANAGERS + WHERE DEPT_CODE = #{dept_code} + AND ROLE = #{role} + + + + + INSERT INTO DEPT_MANAGERS (DEPT_CODE, USER_ID, ROLE, SORT_ORDER) VALUES + + (#{dept_code}, #{uid}, #{role}, #{idx} + 1) + + + diff --git a/db/migrations/RUN_088_MIGRATION.md b/db/migrations/RUN_088_MIGRATION.md new file mode 100644 index 00000000..375bce8b --- /dev/null +++ b/db/migrations/RUN_088_MIGRATION.md @@ -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 -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 -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 모달 diff --git a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx index df7da3f3..f92deae9 100644 --- a/frontend/app/(main)/admin/userMng/deptMngList/page.tsx +++ b/frontend/app/(main)/admin/userMng/deptMngList/page.tsx @@ -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({ - update("approval_manager", v)} - placeholder="사용자 이름을 입력해주세요." + update("approval_managers", ids)} + companyCode={draft.company_code} + max={10} /> - update("dept_manager", v)} - placeholder="사용자 이름을 입력해주세요." + update("dept_managers", ids)} + companyCode={draft.company_code} + max={10} + /> + + + update("org_leaders", ids)} + companyCode={draft.company_code} + max={10} /> @@ -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>({}); + + useEffect(() => { + const unknown = userIds.filter((id) => !resolvedNames[id]); + if (unknown.length === 0 || !companyCode) return; + let cancelled = false; + (async () => { + const updates: Record = {}; + 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 ( + <> +
+ {userIds.map((id) => ( +
+ {resolvedNames[id] || id} + ({id}) + +
+ ))} + {userIds.length < max && ( + + )} + {userIds.length >= max && ( + 최대 {max}명 + )} +
+ { + handleAdd(userId); + }} + onClose={() => setPickerOpen(false)} + /> + + ); +} + // ─────────────────────────────────────────────────────── // 사용자 검색 모달 // ─────────────────────────────────────────────────────── diff --git a/frontend/types/department.ts b/frontend/types/department.ts index 5a75eadf..16cdec28 100644 --- a/frontend/types/department.ts +++ b/frontend/types/department.ts @@ -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;