diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 3ac5d26b..5bcda820 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3,7 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { Client } from "pg"; -import { query, queryOne } from "../database/db"; +import { query, queryOne, getPool } from "../database/db"; import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; @@ -3406,3 +3406,395 @@ export async function copyMenu( }); } } + +/** + * ============================================================ + * 사원 + 부서 통합 관리 API + * ============================================================ + * + * 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다. + * + * ## 핵심 기능 + * 1. user_info 테이블에 사원 개인정보 저장 + * 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장 + * 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환 + * 4. 트랜잭션으로 데이터 정합성 보장 + * + * ## 요청 데이터 구조 + * ```json + * { + * "userInfo": { + * "user_id": "string (필수)", + * "user_name": "string (필수)", + * "email": "string", + * "cell_phone": "string", + * "sabun": "string", + * ... + * }, + * "mainDept": { + * "dept_code": "string (필수)", + * "dept_name": "string", + * "position_name": "string" + * }, + * "subDepts": [ + * { + * "dept_code": "string (필수)", + * "dept_name": "string", + * "position_name": "string" + * } + * ] + * } + * ``` + */ + +// 사원 + 부서 저장 요청 타입 +interface UserWithDeptRequest { + userInfo: { + user_id: string; + user_name: string; + user_name_eng?: string; + user_password?: string; + email?: string; + tel?: string; + cell_phone?: string; + sabun?: string; + user_type?: string; + user_type_name?: string; + status?: string; + locale?: string; + // 메인 부서 정보 (user_info에도 저장) + dept_code?: string; + dept_name?: string; + position_code?: string; + position_name?: string; + }; + mainDept?: { + dept_code: string; + dept_name?: string; + position_name?: string; + }; + subDepts?: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + }>; + isUpdate?: boolean; // 수정 모드 여부 +} + +/** + * POST /api/admin/users/with-dept + * 사원 + 부서 통합 저장 API + */ +export const saveUserWithDept = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + const client = await getPool().connect(); + + try { + const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest; + const companyCode = req.user?.companyCode || "*"; + const currentUserId = req.user?.userId; + + logger.info("사원+부서 통합 저장 요청", { + userId: userInfo?.user_id, + mainDept: mainDept?.dept_code, + subDeptsCount: subDepts.length, + isUpdate, + companyCode, + }); + + // 필수값 검증 + if (!userInfo?.user_id || !userInfo?.user_name) { + res.status(400).json({ + success: false, + message: "사용자 ID와 이름은 필수입니다.", + error: { code: "REQUIRED_FIELD_MISSING" }, + }); + return; + } + + // 트랜잭션 시작 + await client.query("BEGIN"); + + // 1. 기존 사용자 확인 + const existingUser = await client.query( + "SELECT user_id FROM user_info WHERE user_id = $1", + [userInfo.user_id] + ); + const isExistingUser = existingUser.rows.length > 0; + + // 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우) + let encryptedPassword = null; + if (userInfo.user_password) { + encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password); + } + + // 3. user_info 저장 (UPSERT) + // mainDept가 있으면 user_info에도 메인 부서 정보 저장 + const deptCode = mainDept?.dept_code || userInfo.dept_code || null; + const deptName = mainDept?.dept_name || userInfo.dept_name || null; + const positionName = mainDept?.position_name || userInfo.position_name || null; + + if (isExistingUser) { + // 기존 사용자 수정 + const updateFields: string[] = []; + const updateValues: any[] = []; + let paramIndex = 1; + + // 동적으로 업데이트할 필드 구성 + const fieldsToUpdate: Record = { + user_name: userInfo.user_name, + user_name_eng: userInfo.user_name_eng, + email: userInfo.email, + tel: userInfo.tel, + cell_phone: userInfo.cell_phone, + sabun: userInfo.sabun, + user_type: userInfo.user_type, + user_type_name: userInfo.user_type_name, + status: userInfo.status || "active", + locale: userInfo.locale, + dept_code: deptCode, + dept_name: deptName, + position_code: userInfo.position_code, + position_name: positionName, + company_code: companyCode !== "*" ? companyCode : undefined, + }; + + // 비밀번호가 제공된 경우에만 업데이트 + if (encryptedPassword) { + fieldsToUpdate.user_password = encryptedPassword; + } + + for (const [key, value] of Object.entries(fieldsToUpdate)) { + if (value !== undefined) { + updateFields.push(`${key} = $${paramIndex}`); + updateValues.push(value); + paramIndex++; + } + } + + if (updateFields.length > 0) { + updateValues.push(userInfo.user_id); + await client.query( + `UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`, + updateValues + ); + } + } else { + // 새 사용자 등록 + await client.query( + `INSERT INTO user_info ( + user_id, user_name, user_name_eng, user_password, + email, tel, cell_phone, sabun, + user_type, user_type_name, status, locale, + dept_code, dept_name, position_code, position_name, + company_code, regdate + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`, + [ + userInfo.user_id, + userInfo.user_name, + userInfo.user_name_eng || null, + encryptedPassword || null, + userInfo.email || null, + userInfo.tel || null, + userInfo.cell_phone || null, + userInfo.sabun || null, + userInfo.user_type || null, + userInfo.user_type_name || null, + userInfo.status || "active", + userInfo.locale || null, + deptCode, + deptName, + userInfo.position_code || null, + positionName, + companyCode !== "*" ? companyCode : null, + ] + ); + } + + // 4. user_dept 처리 + if (mainDept?.dept_code || subDepts.length > 0) { + // 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용) + const existingDepts = await client.query( + "SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1", + [userInfo.user_id] + ); + const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true); + + // 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환 + if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) { + logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", { + userId: userInfo.user_id, + oldMain: existingMainDept.dept_code, + newMain: mainDept.dept_code, + }); + + await client.query( + "UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2", + [userInfo.user_id, existingMainDept.dept_code] + ); + } + + // 4-3. 기존 겸직 부서 삭제 (메인 제외) + // 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제 + await client.query( + "DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false", + [userInfo.user_id] + ); + + // 4-4. 메인 부서 저장 (UPSERT) + if (mainDept?.dept_code) { + await client.query( + `INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at) + VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id, dept_code) DO UPDATE SET + is_primary = true, + dept_name = $3, + user_name = $4, + position_name = $5, + company_code = $6, + updated_at = NOW()`, + [ + userInfo.user_id, + mainDept.dept_code, + mainDept.dept_name || null, + userInfo.user_name, + mainDept.position_name || null, + companyCode !== "*" ? companyCode : null, + ] + ); + } + + // 4-5. 겸직 부서 저장 + for (const subDept of subDepts) { + if (!subDept.dept_code) continue; + + // 메인 부서와 같은 부서는 겸직으로 추가하지 않음 + if (mainDept?.dept_code === subDept.dept_code) continue; + + await client.query( + `INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at) + VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (user_id, dept_code) DO UPDATE SET + is_primary = false, + dept_name = $3, + user_name = $4, + position_name = $5, + company_code = $6, + updated_at = NOW()`, + [ + userInfo.user_id, + subDept.dept_code, + subDept.dept_name || null, + userInfo.user_name, + subDept.position_name || null, + companyCode !== "*" ? companyCode : null, + ] + ); + } + } + + // 트랜잭션 커밋 + await client.query("COMMIT"); + + logger.info("사원+부서 통합 저장 완료", { + userId: userInfo.user_id, + isUpdate: isExistingUser, + }); + + res.json({ + success: true, + message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.", + data: { + userId: userInfo.user_id, + isUpdate: isExistingUser, + }, + }); + } catch (error: any) { + // 트랜잭션 롤백 + await client.query("ROLLBACK"); + + logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body }); + + // 중복 키 에러 처리 + if (error.code === "23505") { + res.status(400).json({ + success: false, + message: "이미 존재하는 사용자 ID입니다.", + error: { code: "DUPLICATE_USER_ID" }, + }); + return; + } + + res.status(500).json({ + success: false, + message: "사원 저장 중 오류가 발생했습니다.", + error: { code: "SAVE_ERROR", details: error.message }, + }); + } finally { + client.release(); + } +} + +/** + * GET /api/admin/users/:userId/with-dept + * 사원 + 부서 정보 조회 API (수정 모달용) + */ +export const getUserWithDept = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const { userId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + logger.info("사원+부서 조회 요청", { userId, companyCode }); + + // 1. user_info 조회 + let userQuery = "SELECT * FROM user_info WHERE user_id = $1"; + const userParams: any[] = [userId]; + + // 최고 관리자가 아니면 회사 필터링 + if (companyCode !== "*") { + userQuery += " AND company_code = $2"; + userParams.push(companyCode); + } + + const userResult = await query(userQuery, userParams); + + if (userResult.length === 0) { + res.status(404).json({ + success: false, + message: "사용자를 찾을 수 없습니다.", + error: { code: "USER_NOT_FOUND" }, + }); + return; + } + + const userInfo = userResult[0]; + + // 2. user_dept 조회 (메인 + 겸직) + let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC"; + const deptResult = await query(deptQuery, [userId]); + + const mainDept = deptResult.find((d: any) => d.is_primary === true); + const subDepts = deptResult.filter((d: any) => d.is_primary === false); + + res.json({ + success: true, + data: { + userInfo, + mainDept: mainDept || null, + subDepts, + }, + }); + } catch (error: any) { + logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId }); + res.status(500).json({ + success: false, + message: "사원 조회 중 오류가 발생했습니다.", + error: { code: "QUERY_ERROR", details: error.message }, + }); + } +} diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 188e5580..b9964962 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -18,6 +18,8 @@ import { getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 + saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) + getUserWithDept, // 사원 + 부서 조회 (NEW!) getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyByCode, // 회사 단건 조회 @@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 +router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!) router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.post("/users", saveUser); // 사용자 등록/수정 (기존) +router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!) router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 83c725c2..6a829042 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) { return response.data; } +// ============================================================ +// 사원 + 부서 통합 관리 API +// ============================================================ + +/** + * 사원 + 부서 정보 저장 요청 타입 + */ +export interface SaveUserWithDeptRequest { + userInfo: { + user_id: string; + user_name: string; + user_name_eng?: string; + user_password?: string; + email?: string; + tel?: string; + cell_phone?: string; + sabun?: string; + user_type?: string; + user_type_name?: string; + status?: string; + locale?: string; + dept_code?: string; + dept_name?: string; + position_code?: string; + position_name?: string; + }; + mainDept?: { + dept_code: string; + dept_name?: string; + position_name?: string; + }; + subDepts?: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + }>; + isUpdate?: boolean; +} + +/** + * 사원 + 부서 정보 응답 타입 + */ +export interface UserWithDeptResponse { + userInfo: Record; + mainDept: { + dept_code: string; + dept_name?: string; + position_name?: string; + is_primary: boolean; + } | null; + subDepts: Array<{ + dept_code: string; + dept_name?: string; + position_name?: string; + is_primary: boolean; + }>; +} + +/** + * 사원 + 부서 통합 저장 + * + * user_info와 user_dept 테이블에 트랜잭션으로 동시 저장합니다. + * - 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환 + * - 겸직 부서는 전체 삭제 후 재입력 방식 + * + * @param data 저장할 사원 및 부서 정보 + * @returns 저장 결과 + */ +export async function saveUserWithDept(data: SaveUserWithDeptRequest): Promise> { + try { + console.log("사원+부서 통합 저장 API 호출:", data); + + const response = await apiClient.post("/admin/users/with-dept", data); + + console.log("사원+부서 통합 저장 API 응답:", response.data); + return response.data; + } catch (error: any) { + console.error("사원+부서 통합 저장 API 오류:", error); + + // Axios 에러 응답 처리 + if (error.response?.data) { + return error.response.data; + } + + return { + success: false, + message: error.message || "사원 저장 중 오류가 발생했습니다.", + }; + } +} + +/** + * 사원 + 부서 정보 조회 (수정 모달용) + * + * user_info와 user_dept 정보를 함께 조회합니다. + * + * @param userId 조회할 사용자 ID + * @returns 사원 정보 및 부서 관계 정보 + */ +export async function getUserWithDept(userId: string): Promise> { + try { + console.log("사원+부서 조회 API 호출:", userId); + + const response = await apiClient.get(`/admin/users/${userId}/with-dept`); + + console.log("사원+부서 조회 API 응답:", response.data); + return response.data; + } catch (error: any) { + console.error("사원+부서 조회 API 오류:", error); + + if (error.response?.data) { + return error.response.data; + } + + return { + success: false, + message: error.message || "사원 조회 중 오류가 발생했습니다.", + }; + } +} + // 사용자 API 객체로 export export const userAPI = { getList: getUserList, @@ -195,4 +316,7 @@ export const userAPI = { getCompanyList: getCompanyList, getDepartmentList: getDepartmentList, checkDuplicateId: checkDuplicateUserId, + // 사원 + 부서 통합 관리 + saveWithDept: saveUserWithDept, + getWithDept: getUserWithDept, }; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index e8400c49..3bdd2015 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -200,7 +200,11 @@ export const SplitPanelLayout2Component: React.FC { - // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명 + // 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용) + const tableColumnKey = `${joinConfig.joinTable}.${col}`; + mergedItem[tableColumnKey] = joinRow[col]; + + // alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성) const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col; // 메인 테이블에 같은 컬럼이 없으면 추가 if (!(col in mergedItem)) { @@ -210,6 +214,7 @@ export const SplitPanelLayout2Component: React.FC { + // col.name이 "테이블명.컬럼명" 형식인 경우 처리 + const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name; + const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null; + const effectiveSourceTable = col.sourceTable || tableFromName; + + // sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우 + if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) { + // 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식) + const tableColumnKey = `${effectiveSourceTable}.${actualColName}`; + if (item[tableColumnKey] !== undefined) { + return item[tableColumnKey]; + } + // 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도 + const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable); + if (joinTable?.alias) { + const aliasKey = `${joinTable.alias}_${actualColName}`; + if (item[aliasKey] !== undefined) { + return item[aliasKey]; + } + } + // 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감) + if (item[actualColName] !== undefined) { + return item[actualColName]; + } + } + // 4. 기본: 컬럼명으로 직접 접근 + return item[actualColName]; + }, [config.rightPanel?.tableName, config.rightPanel?.joinTables]); + // 값 포맷팅 const formatValue = (value: any, format?: ColumnConfig["format"]): string => { if (value === null || value === undefined) return "-"; @@ -916,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{nameRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -931,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{infoRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -950,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{nameRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; if (idx === 0) { return ( @@ -971,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC 0 && (
{infoRowColumns.map((col, idx) => { - const value = item[col.name]; + const value = getColumnValue(item, col); if (value === null || value === undefined) return null; return ( @@ -1079,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC ( - {formatValue(item[col.name], col.format)} + {formatValue(getColumnValue(item, col), col.format)} ))} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 1a32f2ca..c875316a 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -279,12 +279,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC { const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol); if (col) { joinColumns.push({ ...col, + // 유니크 키를 위해 테이블명_컬럼명 형태로 저장 + column_name: `${jt.joinTable}.${col.column_name}`, column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`, }); } @@ -727,8 +729,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC { - // 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함) - const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")"); + // 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태) + const isJoinColumn = c.column_name.includes("."); if (selectedSourceTable === config.rightPanel?.tableName) { // 메인 테이블 선택 시: 조인 컬럼 아닌 것만 return !isJoinColumn; } else { - // 조인 테이블 선택 시: 해당 테이블 컬럼만 - return c.column_comment?.includes(`(${selectedSourceTable})`); + // 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태) + return c.column_name.startsWith(`${selectedSourceTable}.`); } }); @@ -1163,11 +1170,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC { // 조인 컬럼의 경우 테이블명 제거하고 표시 const displayLabel = c.column_comment?.replace(/\s*\([^)]+\)$/, "") || c.column_name; + // 실제 컬럼명 (테이블명.컬럼명에서 컬럼명만 추출) + const actualColumnName = c.column_name.includes(".") + ? c.column_name.split(".")[1] + : c.column_name; return ( {displayLabel} - {c.column_name} + {actualColumnName} ); @@ -1231,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC { const current = config.rightPanel?.searchColumns || []; updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]); @@ -1240,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+

+ 표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요. +

- {(config.rightPanel?.searchColumns || []).map((searchCol, index) => ( -
- { - const current = [...(config.rightPanel?.searchColumns || [])]; - current[index] = { ...current[index], columnName: value }; - updateConfig("rightPanel.searchColumns", current); - }} - placeholder="컬럼 선택" - /> - + {(config.rightPanel?.searchColumns || []).map((searchCol, index) => { + // 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시 + const displayColumns = config.rightPanel?.displayColumns || []; + + // 유효한 컬럼만 필터링 (name이 있는 것만) + const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== ""); + + // 현재 선택된 컬럼의 표시 정보 + const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName); + const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName); + const selectedLabel = selectedDisplayCol?.label || + selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || + searchCol.columnName; + const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || ""; + const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName; + + return ( +
+ + +
+ ); + })} + {(config.rightPanel?.displayColumns || []).length === 0 && ( +
+ 먼저 표시할 컬럼을 추가하세요
- ))} - {(config.rightPanel?.searchColumns || []).length === 0 && ( + )} + {(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
검색할 컬럼을 추가하세요
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 4f2f5c6b..3938645d 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -444,65 +444,8 @@ export function UniversalFormModalComponent({ return { valid: missingFields.length === 0, missingFields }; }, [config.sections, formData]); - // 저장 처리 - const handleSave = useCallback(async () => { - if (!config.saveConfig.tableName) { - toast.error("저장할 테이블이 설정되지 않았습니다."); - return; - } - - // 필수 필드 검증 - const { valid, missingFields } = validateRequiredFields(); - if (!valid) { - toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); - return; - } - - setSaving(true); - - try { - const { multiRowSave } = config.saveConfig; - - if (multiRowSave?.enabled) { - // 다중 행 저장 - await saveMultipleRows(); - } else { - // 단일 행 저장 - await saveSingleRow(); - } - - // 저장 후 동작 - if (config.saveConfig.afterSave?.showToast) { - toast.success("저장되었습니다."); - } - - if (config.saveConfig.afterSave?.refreshParent) { - window.dispatchEvent(new CustomEvent("refreshParentData")); - } - - // onSave 콜백은 저장 완료 알림용으로만 사용 - // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) - // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 - // _saveCompleted 플래그를 포함하여 전달 - if (onSave) { - onSave({ ...formData, _saveCompleted: true }); - } - } catch (error: any) { - console.error("저장 실패:", error); - // axios 에러의 경우 서버 응답 메시지 추출 - const errorMessage = - error.response?.data?.message || - error.response?.data?.error?.details || - error.message || - "저장에 실패했습니다."; - toast.error(errorMessage); - } finally { - setSaving(false); - } - }, [config, formData, repeatSections, onSave, validateRequiredFields]); - // 단일 행 저장 - const saveSingleRow = async () => { + const saveSingleRow = useCallback(async () => { const dataToSave = { ...formData }; // 메타데이터 필드 제거 @@ -534,15 +477,15 @@ export function UniversalFormModalComponent({ if (!response.data?.success) { throw new Error(response.data?.message || "저장 실패"); } - }; + }, [config.sections, config.saveConfig.tableName, formData]); // 다중 행 저장 (겸직 등) - const saveMultipleRows = async () => { + const saveMultipleRows = useCallback(async () => { const { multiRowSave } = config.saveConfig; if (!multiRowSave) return; - let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = - multiRowSave; + let { commonFields = [], repeatSectionId = "" } = multiRowSave; + const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave; // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용 if (commonFields.length === 0) { @@ -563,56 +506,57 @@ export function UniversalFormModalComponent({ // 디버깅: 설정 확인 console.log("[UniversalFormModal] 다중 행 저장 설정:", { commonFields, - mainSectionFields, repeatSectionId, + mainSectionFields, typeColumn, mainTypeValue, subTypeValue, + repeatSections, + formData, }); - console.log("[UniversalFormModal] 현재 formData:", formData); - // 공통 필드 데이터 추출 - const commonData: Record = {}; - for (const fieldName of commonFields) { + // 반복 섹션 데이터 + const repeatItems = repeatSections[repeatSectionId] || []; + + // 저장할 행들 생성 + const rowsToSave: any[] = []; + + // 공통 데이터 (모든 행에 적용) + const commonData: any = {}; + commonFields.forEach((fieldName) => { if (formData[fieldName] !== undefined) { commonData[fieldName] = formData[fieldName]; } - } - console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData); + }); - // 메인 섹션 필드 데이터 추출 - const mainSectionData: Record = {}; - if (mainSectionFields && mainSectionFields.length > 0) { - for (const fieldName of mainSectionFields) { - if (formData[fieldName] !== undefined) { - mainSectionData[fieldName] = formData[fieldName]; - } + // 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등) + const mainSectionData: any = {}; + mainSectionFields.forEach((fieldName) => { + if (formData[fieldName] !== undefined) { + mainSectionData[fieldName] = formData[fieldName]; } - } - console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData); + }); - // 저장할 행들 준비 - const rowsToSave: Record[] = []; + console.log("[UniversalFormModal] 공통 데이터:", commonData); + console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData); + console.log("[UniversalFormModal] 반복 항목:", repeatItems); - // 1. 메인 행 생성 - const mainRow: Record = { - ...commonData, - ...mainSectionData, - }; + // 메인 행 (공통 데이터 + 메인 섹션 필드) + const mainRow: any = { ...commonData, ...mainSectionData }; if (typeColumn) { mainRow[typeColumn] = mainTypeValue || "main"; } rowsToSave.push(mainRow); - // 2. 반복 섹션 행들 생성 (겸직 등) - const repeatItems = repeatSections[repeatSectionId] || []; + // 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드) for (const item of repeatItems) { - const subRow: Record = { ...commonData }; + const subRow: any = { ...commonData }; - // 반복 섹션 필드 복사 - Object.keys(item).forEach((key) => { - if (!key.startsWith("_")) { - subRow[key] = item[key]; + // 반복 섹션의 필드 값 추가 + const repeatSection = config.sections.find((s) => s.id === repeatSectionId); + repeatSection?.fields.forEach((field) => { + if (item[field.columnName] !== undefined) { + subRow[field.columnName] = item[field.columnName]; } }); @@ -666,7 +610,187 @@ export function UniversalFormModalComponent({ } console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`); - }; + }, [config.sections, config.saveConfig, formData, repeatSections]); + + // 커스텀 API 저장 (사원+부서 통합 저장 등) + const saveWithCustomApi = useCallback(async () => { + const { customApiSave } = config.saveConfig; + if (!customApiSave) return; + + console.log("[UniversalFormModal] 커스텀 API 저장 시작:", customApiSave.apiType); + + const saveUserWithDeptApi = async () => { + const { mainDeptFields, subDeptSectionId, subDeptFields } = customApiSave; + + // 1. userInfo 데이터 구성 + const userInfo: Record = {}; + + // 모든 필드에서 user_info에 해당하는 데이터 추출 + config.sections.forEach((section) => { + if (section.repeatable) return; // 반복 섹션은 제외 + + section.fields.forEach((field) => { + const value = formData[field.columnName]; + if (value !== undefined && value !== null && value !== "") { + userInfo[field.columnName] = value; + } + }); + }); + + // 2. mainDept 데이터 구성 + let mainDept: { dept_code: string; dept_name?: string; position_name?: string } | undefined; + + if (mainDeptFields) { + const deptCode = formData[mainDeptFields.deptCodeField || "dept_code"]; + if (deptCode) { + mainDept = { + dept_code: deptCode, + dept_name: formData[mainDeptFields.deptNameField || "dept_name"], + position_name: formData[mainDeptFields.positionNameField || "position_name"], + }; + } + } + + // 3. subDepts 데이터 구성 (반복 섹션에서) + const subDepts: Array<{ dept_code: string; dept_name?: string; position_name?: string }> = []; + + if (subDeptSectionId && repeatSections[subDeptSectionId]) { + const subDeptItems = repeatSections[subDeptSectionId]; + const deptCodeField = subDeptFields?.deptCodeField || "dept_code"; + const deptNameField = subDeptFields?.deptNameField || "dept_name"; + const positionNameField = subDeptFields?.positionNameField || "position_name"; + + subDeptItems.forEach((item) => { + const deptCode = item[deptCodeField]; + if (deptCode) { + subDepts.push({ + dept_code: deptCode, + dept_name: item[deptNameField], + position_name: item[positionNameField], + }); + } + }); + } + + // 4. API 호출 + console.log("[UniversalFormModal] 사원+부서 저장 데이터:", { userInfo, mainDept, subDepts }); + + const { saveUserWithDept } = await import("@/lib/api/user"); + const response = await saveUserWithDept({ + userInfo: userInfo as any, + mainDept, + subDepts, + isUpdate: !!initialData?.user_id, // 초기 데이터가 있으면 수정 모드 + }); + + if (!response.success) { + throw new Error(response.message || "사원 저장 실패"); + } + + console.log("[UniversalFormModal] 사원+부서 저장 완료:", response.data); + }; + + const saveWithGenericCustomApi = async () => { + if (!customApiSave.customEndpoint) { + throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다."); + } + + const dataToSave = { ...formData }; + + // 메타데이터 필드 제거 + Object.keys(dataToSave).forEach((key) => { + if (key.startsWith("_")) { + delete dataToSave[key]; + } + }); + + // 반복 섹션 데이터 포함 + if (Object.keys(repeatSections).length > 0) { + dataToSave._repeatSections = repeatSections; + } + + const method = customApiSave.customMethod || "POST"; + const response = method === "PUT" + ? await apiClient.put(customApiSave.customEndpoint, dataToSave) + : await apiClient.post(customApiSave.customEndpoint, dataToSave); + + if (!response.data?.success) { + throw new Error(response.data?.message || "저장 실패"); + } + }; + + switch (customApiSave.apiType) { + case "user-with-dept": + await saveUserWithDeptApi(); + break; + case "custom": + await saveWithGenericCustomApi(); + break; + default: + throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`); + } + }, [config.sections, config.saveConfig, formData, repeatSections, initialData]); + + // 저장 처리 + const handleSave = useCallback(async () => { + // 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크 + if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) { + toast.error("저장할 테이블이 설정되지 않았습니다."); + return; + } + + // 필수 필드 검증 + const { valid, missingFields } = validateRequiredFields(); + if (!valid) { + toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`); + return; + } + + setSaving(true); + + try { + const { multiRowSave, customApiSave } = config.saveConfig; + + // 커스텀 API 저장 모드 + if (customApiSave?.enabled) { + await saveWithCustomApi(); + } else if (multiRowSave?.enabled) { + // 다중 행 저장 + await saveMultipleRows(); + } else { + // 단일 행 저장 + await saveSingleRow(); + } + + // 저장 후 동작 + if (config.saveConfig.afterSave?.showToast) { + toast.success("저장되었습니다."); + } + + if (config.saveConfig.afterSave?.refreshParent) { + window.dispatchEvent(new CustomEvent("refreshParentData")); + } + + // onSave 콜백은 저장 완료 알림용으로만 사용 + // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows) + // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록 + // _saveCompleted 플래그를 포함하여 전달 + if (onSave) { + onSave({ ...formData, _saveCompleted: true }); + } + } catch (error: any) { + console.error("저장 실패:", error); + // axios 에러의 경우 서버 응답 메시지 추출 + const errorMessage = + error.response?.data?.message || + error.response?.data?.error?.details || + error.message || + "저장에 실패했습니다."; + toast.error(errorMessage); + } finally { + setSaving(false); + } + }, [config, formData, repeatSections, onSave, validateRequiredFields, saveSingleRow, saveMultipleRows, saveWithCustomApi]); // 폼 초기화 const handleReset = useCallback(() => { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index acc53acc..8552cd6f 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -416,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor {/* 저장 테이블 - Combobox */}
- - - - - - - - - 테이블을 찾을 수 없습니다 - - {tables.map((t) => ( - { - updateSaveConfig({ tableName: t.name }); - setTableSelectOpen(false); - }} - className="text-xs" - > - - {t.name} - {t.label !== t.name && ( - ({t.label}) - )} - - ))} - - - - - - {config.saveConfig.tableName && ( -

- 컬럼 {currentColumns.length}개 로드됨 -

+ {config.saveConfig.customApiSave?.enabled ? ( +
+ 전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다. + {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( + 대상 테이블: user_info + user_dept + )} +
+ ) : ( + <> + + + + + + + + + 테이블을 찾을 수 없습니다 + + {tables.map((t) => ( + { + updateSaveConfig({ tableName: t.name }); + setTableSelectOpen(false); + }} + className="text-xs" + > + + {t.name} + {t.label !== t.name && ( + ({t.label}) + )} + + ))} + + + + + + {config.saveConfig.tableName && ( +

+ 컬럼 {currentColumns.length}개 로드됨 +

+ )} + )}
- {/* 다중 행 저장 설정 */} + {/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */} + {!config.saveConfig.customApiSave?.enabled && (
다중 행 저장 @@ -578,6 +590,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
)}
+ )} + + {/* 커스텀 API 저장 설정 */} +
+
+ 전용 API 저장 + + updateSaveConfig({ + customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" }, + }) + } + /> +
+ 테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다. + + {config.saveConfig.customApiSave?.enabled && ( +
+ {/* API 타입 선택 */} +
+ + +
+ + {/* 사원+부서 통합 저장 설정 */} + {config.saveConfig.customApiSave?.apiType === "user-with-dept" && ( +
+

+ user_info와 user_dept 테이블에 트랜잭션으로 저장합니다. + 메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다. +

+ + {/* 메인 부서 필드 매핑 */} +
+ +
+
+ 부서코드: + +
+
+ 부서명: + +
+
+ 직급: + +
+
+
+ + {/* 겸직 부서 반복 섹션 */} +
+ + +
+ + {/* 겸직 부서 필드 매핑 */} + {config.saveConfig.customApiSave?.subDeptSectionId && ( +
+ +
+
+ 부서코드: + +
+
+ 부서명: + +
+
+ 직급: + +
+
+
+ )} +
+ )} + + {/* 커스텀 API 설정 */} + {config.saveConfig.customApiSave?.apiType === "custom" && ( +
+
+ + + updateSaveConfig({ + customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value }, + }) + } + placeholder="/api/custom/endpoint" + className="h-6 text-[10px] mt-1" + /> +
+
+ + +
+
+ )} +
+ )} +
{/* 저장 후 동작 */}
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index de2526c2..04f7df0e 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -178,6 +178,9 @@ export interface SaveConfig { // 다중 행 저장 설정 multiRowSave?: MultiRowSaveConfig; + // 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용) + customApiSave?: CustomApiSaveConfig; + // 저장 후 동작 (간편 설정) showToast?: boolean; // 토스트 메시지 (기본: true) refreshParent?: boolean; // 부모 새로고침 (기본: true) @@ -191,6 +194,44 @@ export interface SaveConfig { }; } +/** + * 커스텀 API 저장 설정 + * + * 테이블 직접 저장 대신 전용 백엔드 API를 호출합니다. + * 복잡한 비즈니스 로직(다중 테이블 저장, 트랜잭션 등)에 사용합니다. + * + * ## 지원하는 API 타입 + * - `user-with-dept`: 사원 + 부서 통합 저장 (/api/admin/users/with-dept) + * + * ## 데이터 매핑 설정 + * - `userInfoFields`: user_info 테이블에 저장할 필드 매핑 + * - `mainDeptFields`: 메인 부서 정보 필드 매핑 + * - `subDeptSectionId`: 겸직 부서 반복 섹션 ID + */ +export interface CustomApiSaveConfig { + enabled: boolean; + apiType: "user-with-dept" | "custom"; // 확장 가능한 API 타입 + + // user-with-dept 전용 설정 + userInfoFields?: string[]; // user_info에 저장할 필드 목록 (columnName) + mainDeptFields?: { + deptCodeField?: string; // 메인 부서코드 필드명 + deptNameField?: string; // 메인 부서명 필드명 + positionNameField?: string; // 메인 직급 필드명 + }; + subDeptSectionId?: string; // 겸직 부서 반복 섹션 ID + subDeptFields?: { + deptCodeField?: string; // 겸직 부서코드 필드명 + deptNameField?: string; // 겸직 부서명 필드명 + positionNameField?: string; // 겸직 직급 필드명 + }; + + // 커스텀 API 전용 설정 + customEndpoint?: string; // 커스텀 API 엔드포인트 + customMethod?: "POST" | "PUT"; // HTTP 메서드 + customDataTransform?: string; // 데이터 변환 함수명 (추후 확장) +} + // 모달 설정 export interface ModalConfig { title: string;