feat(UniversalFormModal): 전용 API 저장 기능 및 사원+부서 통합 저장 API 구현
- CustomApiSaveConfig 타입 정의 (apiType, mainDeptFields, subDeptFields) - saveWithCustomApi() 함수 추가로 테이블 직접 저장 대신 전용 API 호출 - adminController에 saveUserWithDept(), getUserWithDept() API 추가 - user_info + user_dept 트랜잭션 저장, 메인 부서 변경 시 자동 겸직 전환 - ConfigPanel에 전용 API 저장 설정 UI 추가 - SplitPanelLayout2: getColumnValue()로 조인 테이블 컬럼 값 추출 개선 - 검색 컬럼 선택 시 표시 컬럼 기반으로 변경
This commit is contained in:
parent
a5055cae15
commit
892278853c
|
|
@ -3,7 +3,7 @@ import { logger } from "../utils/logger";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { ApiResponse } from "../types/common";
|
import { ApiResponse } from "../types/common";
|
||||||
import { Client } from "pg";
|
import { Client } from "pg";
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne, getPool } from "../database/db";
|
||||||
import config from "../config/environment";
|
import config from "../config/environment";
|
||||||
import { AdminService } from "../services/adminService";
|
import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
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<void> => {
|
||||||
|
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<string, any> = {
|
||||||
|
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<void> => {
|
||||||
|
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<any>(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<any>(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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
getDepartmentList, // 부서 목록 조회
|
getDepartmentList, // 부서 목록 조회
|
||||||
checkDuplicateUserId, // 사용자 ID 중복 체크
|
checkDuplicateUserId, // 사용자 ID 중복 체크
|
||||||
saveUser, // 사용자 등록/수정
|
saveUser, // 사용자 등록/수정
|
||||||
|
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||||
|
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||||
getCompanyList,
|
getCompanyList,
|
||||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||||
getCompanyByCode, // 회사 단건 조회
|
getCompanyByCode, // 회사 단건 조회
|
||||||
|
|
@ -50,8 +52,10 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
|
||||||
router.get("/users", getUserList);
|
router.get("/users", getUserList);
|
||||||
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
|
||||||
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
|
||||||
|
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
|
||||||
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경
|
||||||
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
router.post("/users", saveUser); // 사용자 등록/수정 (기존)
|
||||||
|
router.post("/users/with-dept", saveUserWithDept); // 사원 + 부서 통합 저장 (NEW!)
|
||||||
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||||
router.put("/profile", updateProfile); // 프로필 수정
|
router.put("/profile", updateProfile); // 프로필 수정
|
||||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,127 @@ export async function checkDuplicateUserId(userId: string) {
|
||||||
return response.data;
|
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<string, any>;
|
||||||
|
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<ApiResponse<{ userId: string; isUpdate: boolean }>> {
|
||||||
|
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<ApiResponse<UserWithDeptResponse>> {
|
||||||
|
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
|
// 사용자 API 객체로 export
|
||||||
export const userAPI = {
|
export const userAPI = {
|
||||||
getList: getUserList,
|
getList: getUserList,
|
||||||
|
|
@ -195,4 +316,7 @@ export const userAPI = {
|
||||||
getCompanyList: getCompanyList,
|
getCompanyList: getCompanyList,
|
||||||
getDepartmentList: getDepartmentList,
|
getDepartmentList: getDepartmentList,
|
||||||
checkDuplicateId: checkDuplicateUserId,
|
checkDuplicateId: checkDuplicateUserId,
|
||||||
|
// 사원 + 부서 통합 관리
|
||||||
|
saveWithDept: saveUserWithDept,
|
||||||
|
getWithDept: getUserWithDept,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
// 선택된 컬럼만 병합
|
// 선택된 컬럼만 병합
|
||||||
const mergedItem = { ...item };
|
const mergedItem = { ...item };
|
||||||
joinConfig.selectColumns.forEach((col) => {
|
joinConfig.selectColumns.forEach((col) => {
|
||||||
// alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명
|
// 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용)
|
||||||
|
const tableColumnKey = `${joinConfig.joinTable}.${col}`;
|
||||||
|
mergedItem[tableColumnKey] = joinRow[col];
|
||||||
|
|
||||||
|
// alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성)
|
||||||
const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col;
|
const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col;
|
||||||
// 메인 테이블에 같은 컬럼이 없으면 추가
|
// 메인 테이블에 같은 컬럼이 없으면 추가
|
||||||
if (!(col in mergedItem)) {
|
if (!(col in mergedItem)) {
|
||||||
|
|
@ -210,6 +214,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
mergedItem[targetKey] = joinRow[col];
|
mergedItem[targetKey] = joinRow[col];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) });
|
||||||
return mergedItem;
|
return mergedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -738,6 +743,37 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
};
|
};
|
||||||
}, [screenContext, component.id]);
|
}, [screenContext, component.id]);
|
||||||
|
|
||||||
|
// 컬럼 값 가져오기 (sourceTable 고려)
|
||||||
|
const getColumnValue = useCallback((item: any, col: ColumnConfig): any => {
|
||||||
|
// 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 => {
|
const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
@ -916,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{nameRowColumns.length > 0 && (
|
{nameRowColumns.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
{nameRowColumns.map((col, idx) => {
|
{nameRowColumns.map((col, idx) => {
|
||||||
const value = item[col.name];
|
const value = getColumnValue(item, col);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="flex items-center gap-1">
|
<span key={idx} className="flex items-center gap-1">
|
||||||
|
|
@ -931,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{infoRowColumns.length > 0 && (
|
{infoRowColumns.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
||||||
{infoRowColumns.map((col, idx) => {
|
{infoRowColumns.map((col, idx) => {
|
||||||
const value = item[col.name];
|
const value = getColumnValue(item, col);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="flex items-center gap-1">
|
<span key={idx} className="flex items-center gap-1">
|
||||||
|
|
@ -950,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{nameRowColumns.length > 0 && (
|
{nameRowColumns.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{nameRowColumns.map((col, idx) => {
|
{nameRowColumns.map((col, idx) => {
|
||||||
const value = item[col.name];
|
const value = getColumnValue(item, col);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -971,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
{infoRowColumns.length > 0 && (
|
{infoRowColumns.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
||||||
{infoRowColumns.map((col, idx) => {
|
{infoRowColumns.map((col, idx) => {
|
||||||
const value = item[col.name];
|
const value = getColumnValue(item, col);
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-sm">
|
<span key={idx} className="text-sm">
|
||||||
|
|
@ -1079,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
)}
|
)}
|
||||||
{displayColumns.map((col, colIdx) => (
|
{displayColumns.map((col, colIdx) => (
|
||||||
<TableCell key={colIdx}>
|
<TableCell key={colIdx}>
|
||||||
{formatValue(item[col.name], col.format)}
|
{formatValue(getColumnValue(item, col), col.format)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||||
|
|
|
||||||
|
|
@ -279,12 +279,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 선택된 컬럼 추가 (테이블명으로 구분)
|
// 선택된 컬럼 추가 (테이블명으로 구분, 유니크 키 생성)
|
||||||
jt.selectColumns.forEach((selCol) => {
|
jt.selectColumns.forEach((selCol) => {
|
||||||
const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol);
|
const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol);
|
||||||
if (col) {
|
if (col) {
|
||||||
joinColumns.push({
|
joinColumns.push({
|
||||||
...col,
|
...col,
|
||||||
|
// 유니크 키를 위해 테이블명_컬럼명 형태로 저장
|
||||||
|
column_name: `${jt.joinTable}.${col.column_name}`,
|
||||||
column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`,
|
column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -727,8 +729,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const currentColumns = side === "left"
|
const currentColumns = side === "left"
|
||||||
? config.leftPanel?.displayColumns || []
|
? config.leftPanel?.displayColumns || []
|
||||||
: config.rightPanel?.displayColumns || [];
|
: config.rightPanel?.displayColumns || [];
|
||||||
|
|
||||||
|
// 기본 테이블 설정 (메인 테이블)
|
||||||
|
const defaultTable = side === "left"
|
||||||
|
? config.leftPanel?.tableName
|
||||||
|
: config.rightPanel?.tableName;
|
||||||
|
|
||||||
updateConfig(path, [...currentColumns, { name: "", label: "" }]);
|
updateConfig(path, [...currentColumns, { name: "", label: "", sourceTable: defaultTable || "" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 표시 컬럼 삭제
|
// 표시 컬럼 삭제
|
||||||
|
|
@ -1083,15 +1090,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
// 선택된 테이블의 컬럼만 필터링
|
// 선택된 테이블의 컬럼만 필터링
|
||||||
const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName;
|
const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName;
|
||||||
const filteredColumns = rightColumns.filter((c) => {
|
const filteredColumns = rightColumns.filter((c) => {
|
||||||
// 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함)
|
// 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태)
|
||||||
const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")");
|
const isJoinColumn = c.column_name.includes(".");
|
||||||
|
|
||||||
if (selectedSourceTable === config.rightPanel?.tableName) {
|
if (selectedSourceTable === config.rightPanel?.tableName) {
|
||||||
// 메인 테이블 선택 시: 조인 컬럼 아닌 것만
|
// 메인 테이블 선택 시: 조인 컬럼 아닌 것만
|
||||||
return !isJoinColumn;
|
return !isJoinColumn;
|
||||||
} else {
|
} else {
|
||||||
// 조인 테이블 선택 시: 해당 테이블 컬럼만
|
// 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태)
|
||||||
return c.column_comment?.includes(`(${selectedSourceTable})`);
|
return c.column_name.startsWith(`${selectedSourceTable}.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1163,11 +1170,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
filteredColumns.map((c) => {
|
filteredColumns.map((c) => {
|
||||||
// 조인 컬럼의 경우 테이블명 제거하고 표시
|
// 조인 컬럼의 경우 테이블명 제거하고 표시
|
||||||
const displayLabel = c.column_comment?.replace(/\s*\([^)]+\)$/, "") || c.column_name;
|
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 (
|
return (
|
||||||
<SelectItem key={c.column_name} value={c.column_name}>
|
<SelectItem key={c.column_name} value={c.column_name}>
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<span>{displayLabel}</span>
|
<span>{displayLabel}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{c.column_name}</span>
|
<span className="text-[10px] text-muted-foreground">{actualColumnName}</span>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
|
|
@ -1231,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 text-xs"
|
className="h-6 text-xs"
|
||||||
|
disabled={(config.rightPanel?.displayColumns || []).length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const current = config.rightPanel?.searchColumns || [];
|
const current = config.rightPanel?.searchColumns || [];
|
||||||
updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]);
|
updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]);
|
||||||
|
|
@ -1240,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-2">
|
||||||
|
표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.
|
||||||
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => (
|
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => {
|
||||||
<div key={index} className="flex items-center gap-2">
|
// 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시
|
||||||
<ColumnSelect
|
const displayColumns = config.rightPanel?.displayColumns || [];
|
||||||
columns={rightColumns}
|
|
||||||
value={searchCol.columnName}
|
// 유효한 컬럼만 필터링 (name이 있는 것만)
|
||||||
onValueChange={(value) => {
|
const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== "");
|
||||||
const current = [...(config.rightPanel?.searchColumns || [])];
|
|
||||||
current[index] = { ...current[index], columnName: value };
|
// 현재 선택된 컬럼의 표시 정보
|
||||||
updateConfig("rightPanel.searchColumns", current);
|
const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName);
|
||||||
}}
|
const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName);
|
||||||
placeholder="컬럼 선택"
|
const selectedLabel = selectedDisplayCol?.label ||
|
||||||
/>
|
selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") ||
|
||||||
<Button
|
searchCol.columnName;
|
||||||
size="sm"
|
const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || "";
|
||||||
variant="ghost"
|
const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
|
||||||
className="h-8 w-8 shrink-0 p-0"
|
|
||||||
onClick={() => {
|
return (
|
||||||
const current = config.rightPanel?.searchColumns || [];
|
<div key={index} className="flex items-center gap-2">
|
||||||
updateConfig(
|
<Select
|
||||||
"rightPanel.searchColumns",
|
value={searchCol.columnName || ""}
|
||||||
current.filter((_, i) => i !== index)
|
onValueChange={(value) => {
|
||||||
);
|
const current = [...(config.rightPanel?.searchColumns || [])];
|
||||||
}}
|
current[index] = { ...current[index], columnName: value };
|
||||||
>
|
updateConfig("rightPanel.searchColumns", current);
|
||||||
<X className="h-3 w-3" />
|
}}
|
||||||
</Button>
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs flex-1">
|
||||||
|
<SelectValue placeholder="컬럼 선택">
|
||||||
|
{searchCol.columnName ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span>{selectedLabel}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">({selectedTableLabel})</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"컬럼 선택"
|
||||||
|
)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{validDisplayColumns.length === 0 ? (
|
||||||
|
<SelectItem value="_empty" disabled>
|
||||||
|
먼저 표시할 컬럼을 추가하세요
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
validDisplayColumns.map((dc, dcIndex) => {
|
||||||
|
const colInfo = rightColumns.find((c) => c.column_name === dc.name);
|
||||||
|
const label = dc.label || colInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || dc.name;
|
||||||
|
const tableName = dc.sourceTable || config.rightPanel?.tableName || "";
|
||||||
|
const tableLabel = tables.find((t) => t.table_name === tableName)?.table_comment || tableName;
|
||||||
|
const actualColName = dc.name.includes(".") ? dc.name.split(".")[1] : dc.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectItem key={`search-${dc.name}-${dcIndex}`} value={dc.name}>
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">({tableLabel})</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{actualColName}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 shrink-0 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const current = config.rightPanel?.searchColumns || [];
|
||||||
|
updateConfig(
|
||||||
|
"rightPanel.searchColumns",
|
||||||
|
current.filter((_, i) => i !== index)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
||||||
|
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
||||||
|
먼저 표시할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
{(config.rightPanel?.searchColumns || []).length === 0 && (
|
{(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
|
||||||
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
|
||||||
검색할 컬럼을 추가하세요
|
검색할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -444,65 +444,8 @@ export function UniversalFormModalComponent({
|
||||||
return { valid: missingFields.length === 0, missingFields };
|
return { valid: missingFields.length === 0, missingFields };
|
||||||
}, [config.sections, formData]);
|
}, [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 };
|
const dataToSave = { ...formData };
|
||||||
|
|
||||||
// 메타데이터 필드 제거
|
// 메타데이터 필드 제거
|
||||||
|
|
@ -534,15 +477,15 @@ export function UniversalFormModalComponent({
|
||||||
if (!response.data?.success) {
|
if (!response.data?.success) {
|
||||||
throw new Error(response.data?.message || "저장 실패");
|
throw new Error(response.data?.message || "저장 실패");
|
||||||
}
|
}
|
||||||
};
|
}, [config.sections, config.saveConfig.tableName, formData]);
|
||||||
|
|
||||||
// 다중 행 저장 (겸직 등)
|
// 다중 행 저장 (겸직 등)
|
||||||
const saveMultipleRows = async () => {
|
const saveMultipleRows = useCallback(async () => {
|
||||||
const { multiRowSave } = config.saveConfig;
|
const { multiRowSave } = config.saveConfig;
|
||||||
if (!multiRowSave) return;
|
if (!multiRowSave) return;
|
||||||
|
|
||||||
let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } =
|
let { commonFields = [], repeatSectionId = "" } = multiRowSave;
|
||||||
multiRowSave;
|
const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave;
|
||||||
|
|
||||||
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
|
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
|
||||||
if (commonFields.length === 0) {
|
if (commonFields.length === 0) {
|
||||||
|
|
@ -563,56 +506,57 @@ export function UniversalFormModalComponent({
|
||||||
// 디버깅: 설정 확인
|
// 디버깅: 설정 확인
|
||||||
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
|
console.log("[UniversalFormModal] 다중 행 저장 설정:", {
|
||||||
commonFields,
|
commonFields,
|
||||||
mainSectionFields,
|
|
||||||
repeatSectionId,
|
repeatSectionId,
|
||||||
|
mainSectionFields,
|
||||||
typeColumn,
|
typeColumn,
|
||||||
mainTypeValue,
|
mainTypeValue,
|
||||||
subTypeValue,
|
subTypeValue,
|
||||||
|
repeatSections,
|
||||||
|
formData,
|
||||||
});
|
});
|
||||||
console.log("[UniversalFormModal] 현재 formData:", formData);
|
|
||||||
|
|
||||||
// 공통 필드 데이터 추출
|
// 반복 섹션 데이터
|
||||||
const commonData: Record<string, any> = {};
|
const repeatItems = repeatSections[repeatSectionId] || [];
|
||||||
for (const fieldName of commonFields) {
|
|
||||||
|
// 저장할 행들 생성
|
||||||
|
const rowsToSave: any[] = [];
|
||||||
|
|
||||||
|
// 공통 데이터 (모든 행에 적용)
|
||||||
|
const commonData: any = {};
|
||||||
|
commonFields.forEach((fieldName) => {
|
||||||
if (formData[fieldName] !== undefined) {
|
if (formData[fieldName] !== undefined) {
|
||||||
commonData[fieldName] = formData[fieldName];
|
commonData[fieldName] = formData[fieldName];
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData);
|
|
||||||
|
|
||||||
// 메인 섹션 필드 데이터 추출
|
// 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
|
||||||
const mainSectionData: Record<string, any> = {};
|
const mainSectionData: any = {};
|
||||||
if (mainSectionFields && mainSectionFields.length > 0) {
|
mainSectionFields.forEach((fieldName) => {
|
||||||
for (const fieldName of mainSectionFields) {
|
if (formData[fieldName] !== undefined) {
|
||||||
if (formData[fieldName] !== undefined) {
|
mainSectionData[fieldName] = formData[fieldName];
|
||||||
mainSectionData[fieldName] = formData[fieldName];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData);
|
|
||||||
|
|
||||||
// 저장할 행들 준비
|
console.log("[UniversalFormModal] 공통 데이터:", commonData);
|
||||||
const rowsToSave: Record<string, any>[] = [];
|
console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
|
||||||
|
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
|
||||||
|
|
||||||
// 1. 메인 행 생성
|
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
||||||
const mainRow: Record<string, any> = {
|
const mainRow: any = { ...commonData, ...mainSectionData };
|
||||||
...commonData,
|
|
||||||
...mainSectionData,
|
|
||||||
};
|
|
||||||
if (typeColumn) {
|
if (typeColumn) {
|
||||||
mainRow[typeColumn] = mainTypeValue || "main";
|
mainRow[typeColumn] = mainTypeValue || "main";
|
||||||
}
|
}
|
||||||
rowsToSave.push(mainRow);
|
rowsToSave.push(mainRow);
|
||||||
|
|
||||||
// 2. 반복 섹션 행들 생성 (겸직 등)
|
// 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
|
||||||
const repeatItems = repeatSections[repeatSectionId] || [];
|
|
||||||
for (const item of repeatItems) {
|
for (const item of repeatItems) {
|
||||||
const subRow: Record<string, any> = { ...commonData };
|
const subRow: any = { ...commonData };
|
||||||
|
|
||||||
// 반복 섹션 필드 복사
|
// 반복 섹션의 필드 값 추가
|
||||||
Object.keys(item).forEach((key) => {
|
const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
|
||||||
if (!key.startsWith("_")) {
|
repeatSection?.fields.forEach((field) => {
|
||||||
subRow[key] = item[key];
|
if (item[field.columnName] !== undefined) {
|
||||||
|
subRow[field.columnName] = item[field.columnName];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -666,7 +610,187 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
|
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<string, any> = {};
|
||||||
|
|
||||||
|
// 모든 필드에서 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(() => {
|
const handleReset = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -416,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
{/* 저장 테이블 - Combobox */}
|
{/* 저장 테이블 - Combobox */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">저장 테이블</Label>
|
<Label className="text-[10px]">저장 테이블</Label>
|
||||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
{config.saveConfig.customApiSave?.enabled ? (
|
||||||
<PopoverTrigger asChild>
|
<div className="mt-1 p-2 bg-muted/50 rounded text-[10px] text-muted-foreground">
|
||||||
<Button
|
전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다.
|
||||||
variant="outline"
|
{config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
|
||||||
role="combobox"
|
<span className="block mt-1">대상 테이블: user_info + user_dept</span>
|
||||||
aria-expanded={tableSelectOpen}
|
)}
|
||||||
className="w-full h-7 justify-between text-xs mt-1"
|
</div>
|
||||||
>
|
) : (
|
||||||
{config.saveConfig.tableName
|
<>
|
||||||
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
|
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||||
config.saveConfig.tableName
|
<PopoverTrigger asChild>
|
||||||
: "테이블 선택 또는 직접 입력"}
|
<Button
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
variant="outline"
|
||||||
</Button>
|
role="combobox"
|
||||||
</PopoverTrigger>
|
aria-expanded={tableSelectOpen}
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
className="w-full h-7 justify-between text-xs mt-1"
|
||||||
<Command>
|
>
|
||||||
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
|
{config.saveConfig.tableName
|
||||||
<CommandList>
|
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
|
||||||
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
config.saveConfig.tableName
|
||||||
<CommandGroup>
|
: "테이블 선택 또는 직접 입력"}
|
||||||
{tables.map((t) => (
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
<CommandItem
|
</Button>
|
||||||
key={t.name}
|
</PopoverTrigger>
|
||||||
value={`${t.name} ${t.label}`}
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
onSelect={() => {
|
<Command>
|
||||||
updateSaveConfig({ tableName: t.name });
|
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
|
||||||
setTableSelectOpen(false);
|
<CommandList>
|
||||||
}}
|
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
className="text-xs"
|
<CommandGroup>
|
||||||
>
|
{tables.map((t) => (
|
||||||
<Check
|
<CommandItem
|
||||||
className={cn(
|
key={t.name}
|
||||||
"mr-2 h-3 w-3",
|
value={`${t.name} ${t.label}`}
|
||||||
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
|
onSelect={() => {
|
||||||
)}
|
updateSaveConfig({ tableName: t.name });
|
||||||
/>
|
setTableSelectOpen(false);
|
||||||
<span className="font-medium">{t.name}</span>
|
}}
|
||||||
{t.label !== t.name && (
|
className="text-xs"
|
||||||
<span className="ml-1 text-muted-foreground">({t.label})</span>
|
>
|
||||||
)}
|
<Check
|
||||||
</CommandItem>
|
className={cn(
|
||||||
))}
|
"mr-2 h-3 w-3",
|
||||||
</CommandGroup>
|
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
|
||||||
</CommandList>
|
)}
|
||||||
</Command>
|
/>
|
||||||
</PopoverContent>
|
<span className="font-medium">{t.name}</span>
|
||||||
</Popover>
|
{t.label !== t.name && (
|
||||||
{config.saveConfig.tableName && (
|
<span className="ml-1 text-muted-foreground">({t.label})</span>
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">
|
)}
|
||||||
컬럼 {currentColumns.length}개 로드됨
|
</CommandItem>
|
||||||
</p>
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{config.saveConfig.tableName && (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
컬럼 {currentColumns.length}개 로드됨
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 다중 행 저장 설정 */}
|
{/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */}
|
||||||
|
{!config.saveConfig.customApiSave?.enabled && (
|
||||||
<div className="border rounded-md p-2 space-y-2">
|
<div className="border rounded-md p-2 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-medium">다중 행 저장</span>
|
<span className="text-[10px] font-medium">다중 행 저장</span>
|
||||||
|
|
@ -578,6 +590,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 API 저장 설정 */}
|
||||||
|
<div className="border rounded-md p-2 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-medium">전용 API 저장</span>
|
||||||
|
<Switch
|
||||||
|
checked={config.saveConfig.customApiSave?.enabled || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: { ...config.saveConfig.customApiSave, enabled: checked, apiType: "user-with-dept" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HelpText>테이블 직접 저장 대신 전용 백엔드 API를 사용합니다. 복잡한 비즈니스 로직(다중 테이블, 트랜잭션)에 적합합니다.</HelpText>
|
||||||
|
|
||||||
|
{config.saveConfig.customApiSave?.enabled && (
|
||||||
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
{/* API 타입 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">API 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.apiType || "user-with-dept"}
|
||||||
|
onValueChange={(value: "user-with-dept" | "custom") =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: { ...config.saveConfig.customApiSave, apiType: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user-with-dept">사원+부서 통합 저장</SelectItem>
|
||||||
|
<SelectItem value="custom">커스텀 API</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사원+부서 통합 저장 설정 */}
|
||||||
|
{config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
|
||||||
|
<div className="space-y-2 p-2 bg-muted/30 rounded">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
user_info와 user_dept 테이블에 트랜잭션으로 저장합니다.
|
||||||
|
메인 부서 변경 시 기존 메인은 겸직으로 자동 전환됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 메인 부서 필드 매핑 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">메인 부서 필드 매핑</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] w-16 shrink-0">부서코드:</span>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.mainDeptFields?.deptCodeField || "dept_code"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: {
|
||||||
|
...config.saveConfig.customApiSave,
|
||||||
|
mainDeptFields: {
|
||||||
|
...config.saveConfig.customApiSave?.mainDeptFields,
|
||||||
|
deptCodeField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.filter((s) => !s.repeatable)
|
||||||
|
.flatMap((s) => s.fields)
|
||||||
|
.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] w-16 shrink-0">부서명:</span>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.mainDeptFields?.deptNameField || "dept_name"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: {
|
||||||
|
...config.saveConfig.customApiSave,
|
||||||
|
mainDeptFields: {
|
||||||
|
...config.saveConfig.customApiSave?.mainDeptFields,
|
||||||
|
deptNameField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.filter((s) => !s.repeatable)
|
||||||
|
.flatMap((s) => s.fields)
|
||||||
|
.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] w-16 shrink-0">직급:</span>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.mainDeptFields?.positionNameField || "position_name"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: {
|
||||||
|
...config.saveConfig.customApiSave,
|
||||||
|
mainDeptFields: {
|
||||||
|
...config.saveConfig.customApiSave?.mainDeptFields,
|
||||||
|
positionNameField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.filter((s) => !s.repeatable)
|
||||||
|
.flatMap((s) => s.fields)
|
||||||
|
.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 겸직 부서 반복 섹션 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">겸직 부서 반복 섹션</Label>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.subDeptSectionId || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: { ...config.saveConfig.customApiSave, subDeptSectionId: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-[10px]">
|
||||||
|
<SelectValue placeholder="반복 섹션 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.filter((s) => s.repeatable)
|
||||||
|
.map((section) => (
|
||||||
|
<SelectItem key={section.id} value={section.id}>
|
||||||
|
{section.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 겸직 부서 필드 매핑 */}
|
||||||
|
{config.saveConfig.customApiSave?.subDeptSectionId && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">겸직 부서 필드 매핑</Label>
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] w-16 shrink-0">부서코드:</span>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.subDeptFields?.deptCodeField || "dept_code"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: {
|
||||||
|
...config.saveConfig.customApiSave,
|
||||||
|
subDeptFields: {
|
||||||
|
...config.saveConfig.customApiSave?.subDeptFields,
|
||||||
|
deptCodeField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId)
|
||||||
|
?.fields.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] w-16 shrink-0">부서명:</span>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.subDeptFields?.deptNameField || "dept_name"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: {
|
||||||
|
...config.saveConfig.customApiSave,
|
||||||
|
subDeptFields: {
|
||||||
|
...config.saveConfig.customApiSave?.subDeptFields,
|
||||||
|
deptNameField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId)
|
||||||
|
?.fields.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] w-16 shrink-0">직급:</span>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.subDeptFields?.positionNameField || "position_name"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: {
|
||||||
|
...config.saveConfig.customApiSave,
|
||||||
|
subDeptFields: {
|
||||||
|
...config.saveConfig.customApiSave?.subDeptFields,
|
||||||
|
positionNameField: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[9px] flex-1">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.sections
|
||||||
|
.find((s) => s.id === config.saveConfig.customApiSave?.subDeptSectionId)
|
||||||
|
?.fields.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.columnName}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 커스텀 API 설정 */}
|
||||||
|
{config.saveConfig.customApiSave?.apiType === "custom" && (
|
||||||
|
<div className="space-y-2 p-2 bg-muted/30 rounded">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">API 엔드포인트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.saveConfig.customApiSave?.customEndpoint || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: { ...config.saveConfig.customApiSave, customEndpoint: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="/api/custom/endpoint"
|
||||||
|
className="h-6 text-[10px] mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px]">HTTP 메서드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.saveConfig.customApiSave?.customMethod || "POST"}
|
||||||
|
onValueChange={(value: "POST" | "PUT") =>
|
||||||
|
updateSaveConfig({
|
||||||
|
customApiSave: { ...config.saveConfig.customApiSave, customMethod: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="POST">POST</SelectItem>
|
||||||
|
<SelectItem value="PUT">PUT</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 저장 후 동작 */}
|
{/* 저장 후 동작 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,9 @@ export interface SaveConfig {
|
||||||
// 다중 행 저장 설정
|
// 다중 행 저장 설정
|
||||||
multiRowSave?: MultiRowSaveConfig;
|
multiRowSave?: MultiRowSaveConfig;
|
||||||
|
|
||||||
|
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
||||||
|
customApiSave?: CustomApiSaveConfig;
|
||||||
|
|
||||||
// 저장 후 동작 (간편 설정)
|
// 저장 후 동작 (간편 설정)
|
||||||
showToast?: boolean; // 토스트 메시지 (기본: true)
|
showToast?: boolean; // 토스트 메시지 (기본: true)
|
||||||
refreshParent?: 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 {
|
export interface ModalConfig {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue