Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
0ffec7f443
|
|
@ -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<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, // 부서 목록 조회
|
||||
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 중복 체크
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
export const userAPI = {
|
||||
getList: getUserList,
|
||||
|
|
@ -195,4 +316,7 @@ export const userAPI = {
|
|||
getCompanyList: getCompanyList,
|
||||
getDepartmentList: getDepartmentList,
|
||||
checkDuplicateId: checkDuplicateUserId,
|
||||
// 사원 + 부서 통합 관리
|
||||
saveWithDept: saveUserWithDept,
|
||||
getWithDept: getUserWithDept,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@ export function ModalRepeaterTableComponent({
|
|||
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
|
||||
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
|
||||
|
||||
// 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||
const sourceColumnLabels = componentConfig?.sourceColumnLabels || {};
|
||||
|
||||
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
|
||||
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
|
||||
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
|
||||
|
|
@ -546,11 +549,12 @@ export function ModalRepeaterTableComponent({
|
|||
handleChange(newData);
|
||||
};
|
||||
|
||||
// 컬럼명 -> 라벨명 매핑 생성
|
||||
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
|
||||
const columnLabels = columns.reduce((acc, col) => {
|
||||
acc[col.field] = col.label;
|
||||
// sourceColumnLabels에 정의된 라벨 우선 사용
|
||||
acc[col.field] = sourceColumnLabels[col.field] || col.label;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}, { ...sourceColumnLabels } as Record<string, string>);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
|
|
|
|||
|
|
@ -520,7 +520,7 @@ export function ModalRepeaterTableConfigPanel({
|
|||
{/* 소스 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">소스 컬럼</Label>
|
||||
<Label className="text-xs sm:text-sm">소스 컬럼 (항목 검색 모달)</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -533,37 +533,75 @@ export function ModalRepeaterTableConfigPanel({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
모달 테이블에 표시할 컬럼들
|
||||
모달 테이블에 표시할 컬럼과 헤더 라벨을 설정합니다
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{(localConfig.sourceColumns || []).map((column, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateSourceColumn(index, value)}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div key={index} className="flex items-start gap-2 p-3 border rounded-md bg-background">
|
||||
<div className="flex-1 space-y-2">
|
||||
{/* 컬럼 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">컬럼</Label>
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateSourceColumn(index, value)}
|
||||
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 라벨 입력 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">표시 라벨</Label>
|
||||
<Input
|
||||
value={localConfig.sourceColumnLabels?.[column] || ""}
|
||||
onChange={(e) => {
|
||||
const newLabels = { ...(localConfig.sourceColumnLabels || {}) };
|
||||
if (e.target.value) {
|
||||
newLabels[column] = e.target.value;
|
||||
} else {
|
||||
delete newLabels[column];
|
||||
}
|
||||
updateConfig({ sourceColumnLabels: newLabels });
|
||||
}}
|
||||
placeholder={tableColumns.find(c => c.columnName === column)?.displayName || column || "라벨 입력"}
|
||||
className="h-8 text-xs"
|
||||
disabled={!column}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSourceColumn(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
// 컬럼 삭제 시 해당 라벨도 삭제
|
||||
const newLabels = { ...(localConfig.sourceColumnLabels || {}) };
|
||||
delete newLabels[column];
|
||||
updateConfig({ sourceColumnLabels: newLabels });
|
||||
removeSourceColumn(index);
|
||||
}}
|
||||
className="h-8 w-8 p-0 mt-5"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(localConfig.sourceColumns || []).length === 0 && (
|
||||
<div className="text-center py-4 border-2 border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"추가" 버튼을 클릭하여 모달에 표시할 컬럼을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface ModalRepeaterTableProps {
|
|||
// 소스 데이터 (모달에서 가져올 데이터)
|
||||
sourceTable: string; // 검색할 테이블 (예: "item_info")
|
||||
sourceColumns: string[]; // 모달에 표시할 컬럼들
|
||||
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
||||
|
||||
// 🆕 저장 대상 테이블 설정
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ColumnConfig,
|
||||
DataTransferField,
|
||||
ActionButtonConfig,
|
||||
JoinTableConfig,
|
||||
} from "./types";
|
||||
import { defaultConfig } from "./config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -128,6 +129,99 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}
|
||||
}, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]);
|
||||
|
||||
// 조인 테이블 데이터 로드 (단일 테이블)
|
||||
const loadJoinTableData = useCallback(async (
|
||||
joinConfig: JoinTableConfig,
|
||||
mainData: any[]
|
||||
): Promise<Map<string, any>> => {
|
||||
const resultMap = new Map<string, any>();
|
||||
if (!joinConfig.joinTable || !joinConfig.mainColumn || !joinConfig.joinColumn || mainData.length === 0) {
|
||||
return resultMap;
|
||||
}
|
||||
|
||||
// 메인 데이터에서 조인할 키 값들 추출
|
||||
const joinKeys = [...new Set(mainData.map((item) => item[joinConfig.mainColumn]).filter(Boolean))];
|
||||
if (joinKeys.length === 0) return resultMap;
|
||||
|
||||
try {
|
||||
console.log(`[SplitPanelLayout2] 조인 테이블 로드: ${joinConfig.joinTable}, 키: ${joinKeys.length}개`);
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${joinConfig.joinTable}/data`, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
// 조인 키 값들로 필터링
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
matchType: "any", // OR 조건으로 여러 키 매칭
|
||||
filters: joinKeys.map((key, idx) => ({
|
||||
id: `join_key_${idx}`,
|
||||
columnName: joinConfig.joinColumn,
|
||||
operator: "equals",
|
||||
value: String(key),
|
||||
valueType: "static",
|
||||
})),
|
||||
},
|
||||
autoFilter: {
|
||||
enabled: true,
|
||||
filterColumn: "company_code",
|
||||
filterType: "company",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const joinData = response.data.data?.data || [];
|
||||
// 조인 컬럼 값을 키로 하는 Map 생성
|
||||
joinData.forEach((item: any) => {
|
||||
const key = item[joinConfig.joinColumn];
|
||||
if (key) {
|
||||
resultMap.set(String(key), item);
|
||||
}
|
||||
});
|
||||
console.log(`[SplitPanelLayout2] 조인 테이블 로드 완료: ${joinData.length}건`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SplitPanelLayout2] 조인 테이블 로드 실패 (${joinConfig.joinTable}):`, error);
|
||||
}
|
||||
|
||||
return resultMap;
|
||||
}, []);
|
||||
|
||||
// 메인 데이터에 조인 테이블 데이터 병합
|
||||
const mergeJoinData = useCallback((
|
||||
mainData: any[],
|
||||
joinConfig: JoinTableConfig,
|
||||
joinDataMap: Map<string, any>
|
||||
): any[] => {
|
||||
return mainData.map((item) => {
|
||||
const joinKey = item[joinConfig.mainColumn];
|
||||
const joinRow = joinDataMap.get(String(joinKey));
|
||||
|
||||
if (joinRow && joinConfig.selectColumns) {
|
||||
// 선택된 컬럼만 병합
|
||||
const mergedItem = { ...item };
|
||||
joinConfig.selectColumns.forEach((col) => {
|
||||
// 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용)
|
||||
const tableColumnKey = `${joinConfig.joinTable}.${col}`;
|
||||
mergedItem[tableColumnKey] = joinRow[col];
|
||||
|
||||
// alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성)
|
||||
const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col;
|
||||
// 메인 테이블에 같은 컬럼이 없으면 추가
|
||||
if (!(col in mergedItem)) {
|
||||
mergedItem[col] = joinRow[col];
|
||||
} else if (joinConfig.alias) {
|
||||
// 메인 테이블에 같은 컬럼이 있으면 alias로 추가
|
||||
mergedItem[targetKey] = joinRow[col];
|
||||
}
|
||||
});
|
||||
console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) });
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 우측 데이터 로드 (좌측 선택 항목 기반)
|
||||
const loadRightData = useCallback(async (selectedItem: any) => {
|
||||
if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) {
|
||||
|
|
@ -173,7 +267,24 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
|
||||
if (response.data.success) {
|
||||
// API 응답 구조: { success: true, data: { data: [...], total, page, ... } }
|
||||
const data = response.data.data?.data || [];
|
||||
let data = response.data.data?.data || [];
|
||||
console.log(`[SplitPanelLayout2] 메인 데이터 로드 완료: ${data.length}건`);
|
||||
|
||||
// 추가 조인 테이블 처리
|
||||
const joinTables = config.rightPanel?.joinTables || [];
|
||||
if (joinTables.length > 0 && data.length > 0) {
|
||||
console.log(`[SplitPanelLayout2] 조인 테이블 처리 시작: ${joinTables.length}개`);
|
||||
|
||||
for (const joinTableConfig of joinTables) {
|
||||
const joinDataMap = await loadJoinTableData(joinTableConfig, data);
|
||||
if (joinDataMap.size > 0) {
|
||||
data = mergeJoinData(data, joinTableConfig, joinDataMap);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SplitPanelLayout2] 조인 데이터 병합 완료`);
|
||||
}
|
||||
|
||||
setRightData(data);
|
||||
console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`);
|
||||
} else {
|
||||
|
|
@ -196,7 +307,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
} finally {
|
||||
setRightLoading(false);
|
||||
}
|
||||
}, [config.rightPanel?.tableName, config.joinConfig]);
|
||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables, config.joinConfig, loadJoinTableData, mergeJoinData]);
|
||||
|
||||
// 좌측 패널 추가 버튼 클릭
|
||||
const handleLeftAddClick = useCallback(() => {
|
||||
|
|
@ -632,6 +743,37 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
};
|
||||
}, [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 => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
|
@ -810,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{nameRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{nameRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
|
|
@ -825,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{infoRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
||||
{infoRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
|
|
@ -844,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{nameRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{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 (
|
||||
|
|
@ -865,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
{infoRowColumns.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
|
||||
{infoRowColumns.map((col, idx) => {
|
||||
const value = item[col.name];
|
||||
const value = getColumnValue(item, col);
|
||||
if (value === null || value === undefined) return null;
|
||||
return (
|
||||
<span key={idx} className="text-sm">
|
||||
|
|
@ -973,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
)}
|
||||
{displayColumns.map((col, colIdx) => (
|
||||
<TableCell key={colIdx}>
|
||||
{formatValue(item[col.name], col.format)}
|
||||
{formatValue(getColumnValue(item, col), col.format)}
|
||||
</TableCell>
|
||||
))}
|
||||
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types";
|
||||
import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField, JoinTableConfig } from "./types";
|
||||
|
||||
// lodash set 대체 함수
|
||||
const setPath = (obj: any, path: string, value: any): any => {
|
||||
|
|
@ -245,6 +245,70 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
}
|
||||
}, [config.rightPanel?.tableName, loadColumns]);
|
||||
|
||||
// 조인 테이블 컬럼도 우측 컬럼 목록에 추가
|
||||
useEffect(() => {
|
||||
const loadJoinTableColumns = async () => {
|
||||
const joinTables = config.rightPanel?.joinTables || [];
|
||||
if (joinTables.length === 0 || !config.rightPanel?.tableName) return;
|
||||
|
||||
// 메인 테이블 컬럼 먼저 로드
|
||||
try {
|
||||
const mainResponse = await apiClient.get(`/table-management/tables/${config.rightPanel.tableName}/columns?size=200`);
|
||||
let mainColumns: ColumnInfo[] = [];
|
||||
|
||||
if (mainResponse.data?.success) {
|
||||
const columnList = mainResponse.data.data?.columns || mainResponse.data.data || [];
|
||||
mainColumns = columnList.map((c: any) => ({
|
||||
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
||||
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// 조인 테이블들의 선택된 컬럼 추가
|
||||
const joinColumns: ColumnInfo[] = [];
|
||||
for (const jt of joinTables) {
|
||||
if (jt.joinTable && jt.selectColumns && jt.selectColumns.length > 0) {
|
||||
try {
|
||||
const joinResponse = await apiClient.get(`/table-management/tables/${jt.joinTable}/columns?size=200`);
|
||||
if (joinResponse.data?.success) {
|
||||
const columnList = joinResponse.data.data?.columns || joinResponse.data.data || [];
|
||||
const transformedColumns = columnList.map((c: any) => ({
|
||||
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
||||
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||
}));
|
||||
|
||||
// 선택된 컬럼 추가 (테이블명으로 구분, 유니크 키 생성)
|
||||
jt.selectColumns.forEach((selCol) => {
|
||||
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})`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`조인 테이블 ${jt.joinTable} 컬럼 로드 실패:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 + 조인 컬럼 합치기
|
||||
setRightColumns([...mainColumns, ...joinColumns]);
|
||||
console.log(`[loadJoinTableColumns] 우측 컬럼 로드 완료: 메인 ${mainColumns.length}개 + 조인 ${joinColumns.length}개`);
|
||||
} catch (error) {
|
||||
console.error("조인 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadJoinTableColumns();
|
||||
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables]);
|
||||
|
||||
// 테이블 선택 컴포넌트
|
||||
const TableSelect: React.FC<{
|
||||
value: string;
|
||||
|
|
@ -388,13 +452,28 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}> = ({ columns, value, onValueChange, placeholder }) => {
|
||||
showTableName?: boolean; // 테이블명 표시 여부
|
||||
tableName?: string; // 메인 테이블명 (조인 컬럼과 구분용)
|
||||
}> = ({ columns, value, onValueChange, placeholder, showTableName = false, tableName }) => {
|
||||
// 현재 선택된 값의 라벨 찾기
|
||||
const selectedColumn = columns.find((col) => col.column_name === value);
|
||||
const displayValue = selectedColumn
|
||||
? selectedColumn.column_comment || selectedColumn.column_name
|
||||
: value || "";
|
||||
|
||||
// 컬럼이 조인 테이블에서 온 것인지 확인 (column_comment에 괄호가 있으면 조인 테이블)
|
||||
const isJoinColumn = (col: ColumnInfo) => col.column_comment?.includes("(") && col.column_comment?.includes(")");
|
||||
|
||||
// 컬럼 표시 텍스트 생성
|
||||
const getColumnDisplayText = (col: ColumnInfo) => {
|
||||
const label = col.column_comment || col.column_name;
|
||||
if (showTableName && tableName && !isJoinColumn(col)) {
|
||||
// 메인 테이블 컬럼에 테이블명 추가
|
||||
return `${label} (${tableName})`;
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value || ""} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="h-9 text-sm min-w-[120px]">
|
||||
|
|
@ -410,7 +489,16 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
) : (
|
||||
columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_comment || col.column_name}
|
||||
<span className="flex flex-col">
|
||||
<span>{col.column_comment || col.column_name}</span>
|
||||
{showTableName && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{isJoinColumn(col)
|
||||
? col.column_name
|
||||
: `${col.column_name} (${tableName || "메인"})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
|
|
@ -419,6 +507,222 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
);
|
||||
};
|
||||
|
||||
// 조인 테이블 아이템 컴포넌트
|
||||
const JoinTableItem: React.FC<{
|
||||
index: number;
|
||||
joinTable: JoinTableConfig;
|
||||
tables: TableInfo[];
|
||||
mainTableColumns: ColumnInfo[];
|
||||
onUpdate: (field: keyof JoinTableConfig | Partial<JoinTableConfig>, value?: any) => void;
|
||||
onRemove: () => void;
|
||||
}> = ({ index, joinTable, tables, mainTableColumns, onUpdate, onRemove }) => {
|
||||
const [joinTableColumns, setJoinTableColumns] = useState<ColumnInfo[]>([]);
|
||||
const [joinTableOpen, setJoinTableOpen] = useState(false);
|
||||
|
||||
// 조인 테이블 선택 시 해당 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadJoinTableColumns = async () => {
|
||||
if (!joinTable.joinTable) {
|
||||
setJoinTableColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${joinTable.joinTable}/columns?size=200`);
|
||||
let columnList: any[] = [];
|
||||
if (response.data?.success && response.data?.data?.columns) {
|
||||
columnList = response.data.data.columns;
|
||||
} else if (Array.isArray(response.data?.data?.columns)) {
|
||||
columnList = response.data.data.columns;
|
||||
} else if (Array.isArray(response.data?.data)) {
|
||||
columnList = response.data.data;
|
||||
}
|
||||
|
||||
const transformedColumns = columnList.map((c: any) => ({
|
||||
column_name: c.columnName ?? c.column_name ?? c.name ?? "",
|
||||
data_type: c.dataType ?? c.data_type ?? c.type ?? "",
|
||||
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
|
||||
}));
|
||||
setJoinTableColumns(transformedColumns);
|
||||
} catch (error) {
|
||||
console.error("조인 테이블 컬럼 로드 실패:", error);
|
||||
setJoinTableColumns([]);
|
||||
}
|
||||
};
|
||||
loadJoinTableColumns();
|
||||
}, [joinTable.joinTable]);
|
||||
|
||||
const selectedTable = tables.find((t) => t.table_name === joinTable.joinTable);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">조인 {index + 1}</span>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onRemove}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 조인 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">조인할 테이블</Label>
|
||||
<Popover open={joinTableOpen} onOpenChange={setJoinTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={joinTableOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedTable
|
||||
? selectedTable.table_comment || selectedTable.table_name
|
||||
: joinTable.joinTable || "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.table_name}
|
||||
value={`${table.table_name} ${table.table_comment || ""}`}
|
||||
onSelect={() => {
|
||||
// cmdk가 value를 소문자로 변환하므로 직접 table.table_name 사용
|
||||
// 여러 필드를 한 번에 업데이트 (연속 호출 시 덮어쓰기 방지)
|
||||
onUpdate({
|
||||
joinTable: table.table_name,
|
||||
selectColumns: [], // 테이블 변경 시 선택 컬럼 초기화
|
||||
});
|
||||
setJoinTableOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
joinTable.joinTable === table.table_name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span>{table.table_comment || table.table_name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{table.table_name}</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 조인 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">조인 방식</Label>
|
||||
<Select
|
||||
value={joinTable.joinType || "LEFT"}
|
||||
onValueChange={(value) => onUpdate("joinType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEFT">LEFT JOIN (데이터 없어도 표시)</SelectItem>
|
||||
<SelectItem value="INNER">INNER JOIN (데이터 있어야만 표시)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">조인 조건</Label>
|
||||
<div className="rounded-md bg-muted/30 p-2 space-y-2">
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">메인 테이블 컬럼</Label>
|
||||
<ColumnSelect
|
||||
columns={mainTableColumns}
|
||||
value={joinTable.mainColumn || ""}
|
||||
onValueChange={(value) => onUpdate("mainColumn", value)}
|
||||
placeholder="메인 테이블 컬럼"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center text-[10px] text-muted-foreground">=</div>
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">조인 테이블 컬럼</Label>
|
||||
<ColumnSelect
|
||||
columns={joinTableColumns}
|
||||
value={joinTable.joinColumn || ""}
|
||||
onValueChange={(value) => onUpdate("joinColumn", value)}
|
||||
placeholder="조인 테이블 컬럼"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가져올 컬럼 선택 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Label className="text-xs">가져올 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 text-[10px] px-1"
|
||||
onClick={() => {
|
||||
const current = joinTable.selectColumns || [];
|
||||
onUpdate("selectColumns", [...current, ""]);
|
||||
}}
|
||||
disabled={!joinTable.joinTable}
|
||||
>
|
||||
<Plus className="mr-0.5 h-2.5 w-2.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
조인 테이블에서 표시할 컬럼들을 선택하세요
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{(joinTable.selectColumns || []).map((col, colIndex) => (
|
||||
<div key={colIndex} className="flex items-center gap-1">
|
||||
<ColumnSelect
|
||||
columns={joinTableColumns}
|
||||
value={col}
|
||||
onValueChange={(value) => {
|
||||
const current = [...(joinTable.selectColumns || [])];
|
||||
current[colIndex] = value;
|
||||
onUpdate("selectColumns", current);
|
||||
}}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 p-0"
|
||||
onClick={() => {
|
||||
const current = joinTable.selectColumns || [];
|
||||
onUpdate(
|
||||
"selectColumns",
|
||||
current.filter((_, i) => i !== colIndex)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{(joinTable.selectColumns || []).length === 0 && (
|
||||
<div className="rounded border py-2 text-center text-[10px] text-muted-foreground">
|
||||
가져올 컬럼을 추가하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 표시 컬럼 추가
|
||||
const addDisplayColumn = (side: "left" | "right") => {
|
||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||
|
|
@ -426,7 +730,12 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
? config.leftPanel?.displayColumns || []
|
||||
: config.rightPanel?.displayColumns || [];
|
||||
|
||||
updateConfig(path, [...currentColumns, { name: "", label: "" }]);
|
||||
// 기본 테이블 설정 (메인 테이블)
|
||||
const defaultTable = side === "left"
|
||||
? config.leftPanel?.tableName
|
||||
: config.rightPanel?.tableName;
|
||||
|
||||
updateConfig(path, [...currentColumns, { name: "", label: "", sourceTable: defaultTable || "" }]);
|
||||
};
|
||||
|
||||
// 표시 컬럼 삭제
|
||||
|
|
@ -440,14 +749,25 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
};
|
||||
|
||||
// 표시 컬럼 업데이트
|
||||
const updateDisplayColumn = (side: "left" | "right", index: number, field: keyof ColumnConfig, value: any) => {
|
||||
const updateDisplayColumn = (
|
||||
side: "left" | "right",
|
||||
index: number,
|
||||
fieldOrPartial: keyof ColumnConfig | Partial<ColumnConfig>,
|
||||
value?: any
|
||||
) => {
|
||||
const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns";
|
||||
const currentColumns = side === "left"
|
||||
? [...(config.leftPanel?.displayColumns || [])]
|
||||
: [...(config.rightPanel?.displayColumns || [])];
|
||||
|
||||
if (currentColumns[index]) {
|
||||
currentColumns[index] = { ...currentColumns[index], [field]: value };
|
||||
if (typeof fieldOrPartial === "object") {
|
||||
// 여러 필드를 한 번에 업데이트
|
||||
currentColumns[index] = { ...currentColumns[index], ...fieldOrPartial };
|
||||
} else {
|
||||
// 단일 필드 업데이트
|
||||
currentColumns[index] = { ...currentColumns[index], [fieldOrPartial]: value };
|
||||
}
|
||||
updateConfig(path, currentColumns);
|
||||
}
|
||||
};
|
||||
|
|
@ -687,6 +1007,66 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 추가 조인 테이블 설정 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">추가 조인 테이블</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const current = config.rightPanel?.joinTables || [];
|
||||
updateConfig("rightPanel.joinTables", [
|
||||
...current,
|
||||
{
|
||||
joinTable: "",
|
||||
joinType: "LEFT",
|
||||
mainColumn: "",
|
||||
joinColumn: "",
|
||||
selectColumns: [],
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
다른 테이블을 조인하면 표시할 컬럼에서 해당 테이블의 컬럼도 선택할 수 있습니다.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(config.rightPanel?.joinTables || []).map((joinTable, index) => (
|
||||
<JoinTableItem
|
||||
key={index}
|
||||
index={index}
|
||||
joinTable={joinTable}
|
||||
tables={tables}
|
||||
mainTableColumns={rightColumns}
|
||||
onUpdate={(fieldOrPartial, value) => {
|
||||
const current = [...(config.rightPanel?.joinTables || [])];
|
||||
if (typeof fieldOrPartial === "object") {
|
||||
// 여러 필드를 한 번에 업데이트
|
||||
current[index] = { ...current[index], ...fieldOrPartial };
|
||||
} else {
|
||||
// 단일 필드 업데이트
|
||||
current[index] = { ...current[index], [fieldOrPartial]: value };
|
||||
}
|
||||
updateConfig("rightPanel.joinTables", current);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const current = config.rightPanel?.joinTables || [];
|
||||
updateConfig(
|
||||
"rightPanel.joinTables",
|
||||
current.filter((_, i) => i !== index)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
|
@ -696,52 +1076,148 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
테이블을 선택한 후 해당 테이블의 컬럼을 선택하세요.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{(config.rightPanel?.displayColumns || []).map((col, index) => (
|
||||
<div key={index} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeDisplayColumn("right", index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
{(config.rightPanel?.displayColumns || []).map((col, index) => {
|
||||
// 선택 가능한 테이블 목록: 메인 테이블 + 조인 테이블들
|
||||
const availableTables = [
|
||||
config.rightPanel?.tableName,
|
||||
...(config.rightPanel?.joinTables || []).map((jt) => jt.joinTable),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// 선택된 테이블의 컬럼만 필터링
|
||||
const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName;
|
||||
const filteredColumns = rightColumns.filter((c) => {
|
||||
// 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태)
|
||||
const isJoinColumn = c.column_name.includes(".");
|
||||
|
||||
if (selectedSourceTable === config.rightPanel?.tableName) {
|
||||
// 메인 테이블 선택 시: 조인 컬럼 아닌 것만
|
||||
return !isJoinColumn;
|
||||
} else {
|
||||
// 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태)
|
||||
return c.column_name.startsWith(`${selectedSourceTable}.`);
|
||||
}
|
||||
});
|
||||
|
||||
// 테이블 라벨 가져오기
|
||||
const getTableLabel = (tableName: string) => {
|
||||
const table = tables.find((t) => t.table_name === tableName);
|
||||
return table?.table_comment || tableName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeDisplayColumn("right", index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">테이블</Label>
|
||||
<Select
|
||||
value={col.sourceTable || config.rightPanel?.tableName || ""}
|
||||
onValueChange={(value) => {
|
||||
// 테이블 변경 시 sourceTable과 name을 한 번에 업데이트
|
||||
updateDisplayColumn("right", index, {
|
||||
sourceTable: value,
|
||||
name: "", // 컬럼 초기화
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((tableName) => (
|
||||
<SelectItem key={tableName} value={tableName}>
|
||||
<span className="flex flex-col">
|
||||
<span>{getTableLabel(tableName)}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{tableName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">컬럼</Label>
|
||||
<Select
|
||||
value={col.name || ""}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredColumns.length === 0 ? (
|
||||
<SelectItem value="_empty" disabled>
|
||||
테이블을 먼저 선택하세요
|
||||
</SelectItem>
|
||||
) : (
|
||||
filteredColumns.map((c) => {
|
||||
// 조인 컬럼의 경우 테이블명 제거하고 표시
|
||||
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 (
|
||||
<SelectItem key={c.column_name} value={c.column_name}>
|
||||
<span className="flex flex-col">
|
||||
<span>{displayLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{actualColumnName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 라벨 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">표시 라벨</Label>
|
||||
<Input
|
||||
value={col.label || ""}
|
||||
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
|
||||
placeholder="라벨명 (미입력 시 컬럼명 사용)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 표시 위치 */}
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">표시 위치</Label>
|
||||
<Select
|
||||
value={col.displayRow || "info"}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">이름 행 (Name Row)</SelectItem>
|
||||
<SelectItem value="info">정보 행 (Info Row)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<ColumnSelect
|
||||
columns={rightColumns}
|
||||
value={col.name}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">표시 라벨</Label>
|
||||
<Input
|
||||
value={col.label || ""}
|
||||
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
|
||||
placeholder="라벨명 (미입력 시 컬럼명 사용)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">표시 위치</Label>
|
||||
<Select
|
||||
value={col.displayRow || "info"}
|
||||
onValueChange={(value) => updateDisplayColumn("right", index, "displayRow", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">이름 행 (Name Row)</SelectItem>
|
||||
<SelectItem value="info">정보 행 (Info Row)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{(config.rightPanel?.displayColumns || []).length === 0 && (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
|
||||
표시할 컬럼을 추가하세요
|
||||
|
|
@ -766,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-xs"
|
||||
disabled={(config.rightPanel?.displayColumns || []).length === 0}
|
||||
onClick={() => {
|
||||
const current = config.rightPanel?.searchColumns || [];
|
||||
updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]);
|
||||
|
|
@ -775,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
|||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
표시할 컬럼 중 검색에 사용할 컬럼을 선택하세요.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<ColumnSelect
|
||||
columns={rightColumns}
|
||||
value={searchCol.columnName}
|
||||
onValueChange={(value) => {
|
||||
const current = [...(config.rightPanel?.searchColumns || [])];
|
||||
current[index] = { ...current[index], columnName: value };
|
||||
updateConfig("rightPanel.searchColumns", current);
|
||||
}}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
<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>
|
||||
{(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 (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={searchCol.columnName || ""}
|
||||
onValueChange={(value) => {
|
||||
const current = [...(config.rightPanel?.searchColumns || [])];
|
||||
current[index] = { ...current[index], columnName: value };
|
||||
updateConfig("rightPanel.searchColumns", current);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{(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>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
export interface ColumnConfig {
|
||||
name: string; // 컬럼명
|
||||
label: string; // 표시 라벨
|
||||
sourceTable?: string; // 소스 테이블명 (메인 테이블 또는 조인 테이블)
|
||||
displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행)
|
||||
width?: number; // 너비 (px)
|
||||
bold?: boolean; // 굵게 표시
|
||||
|
|
@ -94,6 +95,17 @@ export interface RightPanelConfig {
|
|||
actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
|
||||
primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
|
||||
emptyMessage?: string; // 데이터 없을 때 메시지
|
||||
|
||||
/**
|
||||
* 추가 조인 테이블 설정
|
||||
* 메인 테이블에 다른 테이블을 JOIN하여 추가 정보를 함께 표시합니다.
|
||||
*
|
||||
* 사용 예시:
|
||||
* - 메인 테이블: user_dept (부서-사용자 관계)
|
||||
* - 조인 테이블: user_info (사용자 개인정보)
|
||||
* - 결과: 부서별 사원 목록에 이메일, 전화번호 등 개인정보 함께 표시
|
||||
*/
|
||||
joinTables?: JoinTableConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -104,6 +116,27 @@ export interface JoinConfig {
|
|||
rightColumn: string; // 우측 테이블의 조인 컬럼
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 조인 테이블 설정
|
||||
* 우측 패널의 메인 테이블에 다른 테이블을 JOIN하여 추가 컬럼을 가져옵니다.
|
||||
*
|
||||
* 예시: user_dept (메인) + user_info (조인) → 부서관계 + 개인정보 함께 표시
|
||||
*
|
||||
* - joinTable: 조인할 테이블명 (예: user_info)
|
||||
* - joinType: 조인 방식 (LEFT JOIN 권장)
|
||||
* - mainColumn: 메인 테이블의 조인 컬럼 (예: user_id)
|
||||
* - joinColumn: 조인 테이블의 조인 컬럼 (예: user_id)
|
||||
* - selectColumns: 조인 테이블에서 가져올 컬럼들 (예: email, cell_phone)
|
||||
*/
|
||||
export interface JoinTableConfig {
|
||||
joinTable: string; // 조인할 테이블명
|
||||
joinType: "LEFT" | "INNER"; // 조인 타입 (LEFT: 없어도 표시, INNER: 있어야만 표시)
|
||||
mainColumn: string; // 메인 테이블의 조인 컬럼
|
||||
joinColumn: string; // 조인 테이블의 조인 컬럼
|
||||
selectColumns: string[]; // 조인 테이블에서 가져올 컬럼들
|
||||
alias?: string; // 테이블 별칭 (중복 컬럼명 구분용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 설정
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -100,6 +100,11 @@ export function UniversalFormModalComponent({
|
|||
[key: string]: { value: string; label: string }[];
|
||||
}>({});
|
||||
|
||||
// 연동 필드 그룹 데이터 캐시 (테이블별 데이터)
|
||||
const [linkedFieldDataCache, setLinkedFieldDataCache] = useState<{
|
||||
[tableKey: string]: Record<string, any>[];
|
||||
}>({});
|
||||
|
||||
// 로딩 상태
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
|
@ -115,6 +120,33 @@ export function UniversalFormModalComponent({
|
|||
initializeForm();
|
||||
}, [config, initialData]);
|
||||
|
||||
// 필드 레벨 linkedFieldGroup 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const tablesToLoad = new Set<string>();
|
||||
|
||||
// 모든 섹션의 필드에서 linkedFieldGroup.sourceTable 수집
|
||||
config.sections.forEach((section) => {
|
||||
section.fields.forEach((field) => {
|
||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable) {
|
||||
tablesToLoad.add(field.linkedFieldGroup.sourceTable);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 각 테이블 데이터 로드
|
||||
for (const tableName of tablesToLoad) {
|
||||
if (!linkedFieldDataCache[tableName]) {
|
||||
console.log(`[UniversalFormModal] linkedFieldGroup 데이터 로드: ${tableName}`);
|
||||
await loadLinkedFieldData(tableName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.sections]);
|
||||
|
||||
// 폼 초기화
|
||||
const initializeForm = useCallback(async () => {
|
||||
const newFormData: FormDataState = {};
|
||||
|
|
@ -342,6 +374,56 @@ export function UniversalFormModalComponent({
|
|||
[selectOptionsCache],
|
||||
);
|
||||
|
||||
// 연동 필드 그룹 데이터 로드
|
||||
const loadLinkedFieldData = useCallback(
|
||||
async (sourceTable: string): Promise<Record<string, any>[]> => {
|
||||
// 캐시 확인 - 이미 배열로 캐시되어 있으면 반환
|
||||
if (Array.isArray(linkedFieldDataCache[sourceTable]) && linkedFieldDataCache[sourceTable].length > 0) {
|
||||
return linkedFieldDataCache[sourceTable];
|
||||
}
|
||||
|
||||
let data: Record<string, any>[] = [];
|
||||
|
||||
try {
|
||||
console.log(`[연동필드] ${sourceTable} 데이터 로드 시작`);
|
||||
// 현재 회사 기준으로 데이터 조회 (POST 메서드, autoFilter 사용)
|
||||
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
autoFilter: { enabled: true, filterColumn: "company_code" }, // 현재 회사 기준 자동 필터링
|
||||
});
|
||||
|
||||
console.log(`[연동필드] ${sourceTable} API 응답:`, response.data);
|
||||
|
||||
if (response.data?.success) {
|
||||
// data 구조 확인: { data: { data: [...], total, page, ... } } 또는 { data: [...] }
|
||||
const responseData = response.data?.data;
|
||||
if (Array.isArray(responseData)) {
|
||||
// 직접 배열인 경우
|
||||
data = responseData;
|
||||
} else if (responseData?.data && Array.isArray(responseData.data)) {
|
||||
// { data: [...], total: ... } 형태 (tableManagementService 응답)
|
||||
data = responseData.data;
|
||||
} else if (responseData?.rows && Array.isArray(responseData.rows)) {
|
||||
// { rows: [...], total: ... } 형태 (다른 API 응답)
|
||||
data = responseData.rows;
|
||||
}
|
||||
console.log(`[연동필드] ${sourceTable} 파싱된 데이터 ${data.length}개:`, data.slice(0, 3));
|
||||
}
|
||||
|
||||
// 캐시 저장 (빈 배열이라도 저장하여 중복 요청 방지)
|
||||
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: data }));
|
||||
} catch (error) {
|
||||
console.error(`연동 필드 데이터 로드 실패 (${sourceTable}):`, error);
|
||||
// 실패해도 빈 배열로 캐시하여 무한 요청 방지
|
||||
setLinkedFieldDataCache((prev) => ({ ...prev, [sourceTable]: [] }));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
[linkedFieldDataCache],
|
||||
);
|
||||
|
||||
// 필수 필드 검증
|
||||
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
|
||||
const missingFields: string[] = [];
|
||||
|
|
@ -362,59 +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);
|
||||
toast.error(error.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config, formData, repeatSections, onSave, validateRequiredFields]);
|
||||
|
||||
// 단일 행 저장
|
||||
const saveSingleRow = async () => {
|
||||
const saveSingleRow = useCallback(async () => {
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 메타데이터 필드 제거
|
||||
|
|
@ -446,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) {
|
||||
|
|
@ -475,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<string, any> = {};
|
||||
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<string, any> = {};
|
||||
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<string, any>[] = [];
|
||||
console.log("[UniversalFormModal] 공통 데이터:", commonData);
|
||||
console.log("[UniversalFormModal] 메인 섹션 데이터:", mainSectionData);
|
||||
console.log("[UniversalFormModal] 반복 항목:", repeatItems);
|
||||
|
||||
// 1. 메인 행 생성
|
||||
const mainRow: Record<string, any> = {
|
||||
...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<string, any> = { ...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];
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -578,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<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(() => {
|
||||
|
|
@ -624,7 +836,88 @@ export function UniversalFormModalComponent({
|
|||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "select": {
|
||||
// 다중 컬럼 저장이 활성화된 경우
|
||||
const lfgMappings = field.linkedFieldGroup?.mappings;
|
||||
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {
|
||||
const lfg = field.linkedFieldGroup;
|
||||
const sourceTableName = lfg.sourceTable as string;
|
||||
const cachedData = linkedFieldDataCache[sourceTableName];
|
||||
const sourceData = Array.isArray(cachedData) ? cachedData : [];
|
||||
|
||||
// 첫 번째 매핑의 sourceColumn을 드롭다운 값으로 사용
|
||||
const valueColumn = lfgMappings[0].sourceColumn || "";
|
||||
|
||||
// 데이터 로드 (아직 없으면)
|
||||
if (!cachedData && sourceTableName) {
|
||||
loadLinkedFieldData(sourceTableName);
|
||||
}
|
||||
|
||||
// 표시 텍스트 생성 함수
|
||||
const getDisplayText = (row: Record<string, unknown>): string => {
|
||||
const displayVal = row[lfg.displayColumn || ""] || "";
|
||||
const valueVal = row[valueColumn] || "";
|
||||
switch (lfg.displayFormat) {
|
||||
case "code_name":
|
||||
return `${valueVal} - ${displayVal}`;
|
||||
case "name_code":
|
||||
return `${displayVal} (${valueVal})`;
|
||||
case "name_only":
|
||||
default:
|
||||
return String(displayVal);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(selectedValue) => {
|
||||
// 선택된 값에 해당하는 행 찾기
|
||||
const selectedRow = sourceData.find((row) => String(row[valueColumn]) === selectedValue);
|
||||
|
||||
// 기본 필드 값 변경 (첫 번째 매핑의 값)
|
||||
onChangeHandler(selectedValue);
|
||||
|
||||
// 매핑된 컬럼들도 함께 저장
|
||||
if (selectedRow && lfg.mappings) {
|
||||
lfg.mappings.forEach((mapping) => {
|
||||
if (mapping.sourceColumn && mapping.targetColumn) {
|
||||
const mappedValue = selectedRow[mapping.sourceColumn];
|
||||
// formData에 직접 저장
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[mapping.targetColumn]: mappedValue,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger id={fieldKey} className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceData.length > 0 ? (
|
||||
sourceData.map((row, index) => (
|
||||
<SelectItem
|
||||
key={`${row[valueColumn] || index}_${index}`}
|
||||
value={String(row[valueColumn] || "")}
|
||||
>
|
||||
{getDisplayText(row)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="_empty" disabled>
|
||||
{cachedData === undefined ? "데이터를 불러오는 중..." : "데이터가 없습니다"}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 select 필드
|
||||
return (
|
||||
<SelectField
|
||||
fieldId={fieldKey}
|
||||
|
|
@ -636,6 +929,7 @@ export function UniversalFormModalComponent({
|
|||
loadOptions={loadSelectOptions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "date":
|
||||
return (
|
||||
|
|
@ -806,6 +1100,7 @@ export function UniversalFormModalComponent({
|
|||
<CollapsibleContent>
|
||||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -827,6 +1122,7 @@ export function UniversalFormModalComponent({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
@ -885,6 +1181,7 @@ export function UniversalFormModalComponent({
|
|||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(12, 1fr)` }}>
|
||||
{/* 일반 필드 렌더링 */}
|
||||
{section.fields.map((field) =>
|
||||
renderFieldWithColumns(
|
||||
field,
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@ import {
|
|||
UniversalFormModalConfigPanelProps,
|
||||
FormSectionConfig,
|
||||
FormFieldConfig,
|
||||
LinkedFieldMapping,
|
||||
FIELD_TYPE_OPTIONS,
|
||||
MODAL_SIZE_OPTIONS,
|
||||
SELECT_OPTION_TYPE_OPTIONS,
|
||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultFieldConfig,
|
||||
|
|
@ -87,6 +89,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.saveConfig.tableName]);
|
||||
|
||||
// 다중 컬럼 저장의 소스 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const allSourceTables = new Set<string>();
|
||||
config.sections.forEach((section) => {
|
||||
// 필드 레벨의 linkedFieldGroup 확인
|
||||
section.fields.forEach((field) => {
|
||||
if (field.linkedFieldGroup?.sourceTable) {
|
||||
allSourceTables.add(field.linkedFieldGroup.sourceTable);
|
||||
}
|
||||
});
|
||||
});
|
||||
allSourceTables.forEach((tableName) => {
|
||||
if (!tableColumns[tableName]) {
|
||||
loadTableColumns(tableName);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [config.sections]);
|
||||
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
|
|
@ -395,62 +416,74 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
{/* 저장 테이블 - Combobox */}
|
||||
<div>
|
||||
<Label className="text-[10px]">저장 테이블</Label>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="w-full h-7 justify-between text-xs mt-1"
|
||||
>
|
||||
{config.saveConfig.tableName
|
||||
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
|
||||
config.saveConfig.tableName
|
||||
: "테이블 선택 또는 직접 입력"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.name}
|
||||
value={`${t.name} ${t.label}`}
|
||||
onSelect={() => {
|
||||
updateSaveConfig({ tableName: t.name });
|
||||
setTableSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{t.name}</span>
|
||||
{t.label !== t.name && (
|
||||
<span className="ml-1 text-muted-foreground">({t.label})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.saveConfig.tableName && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
컬럼 {currentColumns.length}개 로드됨
|
||||
</p>
|
||||
{config.saveConfig.customApiSave?.enabled ? (
|
||||
<div className="mt-1 p-2 bg-muted/50 rounded text-[10px] text-muted-foreground">
|
||||
전용 API 저장 모드에서는 API가 테이블 저장을 처리합니다.
|
||||
{config.saveConfig.customApiSave?.apiType === "user-with-dept" && (
|
||||
<span className="block mt-1">대상 테이블: user_info + user_dept</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableSelectOpen}
|
||||
className="w-full h-7 justify-between text-xs mt-1"
|
||||
>
|
||||
{config.saveConfig.tableName
|
||||
? tables.find((t) => t.name === config.saveConfig.tableName)?.label ||
|
||||
config.saveConfig.tableName
|
||||
: "테이블 선택 또는 직접 입력"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2 text-center">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.name}
|
||||
value={`${t.name} ${t.label}`}
|
||||
onSelect={() => {
|
||||
updateSaveConfig({ tableName: t.name });
|
||||
setTableSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.saveConfig.tableName === t.name ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{t.name}</span>
|
||||
{t.label !== t.name && (
|
||||
<span className="ml-1 text-muted-foreground">({t.label})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.saveConfig.tableName && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
컬럼 {currentColumns.length}개 로드됨
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 다중 행 저장 설정 */}
|
||||
{/* 다중 행 저장 설정 - 전용 API 모드에서는 숨김 */}
|
||||
{!config.saveConfig.customApiSave?.enabled && (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">다중 행 저장</span>
|
||||
|
|
@ -554,47 +587,321 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
</Select>
|
||||
<HelpText>겸직 등 반복 데이터가 있는 섹션</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
{/* 커스텀 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]">구분 컬럼</Label>
|
||||
<Input
|
||||
value={config.saveConfig.multiRowSave?.typeColumn || "employment_type"}
|
||||
onChange={(e) =>
|
||||
<Label className="text-[10px]">API 타입</Label>
|
||||
<Select
|
||||
value={config.saveConfig.customApiSave?.apiType || "user-with-dept"}
|
||||
onValueChange={(value: "user-with-dept" | "custom") =>
|
||||
updateSaveConfig({
|
||||
multiRowSave: { ...config.saveConfig.multiRowSave, typeColumn: e.target.value },
|
||||
customApiSave: { ...config.saveConfig.customApiSave, apiType: value },
|
||||
})
|
||||
}
|
||||
placeholder="employment_type"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>메인/서브를 구분하는 컬럼명</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">메인 값</Label>
|
||||
<Input
|
||||
value={config.saveConfig.multiRowSave?.mainTypeValue || "main"}
|
||||
onChange={(e) =>
|
||||
updateSaveConfig({
|
||||
multiRowSave: { ...config.saveConfig.multiRowSave, mainTypeValue: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">서브 값</Label>
|
||||
<Input
|
||||
value={config.saveConfig.multiRowSave?.subTypeValue || "concurrent"}
|
||||
onChange={(e) =>
|
||||
updateSaveConfig({
|
||||
multiRowSave: { ...config.saveConfig.multiRowSave, subTypeValue: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
>
|
||||
<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>
|
||||
|
|
@ -659,7 +966,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
<Card
|
||||
key={section.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
"cursor-pointer transition-colors !p-0",
|
||||
selectedSectionId === section.id && "ring-2 ring-primary",
|
||||
)}
|
||||
onClick={() => {
|
||||
|
|
@ -1144,7 +1451,8 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
{/* Select 옵션 설정 */}
|
||||
{selectedField.fieldType === "select" && (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<Label className="text-[10px] font-medium">선택 옵션 설정</Label>
|
||||
<Label className="text-[10px] font-medium">드롭다운 옵션 설정</Label>
|
||||
<HelpText>드롭다운에 표시될 옵션 목록을 어디서 가져올지 설정합니다.</HelpText>
|
||||
<Select
|
||||
value={selectedField.selectOptions?.type || "static"}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1168,10 +1476,15 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedField.selectOptions?.type === "static" && (
|
||||
<HelpText>직접 입력: 옵션을 수동으로 입력합니다. (현재 미구현 - 테이블 참조 사용 권장)</HelpText>
|
||||
)}
|
||||
|
||||
{selectedField.selectOptions?.type === "table" && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||
<div>
|
||||
<Label className="text-[10px]">참조 테이블</Label>
|
||||
<Label className="text-[10px]">참조 테이블 (옵션을 가져올 테이블)</Label>
|
||||
<Select
|
||||
value={selectedField.selectOptions?.tableName || ""}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1194,9 +1507,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>예: dept_info (부서 테이블)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">값 컬럼</Label>
|
||||
<Label className="text-[10px]">값 컬럼 (저장될 값)</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.valueColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -1207,12 +1521,13 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
},
|
||||
})
|
||||
}
|
||||
placeholder="code"
|
||||
placeholder="dept_code"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>선택 시 실제 저장되는 값 (예: D001)</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">라벨 컬럼</Label>
|
||||
<Label className="text-[10px]">라벨 컬럼 (화면에 표시될 텍스트)</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.labelColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -1223,15 +1538,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
},
|
||||
})
|
||||
}
|
||||
placeholder="name"
|
||||
placeholder="dept_name"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>드롭다운에 보여질 텍스트 (예: 영업부)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedField.selectOptions?.type === "code" && (
|
||||
<div className="pt-2 border-t">
|
||||
<HelpText>공통코드: 공통코드 테이블에서 옵션을 가져옵니다.</HelpText>
|
||||
<Label className="text-[10px]">공통코드 카테고리</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.codeCategory || ""}
|
||||
|
|
@ -1246,6 +1563,235 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
placeholder="POSITION_CODE"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
/>
|
||||
<HelpText>예: POSITION_CODE (직급), STATUS_CODE (상태) 등</HelpText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다중 컬럼 저장 (select 타입만) */}
|
||||
{selectedField.fieldType === "select" && (
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">다중 컬럼 저장</span>
|
||||
<Switch
|
||||
checked={selectedField.linkedFieldGroup?.enabled || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
enabled: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>
|
||||
드롭다운 선택 시 여러 컬럼에 동시 저장합니다.
|
||||
<br />예: 부서 선택 시 부서코드 + 부서명을 각각 다른 컬럼에 저장
|
||||
</HelpText>
|
||||
|
||||
{selectedField.linkedFieldGroup?.enabled && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
{/* 소스 테이블 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">데이터 소스 테이블</Label>
|
||||
<Select
|
||||
value={selectedField.linkedFieldGroup?.sourceTable || ""}
|
||||
onValueChange={(value) => {
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
sourceTable: value,
|
||||
},
|
||||
});
|
||||
if (value && !tableColumns[value]) {
|
||||
loadTableColumns(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>드롭다운 옵션을 가져올 테이블</HelpText>
|
||||
</div>
|
||||
|
||||
{/* 표시 형식 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">드롭다운 표시 형식</Label>
|
||||
<Select
|
||||
value={selectedField.linkedFieldGroup?.displayFormat || "name_only"}
|
||||
onValueChange={(value: "name_only" | "code_name" | "name_code") =>
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
displayFormat: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LINKED_FIELD_DISPLAY_FORMAT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 / 값 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<Label className="text-[10px]">표시 컬럼 (사용자에게 보여줄 텍스트)</Label>
|
||||
<Select
|
||||
value={selectedField.linkedFieldGroup?.displayColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
displayColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
|
||||
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>사용자가 드롭다운에서 보게 될 텍스트 (예: 영업부, 개발부)</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 저장할 컬럼 매핑 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">저장할 컬럼 매핑</Label>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newMapping: LinkedFieldMapping = { sourceColumn: "", targetColumn: "" };
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: [...(selectedField.linkedFieldGroup?.mappings || []), newMapping],
|
||||
},
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>드롭다운 선택 시 소스 테이블의 어떤 값을 어떤 컬럼에 저장할지 설정</HelpText>
|
||||
|
||||
{(selectedField.linkedFieldGroup?.mappings || []).map((mapping, mappingIndex) => (
|
||||
<div key={mappingIndex} className="bg-muted/30 p-1.5 rounded space-y-1 border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-muted-foreground">매핑 #{mappingIndex + 1}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 text-destructive"
|
||||
onClick={() => {
|
||||
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).filter(
|
||||
(_, i) => i !== mappingIndex
|
||||
);
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: updatedMappings,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[9px]">가져올 컬럼 (소스 테이블)</Label>
|
||||
<Select
|
||||
value={mapping.sourceColumn}
|
||||
onValueChange={(value) => {
|
||||
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
|
||||
i === mappingIndex ? { ...m, sourceColumn: value } : m
|
||||
);
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: updatedMappings,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="소스 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[selectedField.linkedFieldGroup?.sourceTable || ""] || []).map((col) => (
|
||||
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[9px]">저장할 컬럼 (저장 테이블)</Label>
|
||||
<Select
|
||||
value={mapping.targetColumn}
|
||||
onValueChange={(value) => {
|
||||
const updatedMappings = (selectedField.linkedFieldGroup?.mappings || []).map((m, i) =>
|
||||
i === mappingIndex ? { ...m, targetColumn: value } : m
|
||||
);
|
||||
updateField(selectedSection.id, selectedField.id, {
|
||||
linkedFieldGroup: {
|
||||
...selectedField.linkedFieldGroup,
|
||||
mappings: updatedMappings,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-5 text-[9px] mt-0.5">
|
||||
<SelectValue placeholder="저장할 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[config.saveConfig.tableName] || []).map((col) => (
|
||||
<SelectItem key={col.name} value={col.name} className="text-xs">
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(selectedField.linkedFieldGroup?.mappings || []).length === 0 && (
|
||||
<p className="text-[9px] text-muted-foreground text-center py-2">
|
||||
+ 버튼을 눌러 매핑을 추가하세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,6 +90,27 @@ export const defaultSectionConfig = {
|
|||
itemTitle: "항목 {index}",
|
||||
confirmRemove: false,
|
||||
},
|
||||
linkedFieldGroups: [],
|
||||
};
|
||||
|
||||
// 기본 연동 필드 그룹 설정
|
||||
export const defaultLinkedFieldGroupConfig = {
|
||||
id: "",
|
||||
label: "연동 필드",
|
||||
sourceTable: "dept_info",
|
||||
displayFormat: "code_name" as const,
|
||||
displayColumn: "dept_name",
|
||||
valueColumn: "dept_code",
|
||||
mappings: [],
|
||||
required: false,
|
||||
placeholder: "선택하세요",
|
||||
gridSpan: 6,
|
||||
};
|
||||
|
||||
// 기본 연동 필드 매핑 설정
|
||||
export const defaultLinkedFieldMappingConfig = {
|
||||
sourceColumn: "",
|
||||
targetColumn: "",
|
||||
};
|
||||
|
||||
// 기본 채번규칙 설정
|
||||
|
|
@ -136,3 +157,8 @@ export const generateSectionId = (): string => {
|
|||
export const generateFieldId = (): string => {
|
||||
return generateUniqueId("field");
|
||||
};
|
||||
|
||||
// 유틸리티: 연동 필드 그룹 ID 생성
|
||||
export const generateLinkedFieldGroupId = (): string => {
|
||||
return generateUniqueId("linked");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,15 @@ export interface FormFieldConfig {
|
|||
// Select 옵션
|
||||
selectOptions?: SelectOptionConfig;
|
||||
|
||||
// 다중 컬럼 저장 (드롭다운 선택 시 여러 컬럼에 동시 저장)
|
||||
linkedFieldGroup?: {
|
||||
enabled?: boolean; // 사용 여부
|
||||
sourceTable?: string; // 소스 테이블 (예: dept_info)
|
||||
displayColumn?: string; // 표시할 컬럼 (예: dept_name) - 드롭다운에 보여줄 텍스트
|
||||
displayFormat?: "name_only" | "code_name" | "name_code"; // 표시 형식
|
||||
mappings?: LinkedFieldMapping[]; // 저장할 컬럼 매핑 (첫 번째 매핑의 sourceColumn이 드롭다운 값으로 사용됨)
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
validation?: FieldValidationConfig;
|
||||
|
||||
|
|
@ -96,6 +105,27 @@ export interface FormFieldConfig {
|
|||
};
|
||||
}
|
||||
|
||||
// 연동 필드 매핑 설정
|
||||
export interface LinkedFieldMapping {
|
||||
sourceColumn: string; // 소스 테이블 컬럼 (예: "dept_code")
|
||||
targetColumn: string; // 저장할 컬럼 (예: "position_code")
|
||||
}
|
||||
|
||||
// 연동 필드 그룹 설정 (섹션 레벨)
|
||||
// 하나의 드롭다운에서 선택 시 여러 컬럼에 자동 저장
|
||||
export interface LinkedFieldGroup {
|
||||
id: string;
|
||||
label: string; // 드롭다운 라벨 (예: "겸직부서")
|
||||
sourceTable: string; // 소스 테이블 (예: "dept_info")
|
||||
displayFormat: "name_only" | "code_name" | "name_code"; // 표시 형식
|
||||
displayColumn: string; // 표시할 컬럼 (예: "dept_name")
|
||||
valueColumn: string; // 값으로 사용할 컬럼 (예: "dept_code")
|
||||
mappings: LinkedFieldMapping[]; // 필드 매핑 목록
|
||||
required?: boolean; // 필수 여부
|
||||
placeholder?: string; // 플레이스홀더
|
||||
gridSpan?: number; // 그리드 스팬 (1-12)
|
||||
}
|
||||
|
||||
// 반복 섹션 설정
|
||||
export interface RepeatSectionConfig {
|
||||
minItems?: number; // 최소 항목 수 (기본: 0)
|
||||
|
|
@ -119,6 +149,9 @@ export interface FormSectionConfig {
|
|||
repeatable?: boolean;
|
||||
repeatConfig?: RepeatSectionConfig;
|
||||
|
||||
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
||||
linkedFieldGroups?: LinkedFieldGroup[];
|
||||
|
||||
// 섹션 레이아웃
|
||||
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
|
||||
gap?: string; // 필드 간 간격
|
||||
|
|
@ -145,6 +178,9 @@ export interface SaveConfig {
|
|||
// 다중 행 저장 설정
|
||||
multiRowSave?: MultiRowSaveConfig;
|
||||
|
||||
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
||||
customApiSave?: CustomApiSaveConfig;
|
||||
|
||||
// 저장 후 동작 (간편 설정)
|
||||
showToast?: boolean; // 토스트 메시지 (기본: true)
|
||||
refreshParent?: boolean; // 부모 새로고침 (기본: true)
|
||||
|
|
@ -158,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;
|
||||
|
|
@ -257,3 +331,10 @@ export const SELECT_OPTION_TYPE_OPTIONS = [
|
|||
{ value: "table", label: "테이블 참조" },
|
||||
{ value: "code", label: "공통코드" },
|
||||
] as const;
|
||||
|
||||
// 연동 필드 표시 형식 옵션
|
||||
export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
|
||||
{ value: "name_only", label: "이름만 (예: 영업부)" },
|
||||
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
|
||||
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
|
||||
] as const;
|
||||
|
|
|
|||
Loading…
Reference in New Issue