Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
bc0cb56f1f
|
|
@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||||
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||||
|
import { MenuCopyService } from "../services/menuCopyService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 메뉴 목록 조회
|
* 관리자 메뉴 목록 조회
|
||||||
|
|
@ -3253,3 +3254,93 @@ export async function getTableSchema(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 복사
|
||||||
|
* POST /api/admin/menus/:menuObjid/copy
|
||||||
|
*/
|
||||||
|
export async function copyMenu(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { menuObjid } = req.params;
|
||||||
|
const { targetCompanyCode } = req.body;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const userCompanyCode = req.user!.companyCode;
|
||||||
|
const userType = req.user!.userType;
|
||||||
|
const isSuperAdmin = req.user!.isSuperAdmin;
|
||||||
|
|
||||||
|
logger.info(`
|
||||||
|
=== 메뉴 복사 API 호출 ===
|
||||||
|
menuObjid: ${menuObjid}
|
||||||
|
targetCompanyCode: ${targetCompanyCode}
|
||||||
|
userId: ${userId}
|
||||||
|
userCompanyCode: ${userCompanyCode}
|
||||||
|
userType: ${userType}
|
||||||
|
isSuperAdmin: ${isSuperAdmin}
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 권한 체크: 최고 관리자만 가능
|
||||||
|
if (!isSuperAdmin && userType !== "SUPER_ADMIN") {
|
||||||
|
logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`);
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다",
|
||||||
|
error: {
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
details: "Only super admin can copy menus",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필수 파라미터 검증
|
||||||
|
if (!menuObjid || !targetCompanyCode) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 파라미터가 누락되었습니다",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_PARAMETERS",
|
||||||
|
details: "menuObjid and targetCompanyCode are required",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면명 변환 설정 (선택사항)
|
||||||
|
const screenNameConfig = req.body.screenNameConfig
|
||||||
|
? {
|
||||||
|
removeText: req.body.screenNameConfig.removeText,
|
||||||
|
addPrefix: req.body.screenNameConfig.addPrefix,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 메뉴 복사 실행
|
||||||
|
const menuCopyService = new MenuCopyService();
|
||||||
|
const result = await menuCopyService.copyMenu(
|
||||||
|
parseInt(menuObjid, 10),
|
||||||
|
targetCompanyCode,
|
||||||
|
userId,
|
||||||
|
screenNameConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("✅ 메뉴 복사 API 성공");
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "메뉴 복사 완료",
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("❌ 메뉴 복사 API 실패:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "메뉴 복사 중 오류가 발생했습니다",
|
||||||
|
error: {
|
||||||
|
code: "MENU_COPY_ERROR",
|
||||||
|
details: error.message || "Unknown error",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수주 목록 조회 API
|
* 수주 목록 조회 API (마스터 + 품목 JOIN)
|
||||||
* GET /api/orders
|
* GET /api/orders
|
||||||
*/
|
*/
|
||||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
@ -184,14 +184,14 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
||||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereConditions.push(`writer LIKE $${paramIndex}`);
|
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
||||||
params.push(`%${companyCode}%`);
|
params.push(`%${companyCode}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색
|
// 검색
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
whereConditions.push(`objid LIKE $${paramIndex}`);
|
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
||||||
params.push(`%${searchText}%`);
|
params.push(`%${searchText}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -201,16 +201,47 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// 카운트 쿼리
|
// 카운트 쿼리 (고유한 수주 개수)
|
||||||
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
|
const countQuery = `
|
||||||
|
SELECT COUNT(DISTINCT m.objid) as count
|
||||||
|
FROM order_mng_master m
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
const countResult = await pool.query(countQuery, params);
|
const countResult = await pool.query(countQuery, params);
|
||||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||||
|
|
||||||
// 데이터 쿼리
|
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT * FROM order_mng_master
|
SELECT
|
||||||
|
m.objid as order_no,
|
||||||
|
m.partner_objid,
|
||||||
|
m.final_delivery_date,
|
||||||
|
m.reason,
|
||||||
|
m.status,
|
||||||
|
m.reg_date,
|
||||||
|
m.writer,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
CASE WHEN s.objid IS NOT NULL THEN
|
||||||
|
json_build_object(
|
||||||
|
'sub_objid', s.objid,
|
||||||
|
'part_objid', s.part_objid,
|
||||||
|
'partner_price', s.partner_price,
|
||||||
|
'partner_qty', s.partner_qty,
|
||||||
|
'delivery_date', s.delivery_date,
|
||||||
|
'status', s.status,
|
||||||
|
'regdate', s.regdate
|
||||||
|
)
|
||||||
|
END
|
||||||
|
ORDER BY s.regdate
|
||||||
|
) FILTER (WHERE s.objid IS NOT NULL),
|
||||||
|
'[]'::json
|
||||||
|
) as items
|
||||||
|
FROM order_mng_master m
|
||||||
|
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY reg_date DESC
|
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
|
||||||
|
ORDER BY m.reg_date DESC
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -219,6 +250,13 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
||||||
const dataResult = await pool.query(dataQuery, params);
|
const dataResult = await pool.query(dataQuery, params);
|
||||||
|
|
||||||
|
logger.info("수주 목록 조회 성공", {
|
||||||
|
companyCode,
|
||||||
|
total,
|
||||||
|
page: parseInt(page as string),
|
||||||
|
itemCount: dataResult.rows.length,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: dataResult.rows,
|
data: dataResult.rows,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
deleteMenu, // 메뉴 삭제
|
deleteMenu, // 메뉴 삭제
|
||||||
deleteMenusBatch, // 메뉴 일괄 삭제
|
deleteMenusBatch, // 메뉴 일괄 삭제
|
||||||
toggleMenuStatus, // 메뉴 상태 토글
|
toggleMenuStatus, // 메뉴 상태 토글
|
||||||
|
copyMenu, // 메뉴 복사
|
||||||
getUserList,
|
getUserList,
|
||||||
getUserInfo, // 사용자 상세 조회
|
getUserInfo, // 사용자 상세 조회
|
||||||
getUserHistory, // 사용자 변경이력 조회
|
getUserHistory, // 사용자 변경이력 조회
|
||||||
|
|
@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus);
|
||||||
router.get("/user-menus", getUserMenus);
|
router.get("/user-menus", getUserMenus);
|
||||||
router.get("/menus/:menuId", getMenuInfo);
|
router.get("/menus/:menuId", getMenuInfo);
|
||||||
router.post("/menus", saveMenu); // 메뉴 추가
|
router.post("/menus", saveMenu); // 메뉴 추가
|
||||||
|
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||||
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
|
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
|
||||||
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
|
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
|
||||||
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)
|
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)
|
||||||
|
|
|
||||||
|
|
@ -320,19 +320,34 @@ export class DynamicFormService {
|
||||||
Object.keys(dataToInsert).forEach((key) => {
|
Object.keys(dataToInsert).forEach((key) => {
|
||||||
const value = dataToInsert[key];
|
const value = dataToInsert[key];
|
||||||
|
|
||||||
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
|
// 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열)
|
||||||
if (
|
let parsedArray: any[] | null = null;
|
||||||
|
|
||||||
|
// 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등)
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
parsedArray = value;
|
||||||
|
console.log(
|
||||||
|
`🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput)
|
||||||
|
else if (
|
||||||
typeof value === "string" &&
|
typeof value === "string" &&
|
||||||
value.trim().startsWith("[") &&
|
value.trim().startsWith("[") &&
|
||||||
value.trim().endsWith("]")
|
value.trim().endsWith("]")
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const parsedArray = JSON.parse(value);
|
parsedArray = JSON.parse(value);
|
||||||
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
|
|
||||||
console.log(
|
console.log(
|
||||||
`🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
|
||||||
);
|
);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파싱된 배열이 있으면 처리
|
||||||
|
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||||
let targetTable: string | undefined;
|
let targetTable: string | undefined;
|
||||||
|
|
@ -352,13 +367,34 @@ export class DynamicFormService {
|
||||||
componentId: key,
|
componentId: key,
|
||||||
});
|
});
|
||||||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||||||
}
|
|
||||||
} catch (parseError) {
|
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
||||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
||||||
}
|
itemCount: actualData.length,
|
||||||
|
firstItem: actualData[0],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
|
||||||
|
const separateRepeaterData: typeof repeaterData = [];
|
||||||
|
const mergedRepeaterData: typeof repeaterData = [];
|
||||||
|
|
||||||
|
repeaterData.forEach(repeater => {
|
||||||
|
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||||||
|
// 다른 테이블: 나중에 별도 저장
|
||||||
|
separateRepeaterData.push(repeater);
|
||||||
|
} else {
|
||||||
|
// 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에)
|
||||||
|
mergedRepeaterData.push(repeater);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔄 Repeater 데이터 분류:`, {
|
||||||
|
separate: separateRepeaterData.length, // 별도 테이블
|
||||||
|
merged: mergedRepeaterData.length, // 메인 테이블과 병합
|
||||||
|
});
|
||||||
|
|
||||||
// 존재하지 않는 컬럼 제거
|
// 존재하지 않는 컬럼 제거
|
||||||
Object.keys(dataToInsert).forEach((key) => {
|
Object.keys(dataToInsert).forEach((key) => {
|
||||||
if (!tableColumns.includes(key)) {
|
if (!tableColumns.includes(key)) {
|
||||||
|
|
@ -369,9 +405,6 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
|
|
||||||
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
|
|
||||||
|
|
||||||
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
||||||
tableName,
|
tableName,
|
||||||
dataToInsert,
|
dataToInsert,
|
||||||
|
|
@ -452,28 +485,95 @@ export class DynamicFormService {
|
||||||
const userId = data.updated_by || data.created_by || "system";
|
const userId = data.updated_by || data.created_by || "system";
|
||||||
const clientIp = ipAddress || "unknown";
|
const clientIp = ipAddress || "unknown";
|
||||||
|
|
||||||
const result = await transaction(async (client) => {
|
let result: any[];
|
||||||
// 세션 변수 설정
|
|
||||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
if (mergedRepeaterData.length > 0) {
|
||||||
|
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
||||||
// UPSERT 실행
|
|
||||||
const res = await client.query(upsertQuery, values);
|
result = [];
|
||||||
return res.rows;
|
|
||||||
});
|
for (const repeater of mergedRepeaterData) {
|
||||||
|
for (const item of repeater.data) {
|
||||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
// 헤더 + 품목을 병합
|
||||||
|
const mergedData = { ...dataToInsert, ...item };
|
||||||
|
|
||||||
|
// 타입 변환
|
||||||
|
Object.keys(mergedData).forEach((columnName) => {
|
||||||
|
const column = columnInfo.find((col) => col.column_name === columnName);
|
||||||
|
if (column) {
|
||||||
|
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||||
|
mergedData[columnName],
|
||||||
|
column.data_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedColumns = Object.keys(mergedData);
|
||||||
|
const mergedValues: any[] = Object.values(mergedData);
|
||||||
|
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
|
||||||
|
|
||||||
|
let mergedUpsertQuery: string;
|
||||||
|
if (primaryKeys.length > 0) {
|
||||||
|
const conflictColumns = primaryKeys.join(", ");
|
||||||
|
const updateSet = mergedColumns
|
||||||
|
.filter((col) => !primaryKeys.includes(col))
|
||||||
|
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
mergedUpsertQuery = updateSet
|
||||||
|
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||||
|
VALUES (${mergedPlaceholders})
|
||||||
|
ON CONFLICT (${conflictColumns})
|
||||||
|
DO UPDATE SET ${updateSet}
|
||||||
|
RETURNING *`
|
||||||
|
: `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||||
|
VALUES (${mergedPlaceholders})
|
||||||
|
ON CONFLICT (${conflictColumns})
|
||||||
|
DO NOTHING
|
||||||
|
RETURNING *`;
|
||||||
|
} else {
|
||||||
|
mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||||
|
VALUES (${mergedPlaceholders})
|
||||||
|
RETURNING *`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📝 병합 INSERT:`, { mergedData });
|
||||||
|
|
||||||
|
const itemResult = await transaction(async (client) => {
|
||||||
|
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||||
|
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||||
|
const res = await client.query(mergedUpsertQuery, mergedValues);
|
||||||
|
return res.rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
result.push(itemResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
|
||||||
|
} else {
|
||||||
|
// 일반 모드: 헤더만 저장
|
||||||
|
result = await transaction(async (client) => {
|
||||||
|
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||||
|
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||||
|
const res = await client.query(upsertQuery, values);
|
||||||
|
return res.rows;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||||
|
}
|
||||||
|
|
||||||
// 결과를 표준 형식으로 변환
|
// 결과를 표준 형식으로 변환
|
||||||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||||||
|
|
||||||
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
|
// 📝 별도 테이블 Repeater 데이터 저장
|
||||||
if (repeaterData.length > 0) {
|
if (separateRepeaterData.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater`
|
`🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개`
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const repeater of repeaterData) {
|
for (const repeater of separateRepeaterData) {
|
||||||
const targetTableName = repeater.targetTable || tableName;
|
const targetTableName = repeater.targetTable || tableName;
|
||||||
console.log(
|
console.log(
|
||||||
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
||||||
|
|
@ -497,8 +597,13 @@ export class DynamicFormService {
|
||||||
created_by,
|
created_by,
|
||||||
updated_by,
|
updated_by,
|
||||||
regdate: new Date(),
|
regdate: new Date(),
|
||||||
|
// 🔥 멀티테넌시: company_code 필수 추가
|
||||||
|
company_code: data.company_code || company_code,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔥 별도 테이블인 경우에만 외래키 추가
|
||||||
|
// (같은 테이블이면 이미 병합 모드에서 처리됨)
|
||||||
|
|
||||||
// 대상 테이블에 존재하는 컬럼만 필터링
|
// 대상 테이블에 존재하는 컬럼만 필터링
|
||||||
Object.keys(itemData).forEach((key) => {
|
Object.keys(itemData).forEach((key) => {
|
||||||
if (!targetColumnNames.includes(key)) {
|
if (!targetColumnNames.includes(key)) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -300,10 +300,9 @@ class NumberingRuleService {
|
||||||
FROM numbering_rules
|
FROM numbering_rules
|
||||||
WHERE
|
WHERE
|
||||||
scope_type = 'global'
|
scope_type = 'global'
|
||||||
OR scope_type = 'table'
|
|
||||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||||
|
|
@ -313,9 +312,9 @@ class NumberingRuleService {
|
||||||
created_at DESC
|
created_at DESC
|
||||||
`;
|
`;
|
||||||
params = [siblingObjids];
|
params = [siblingObjids];
|
||||||
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
|
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
|
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||||
query = `
|
query = `
|
||||||
SELECT
|
SELECT
|
||||||
rule_id AS "ruleId",
|
rule_id AS "ruleId",
|
||||||
|
|
@ -336,10 +335,9 @@ class NumberingRuleService {
|
||||||
WHERE company_code = $1
|
WHERE company_code = $1
|
||||||
AND (
|
AND (
|
||||||
scope_type = 'global'
|
scope_type = 'global'
|
||||||
OR scope_type = 'table'
|
|
||||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
|
|
@ -350,7 +348,7 @@ class NumberingRuleService {
|
||||||
created_at DESC
|
created_at DESC
|
||||||
`;
|
`;
|
||||||
params = [companyCode, siblingObjids];
|
params = [companyCode, siblingObjids];
|
||||||
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
|
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 수주 관리 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 품목 (order_mng_sub)
|
||||||
|
*/
|
||||||
|
export interface OrderItem {
|
||||||
|
sub_objid: string; // 품목 고유 ID (예: ORD-20251121-051_1)
|
||||||
|
part_objid: string; // 품목 코드
|
||||||
|
partner_price: number; // 단가
|
||||||
|
partner_qty: number; // 수량
|
||||||
|
delivery_date: string | null; // 납기일
|
||||||
|
status: string; // 상태
|
||||||
|
regdate: string; // 등록일
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 마스터 (order_mng_master)
|
||||||
|
*/
|
||||||
|
export interface OrderMaster {
|
||||||
|
order_no: string; // 수주 번호 (예: ORD-20251121-051)
|
||||||
|
partner_objid: string; // 거래처 코드
|
||||||
|
final_delivery_date: string | null; // 최종 납품일
|
||||||
|
reason: string | null; // 메모/사유
|
||||||
|
status: string; // 상태
|
||||||
|
reg_date: string; // 등록일
|
||||||
|
writer: string; // 작성자 (userId|companyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 + 품목 (API 응답)
|
||||||
|
*/
|
||||||
|
export interface OrderWithItems extends OrderMaster {
|
||||||
|
items: OrderItem[]; // 품목 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 등록 요청
|
||||||
|
*/
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
inputMode: string; // 입력 방식
|
||||||
|
salesType?: string; // 판매 유형 (국내/해외)
|
||||||
|
priceType?: string; // 단가 방식
|
||||||
|
customerCode: string; // 거래처 코드
|
||||||
|
contactPerson?: string; // 담당자
|
||||||
|
deliveryDestination?: string; // 납품처
|
||||||
|
deliveryAddress?: string; // 납품장소
|
||||||
|
deliveryDate?: string; // 납품일
|
||||||
|
items: Array<{
|
||||||
|
item_code?: string; // 품목 코드
|
||||||
|
id?: string; // 품목 ID (item_code 대체)
|
||||||
|
quantity?: number; // 수량
|
||||||
|
unit_price?: number; // 단가
|
||||||
|
selling_price?: number; // 판매가
|
||||||
|
amount?: number; // 금액
|
||||||
|
delivery_date?: string; // 품목별 납기일
|
||||||
|
}>;
|
||||||
|
memo?: string; // 메모
|
||||||
|
tradeInfo?: {
|
||||||
|
// 해외 판매 시
|
||||||
|
incoterms?: string;
|
||||||
|
paymentTerms?: string;
|
||||||
|
currency?: string;
|
||||||
|
portOfLoading?: string;
|
||||||
|
portOfDischarge?: string;
|
||||||
|
hsCode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수주 등록 응답
|
||||||
|
*/
|
||||||
|
export interface CreateOrderResponse {
|
||||||
|
orderNo: string; // 생성된 수주 번호
|
||||||
|
masterObjid: string; // 마스터 ID
|
||||||
|
itemCount: number; // 품목 개수
|
||||||
|
totalAmount: number; // 전체 금액
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
# 마이그레이션 1003: source_menu_objid 추가
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다.
|
||||||
|
|
||||||
|
## 🎯 목적
|
||||||
|
|
||||||
|
### 이전 방식의 문제점
|
||||||
|
- 메뉴 이름으로만 기존 복사본 판단
|
||||||
|
- 같은 이름의 다른 메뉴도 삭제될 위험
|
||||||
|
- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가
|
||||||
|
|
||||||
|
### 개선 후
|
||||||
|
- 원본 메뉴 ID로 정확히 추적
|
||||||
|
- 같은 원본에서 복사된 메뉴만 덮어쓰기
|
||||||
|
- 수동 메뉴와 복사 메뉴 명확히 구분
|
||||||
|
|
||||||
|
## 🗄️ 스키마 변경
|
||||||
|
|
||||||
|
### 추가되는 컬럼
|
||||||
|
```sql
|
||||||
|
ALTER TABLE menu_info
|
||||||
|
ADD COLUMN source_menu_objid BIGINT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인덱스
|
||||||
|
```sql
|
||||||
|
-- 단일 인덱스
|
||||||
|
CREATE INDEX idx_menu_info_source_menu_objid
|
||||||
|
ON menu_info(source_menu_objid);
|
||||||
|
|
||||||
|
-- 복합 인덱스 (회사별 검색 최적화)
|
||||||
|
CREATE INDEX idx_menu_info_source_company
|
||||||
|
ON menu_info(source_menu_objid, company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 데이터 구조
|
||||||
|
|
||||||
|
### 복사된 메뉴의 source_menu_objid 값
|
||||||
|
|
||||||
|
| 메뉴 레벨 | source_menu_objid | 설명 |
|
||||||
|
|-----------|-------------------|------|
|
||||||
|
| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 |
|
||||||
|
| 하위 메뉴 | NULL | 최상위 메뉴만 추적 |
|
||||||
|
| 수동 생성 메뉴 | NULL | 복사가 아님 |
|
||||||
|
|
||||||
|
### 예시
|
||||||
|
|
||||||
|
#### 원본 (COMPANY_7)
|
||||||
|
```
|
||||||
|
- 사용자 (objid: 1762407678882)
|
||||||
|
└─ 영업관리 (objid: 1762421877772)
|
||||||
|
└─ 거래처관리 (objid: 1762421920304)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 복사본 (COMPANY_11)
|
||||||
|
```
|
||||||
|
- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적
|
||||||
|
└─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL)
|
||||||
|
└─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 실행 방법
|
||||||
|
|
||||||
|
### 로컬 PostgreSQL
|
||||||
|
```bash
|
||||||
|
psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker 환경
|
||||||
|
```bash
|
||||||
|
# 백엔드 컨테이너를 통해 실행
|
||||||
|
docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### DBeaver / pgAdmin
|
||||||
|
1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기
|
||||||
|
2. 전체 스크립트 실행
|
||||||
|
|
||||||
|
## ✅ 확인 방법
|
||||||
|
|
||||||
|
### 1. 컬럼 추가 확인
|
||||||
|
```sql
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'menu_info'
|
||||||
|
AND column_name = 'source_menu_objid';
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
column_name | data_type | is_nullable
|
||||||
|
-------------------|-----------|-------------
|
||||||
|
source_menu_objid | bigint | YES
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 인덱스 생성 확인
|
||||||
|
```sql
|
||||||
|
SELECT indexname, indexdef
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'menu_info'
|
||||||
|
AND indexname LIKE '%source%';
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
indexname | indexdef
|
||||||
|
---------------------------------|----------------------------------
|
||||||
|
idx_menu_info_source_menu_objid | CREATE INDEX ...
|
||||||
|
idx_menu_info_source_company | CREATE INDEX ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 기존 데이터 확인
|
||||||
|
```sql
|
||||||
|
-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(source_menu_objid) as with_source
|
||||||
|
FROM menu_info;
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
total | with_source
|
||||||
|
------|-------------
|
||||||
|
114 | 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 롤백 (필요 시)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 인덱스 삭제
|
||||||
|
DROP INDEX IF EXISTS idx_menu_info_source_menu_objid;
|
||||||
|
DROP INDEX IF EXISTS idx_menu_info_source_company;
|
||||||
|
|
||||||
|
-- 컬럼 삭제
|
||||||
|
ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 주의사항
|
||||||
|
|
||||||
|
1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다.
|
||||||
|
2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다.
|
||||||
|
3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다.
|
||||||
|
|
||||||
|
## 🧪 테스트 시나리오
|
||||||
|
|
||||||
|
### 1. 첫 복사 (source_menu_objid 설정)
|
||||||
|
```
|
||||||
|
원본: 사용자 (objid: 1762407678882, COMPANY_7)
|
||||||
|
복사: 사용자 (objid: 1763688215729, COMPANY_11)
|
||||||
|
source_menu_objid: 1762407678882 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 재복사 (정확한 덮어쓰기)
|
||||||
|
```
|
||||||
|
복사 전 조회:
|
||||||
|
SELECT objid FROM menu_info
|
||||||
|
WHERE source_menu_objid = 1762407678882
|
||||||
|
AND company_code = 'COMPANY_11'
|
||||||
|
→ 1763688215729 발견
|
||||||
|
|
||||||
|
동작:
|
||||||
|
1. objid=1763688215729의 메뉴 트리 전체 삭제
|
||||||
|
2. 새로 복사 (source_menu_objid: 1762407678882)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 다른 메뉴는 영향 없음
|
||||||
|
```
|
||||||
|
수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11)
|
||||||
|
source_menu_objid: NULL ✅
|
||||||
|
|
||||||
|
"사용자" 메뉴 재복사 시:
|
||||||
|
→ 관리자 메뉴는 그대로 유지 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 관련 파일
|
||||||
|
|
||||||
|
- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql`
|
||||||
|
- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts`
|
||||||
|
- `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기
|
||||||
|
- `copyMenus()`: 복사 시 source_menu_objid 저장
|
||||||
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
# 마이그레이션 1003 실행 가이드
|
||||||
|
|
||||||
|
## ❌ 현재 에러
|
||||||
|
```
|
||||||
|
column "source_menu_objid" does not exist
|
||||||
|
```
|
||||||
|
|
||||||
|
**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음
|
||||||
|
|
||||||
|
## ✅ 해결 방법
|
||||||
|
|
||||||
|
### 방법 1: psql 직접 실행 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. PostgreSQL 접속 정보 확인
|
||||||
|
# - Host: localhost (또는 실제 DB 호스트)
|
||||||
|
# - Port: 5432 (기본값)
|
||||||
|
# - Database: ilshin
|
||||||
|
# - User: postgres
|
||||||
|
|
||||||
|
# 2. 마이그레이션 실행
|
||||||
|
cd /Users/kimjuseok/ERP-node
|
||||||
|
psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||||
|
|
||||||
|
# 또는 대화형으로
|
||||||
|
psql -h localhost -U postgres -d ilshin
|
||||||
|
# 그 다음 파일 내용 붙여넣기
|
||||||
|
```
|
||||||
|
|
||||||
|
### 방법 2: DBeaver / pgAdmin에서 실행
|
||||||
|
|
||||||
|
1. DBeaver 또는 pgAdmin 실행
|
||||||
|
2. `ilshin` 데이터베이스 연결
|
||||||
|
3. SQL 편집기 열기
|
||||||
|
4. 아래 SQL 복사하여 실행:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- source_menu_objid 컬럼 추가
|
||||||
|
ALTER TABLE menu_info
|
||||||
|
ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
|
||||||
|
|
||||||
|
-- 인덱스 생성 (검색 성능 향상)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid
|
||||||
|
ON menu_info(source_menu_objid);
|
||||||
|
|
||||||
|
-- 복합 인덱스: 회사별 원본 메뉴 검색
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company
|
||||||
|
ON menu_info(source_menu_objid, company_code);
|
||||||
|
|
||||||
|
-- 컬럼 설명 추가
|
||||||
|
COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)';
|
||||||
|
|
||||||
|
-- 확인
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'menu_info'
|
||||||
|
AND column_name = 'source_menu_objid';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 방법 3: Docker를 통한 실행
|
||||||
|
|
||||||
|
Docker Compose 설정 확인 후:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Compose에 DB 서비스가 있는 경우
|
||||||
|
docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql
|
||||||
|
|
||||||
|
# 또는 SQL을 직접 실행
|
||||||
|
docker-compose exec db psql -U postgres -d ilshin -c "
|
||||||
|
ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ 실행 후 확인
|
||||||
|
|
||||||
|
### 1. 컬럼이 추가되었는지 확인
|
||||||
|
```sql
|
||||||
|
SELECT column_name, data_type, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'menu_info'
|
||||||
|
AND column_name = 'source_menu_objid';
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
column_name | data_type | is_nullable
|
||||||
|
-------------------|-----------|-------------
|
||||||
|
source_menu_objid | bigint | YES
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 인덱스 확인
|
||||||
|
```sql
|
||||||
|
SELECT indexname
|
||||||
|
FROM pg_indexes
|
||||||
|
WHERE tablename = 'menu_info'
|
||||||
|
AND indexname LIKE '%source%';
|
||||||
|
```
|
||||||
|
|
||||||
|
**예상 결과**:
|
||||||
|
```
|
||||||
|
indexname
|
||||||
|
---------------------------------
|
||||||
|
idx_menu_info_source_menu_objid
|
||||||
|
idx_menu_info_source_company
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 메뉴 복사 재시도
|
||||||
|
마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요.
|
||||||
|
|
||||||
|
## 🔍 DB 접속 정보 찾기
|
||||||
|
|
||||||
|
### 환경 변수 확인
|
||||||
|
```bash
|
||||||
|
# .env 파일 확인
|
||||||
|
cat backend-node/.env | grep DB
|
||||||
|
|
||||||
|
# Docker Compose 확인
|
||||||
|
cat docker-compose*.yml | grep -A 10 postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### 일반적인 접속 정보
|
||||||
|
- **Host**: localhost 또는 127.0.0.1
|
||||||
|
- **Port**: 5432 (기본값)
|
||||||
|
- **Database**: ilshin
|
||||||
|
- **User**: postgres
|
||||||
|
- **Password**: (환경 설정 파일에서 확인)
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장
|
||||||
|
2. **권한 확인**: ALTER TABLE 권한이 필요합니다
|
||||||
|
3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다
|
||||||
|
|
||||||
|
## 📞 문제 해결
|
||||||
|
|
||||||
|
### "permission denied" 에러
|
||||||
|
→ postgres 사용자 또는 superuser 권한으로 실행 필요
|
||||||
|
|
||||||
|
### "relation does not exist" 에러
|
||||||
|
→ 올바른 데이터베이스(ilshin)에 접속했는지 확인
|
||||||
|
|
||||||
|
### "already exists" 에러
|
||||||
|
→ 이미 실행됨. 무시하고 진행 가능
|
||||||
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
# COMPANY_11 테스트 데이터 정리 가이드
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다.
|
||||||
|
|
||||||
|
## ⚠️ 중요 사항
|
||||||
|
|
||||||
|
- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`)
|
||||||
|
- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드
|
||||||
|
- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능
|
||||||
|
- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT
|
||||||
|
|
||||||
|
## 🚀 실행 방법
|
||||||
|
|
||||||
|
### 방법 1: Docker 컨테이너에서 직접 실행 (권장)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨)
|
||||||
|
cd /Users/kimjuseok/ERP-node
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql
|
||||||
|
|
||||||
|
# 2. 실제 삭제 실행
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 방법 2: DBeaver 또는 pgAdmin에서 실행
|
||||||
|
|
||||||
|
1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기
|
||||||
|
2. 전체 스크립트 실행 (롤백되어 안전)
|
||||||
|
3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행
|
||||||
|
|
||||||
|
### 방법 3: psql 직접 접속
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 컨테이너 접속
|
||||||
|
docker exec -it erp-node-db-1 psql -U postgres -d ilshin
|
||||||
|
|
||||||
|
# 2. SQL 복사 붙여넣기
|
||||||
|
# (cleanup_company_11_execute.sql 내용 복사)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 삭제 대상
|
||||||
|
|
||||||
|
| 항목 | 테이블명 | 삭제 여부 |
|
||||||
|
|------|----------|-----------|
|
||||||
|
| 메뉴 | `menu_info` | ✅ 삭제 |
|
||||||
|
| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 |
|
||||||
|
| 화면 정의 | `screen_definitions` | ✅ 삭제 |
|
||||||
|
| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 |
|
||||||
|
| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 |
|
||||||
|
| 플로우 정의 | `flow_definition` | ✅ 삭제 |
|
||||||
|
| 플로우 스텝 | `flow_step` | ✅ 삭제 |
|
||||||
|
| 플로우 연결 | `flow_step_connection` | ✅ 삭제 |
|
||||||
|
| 코드 카테고리 | `code_category` | ✅ 삭제 |
|
||||||
|
| 코드 정보 | `code_info` | ✅ 삭제 |
|
||||||
|
| **권한 그룹** | `authority_master` | ❌ **보존** |
|
||||||
|
| **권한 멤버** | `authority_sub_user` | ❌ **보존** |
|
||||||
|
| **사용자** | `user_info` | ❌ **보존** |
|
||||||
|
|
||||||
|
## 🔍 삭제 순서 (외래키 제약 고려)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. screen_layouts (화면 레이아웃)
|
||||||
|
2. screen_menu_assignments (화면-메뉴 할당)
|
||||||
|
3. screen_definitions (화면 정의)
|
||||||
|
4. rel_menu_auth (메뉴 권한)
|
||||||
|
5. menu_info (메뉴)
|
||||||
|
6. flow_step (플로우 스텝)
|
||||||
|
7. flow_step_connection (플로우 연결)
|
||||||
|
8. flow_definition (플로우 정의)
|
||||||
|
9. code_info (코드 정보)
|
||||||
|
10. code_category (코드 카테고리)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ 실행 후 확인
|
||||||
|
|
||||||
|
스크립트 실행 후 다음과 같이 표시됩니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✅ 삭제 완료!
|
||||||
|
|
||||||
|
남은 데이터:
|
||||||
|
- 메뉴: 0 개
|
||||||
|
- 화면: 0 개
|
||||||
|
- 권한 그룹: 1 개 (보존됨)
|
||||||
|
- 사용자: 1 개 (보존됨)
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
✨ 정리 완료! 메뉴 복사 테스트 준비됨
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 테스트 시나리오
|
||||||
|
|
||||||
|
1. **데이터 정리**
|
||||||
|
```bash
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **메뉴 복사 실행**
|
||||||
|
- 프론트엔드에서 원본 메뉴 선택
|
||||||
|
- "복사" 버튼 클릭
|
||||||
|
- 대상 회사: COMPANY_11 선택
|
||||||
|
- 복사 실행
|
||||||
|
|
||||||
|
3. **복사 결과 확인**
|
||||||
|
- COMPANY_11 사용자(copy)로 로그인
|
||||||
|
- 사용자 메뉴에 복사된 메뉴 표시 확인
|
||||||
|
- 버튼 클릭 시 모달 화면 정상 열림 확인
|
||||||
|
- 플로우 기능 정상 작동 확인
|
||||||
|
|
||||||
|
## 🔄 재테스트
|
||||||
|
|
||||||
|
재테스트가 필요하면 다시 정리 스크립트를 실행하세요:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 빠른 재테스트
|
||||||
|
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 참고
|
||||||
|
|
||||||
|
- **백업**: 중요한 데이터가 있다면 먼저 백업하세요
|
||||||
|
- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다
|
||||||
|
- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,344 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { menuApi, MenuCopyResult } from "@/lib/api/menu";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface MenuCopyDialogProps {
|
||||||
|
menuObjid: number | null;
|
||||||
|
menuName: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCopyComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Company {
|
||||||
|
company_code: string;
|
||||||
|
company_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuCopyDialog({
|
||||||
|
menuObjid,
|
||||||
|
menuName,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onCopyComplete,
|
||||||
|
}: MenuCopyDialogProps) {
|
||||||
|
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||||
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
const [copying, setCopying] = useState(false);
|
||||||
|
const [result, setResult] = useState<MenuCopyResult | null>(null);
|
||||||
|
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||||
|
|
||||||
|
// 화면명 일괄 변경 설정
|
||||||
|
const [useBulkRename, setUseBulkRename] = useState(false);
|
||||||
|
const [removeText, setRemoveText] = useState("");
|
||||||
|
const [addPrefix, setAddPrefix] = useState("");
|
||||||
|
|
||||||
|
// 회사 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadCompanies();
|
||||||
|
// 다이얼로그가 열릴 때마다 초기화
|
||||||
|
setTargetCompanyCode("");
|
||||||
|
setResult(null);
|
||||||
|
setUseBulkRename(false);
|
||||||
|
setRemoveText("");
|
||||||
|
setAddPrefix("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const loadCompanies = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingCompanies(true);
|
||||||
|
const response = await apiClient.get("/admin/companies/db");
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
// 최고 관리자(*) 회사 제외
|
||||||
|
const filteredCompanies = response.data.data.filter(
|
||||||
|
(company: Company) => company.company_code !== "*"
|
||||||
|
);
|
||||||
|
setCompanies(filteredCompanies);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("회사 목록 조회 실패:", error);
|
||||||
|
toast.error("회사 목록을 불러올 수 없습니다");
|
||||||
|
} finally {
|
||||||
|
setLoadingCompanies(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!menuObjid) {
|
||||||
|
toast.error("메뉴를 선택해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetCompanyCode) {
|
||||||
|
toast.error("대상 회사를 선택해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopying(true);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 화면명 변환 설정 (사용 중일 때만 전달)
|
||||||
|
const screenNameConfig =
|
||||||
|
useBulkRename && (removeText.trim() || addPrefix.trim())
|
||||||
|
? {
|
||||||
|
removeText: removeText.trim() || undefined,
|
||||||
|
addPrefix: addPrefix.trim() || undefined,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const response = await menuApi.copyMenu(
|
||||||
|
menuObjid,
|
||||||
|
targetCompanyCode,
|
||||||
|
screenNameConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setResult(response.data);
|
||||||
|
toast.success("메뉴 복사 완료!");
|
||||||
|
|
||||||
|
// 경고 메시지 표시
|
||||||
|
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||||
|
response.data.warnings.forEach((warning) => {
|
||||||
|
toast.warning(warning);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 복사 완료 콜백
|
||||||
|
if (onCopyComplete) {
|
||||||
|
onCopyComplete();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "메뉴 복사 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("메뉴 복사 오류:", error);
|
||||||
|
toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다");
|
||||||
|
} finally {
|
||||||
|
setCopying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!copying) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
메뉴 복사
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
"{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 회사 선택 */}
|
||||||
|
{!result && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="company" className="text-xs sm:text-sm">
|
||||||
|
대상 회사 *
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={targetCompanyCode}
|
||||||
|
onValueChange={setTargetCompanyCode}
|
||||||
|
disabled={copying || loadingCompanies}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="company"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="회사 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{loadingCompanies ? (
|
||||||
|
<div className="flex items-center justify-center p-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
로딩 중...
|
||||||
|
</div>
|
||||||
|
) : companies.length === 0 ? (
|
||||||
|
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||||
|
사용 가능한 회사가 없습니다
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
companies.map((company) => (
|
||||||
|
<SelectItem
|
||||||
|
key={company.company_code}
|
||||||
|
value={company.company_code}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{company.company_name} ({company.company_code})
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면명 일괄 변경 설정 */}
|
||||||
|
{!result && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="useBulkRename"
|
||||||
|
checked={useBulkRename}
|
||||||
|
onCheckedChange={(checked) => setUseBulkRename(checked as boolean)}
|
||||||
|
disabled={copying}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="useBulkRename"
|
||||||
|
className="text-xs sm:text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
화면명 일괄 변경 사용
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useBulkRename && (
|
||||||
|
<div className="space-y-3 pl-6 border-l-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="removeText" className="text-xs sm:text-sm">
|
||||||
|
제거할 텍스트
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="removeText"
|
||||||
|
value={removeText}
|
||||||
|
onChange={(e) => setRemoveText(e.target.value)}
|
||||||
|
placeholder="예: 탑씰"
|
||||||
|
disabled={copying}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
화면명에서 이 텍스트를 제거합니다 (예: "탑씰 회사정보" → "회사정보")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="addPrefix" className="text-xs sm:text-sm">
|
||||||
|
추가할 접두사
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="addPrefix"
|
||||||
|
value={addPrefix}
|
||||||
|
onChange={(e) => setAddPrefix(e.target.value)}
|
||||||
|
placeholder="예: 한신"
|
||||||
|
disabled={copying}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
화면명 앞에 이 텍스트를 추가합니다 (예: "회사정보" → "한신 회사정보")
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 복사 항목 안내 */}
|
||||||
|
{!result && (
|
||||||
|
<div className="rounded-md border p-3 text-xs">
|
||||||
|
<p className="font-medium mb-2">복사되는 항목:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
|
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||||
|
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||||
|
<li>플로우 제어 (스텝, 연결)</li>
|
||||||
|
<li>코드 카테고리 + 코드</li>
|
||||||
|
<li>카테고리 설정 + 채번 규칙</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-2 text-warning">
|
||||||
|
⚠️ 실제 데이터는 복사되지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 복사 결과 */}
|
||||||
|
{result && (
|
||||||
|
<div className="rounded-md border border-success bg-success/10 p-3 text-xs space-y-2">
|
||||||
|
<p className="font-medium text-success">✅ 복사 완료!</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">메뉴:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedMenus}개</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">화면:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedScreens}개</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedFlows}개</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedCategories}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-muted-foreground">코드:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedCodes}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={copying}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{result ? "닫기" : "취소"}
|
||||||
|
</Button>
|
||||||
|
{!result && (
|
||||||
|
<Button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={copying || !targetCompanyCode || loadingCompanies}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{copying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
복사 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"복사 시작"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu";
|
||||||
import type { MenuItem } from "@/lib/api/menu";
|
import type { MenuItem } from "@/lib/api/menu";
|
||||||
import { MenuTable } from "./MenuTable";
|
import { MenuTable } from "./MenuTable";
|
||||||
import { MenuFormModal } from "./MenuFormModal";
|
import { MenuFormModal } from "./MenuFormModal";
|
||||||
|
import { MenuCopyDialog } from "./MenuCopyDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext";
|
||||||
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
|
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
|
||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
|
||||||
|
|
||||||
type MenuType = "admin" | "user";
|
type MenuType = "admin" | "user";
|
||||||
|
|
||||||
export const MenuManagement: React.FC = () => {
|
export const MenuManagement: React.FC = () => {
|
||||||
const { adminMenus, userMenus, refreshMenus } = useMenu();
|
const { adminMenus, userMenus, refreshMenus } = useMenu();
|
||||||
|
const { user } = useAuth(); // 현재 사용자 정보 가져오기
|
||||||
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
|
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [formModalOpen, setFormModalOpen] = useState(false);
|
const [formModalOpen, setFormModalOpen] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||||
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
||||||
|
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
|
||||||
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
|
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
|
||||||
|
|
@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => {
|
||||||
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
|
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
|
||||||
const { userLang } = useMultiLang({ companyCode: "*" });
|
const { userLang } = useMultiLang({ companyCode: "*" });
|
||||||
|
|
||||||
|
// SUPER_ADMIN 여부 확인
|
||||||
|
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
|
||||||
|
|
||||||
// 다국어 텍스트 상태
|
// 다국어 텍스트 상태
|
||||||
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||||
const [uiTextsLoading, setUiTextsLoading] = useState(false);
|
const [uiTextsLoading, setUiTextsLoading] = useState(false);
|
||||||
|
|
@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopyMenu = (menuId: string, menuName: string) => {
|
||||||
|
setSelectedMenuId(menuId);
|
||||||
|
setSelectedMenuName(menuName);
|
||||||
|
setCopyDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyComplete = async () => {
|
||||||
|
// 복사 완료 후 메뉴 목록 새로고침
|
||||||
|
await loadMenus(false);
|
||||||
|
toast.success("메뉴 복사가 완료되었습니다");
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleStatus = async (menuId: string) => {
|
const handleToggleStatus = async (menuId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await menuApi.toggleMenuStatus(menuId);
|
const response = await menuApi.toggleMenuStatus(menuId);
|
||||||
|
|
@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
title=""
|
title=""
|
||||||
onAddMenu={handleAddMenu}
|
onAddMenu={handleAddMenu}
|
||||||
onEditMenu={handleEditMenu}
|
onEditMenu={handleEditMenu}
|
||||||
|
onCopyMenu={handleCopyMenu}
|
||||||
onToggleStatus={handleToggleStatus}
|
onToggleStatus={handleToggleStatus}
|
||||||
selectedMenus={selectedMenus}
|
selectedMenus={selectedMenus}
|
||||||
onMenuSelectionChange={handleMenuSelectionChange}
|
onMenuSelectionChange={handleMenuSelectionChange}
|
||||||
|
|
@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => {
|
||||||
expandedMenus={expandedMenus}
|
expandedMenus={expandedMenus}
|
||||||
onToggleExpand={handleToggleExpand}
|
onToggleExpand={handleToggleExpand}
|
||||||
uiTexts={uiTexts}
|
uiTexts={uiTexts}
|
||||||
|
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<MenuCopyDialog
|
||||||
|
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
|
||||||
|
menuName={selectedMenuName}
|
||||||
|
open={copyDialogOpen}
|
||||||
|
onOpenChange={setCopyDialogOpen}
|
||||||
|
onCopyComplete={handleCopyComplete}
|
||||||
|
/>
|
||||||
</LoadingOverlay>
|
</LoadingOverlay>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface MenuTableProps {
|
||||||
title: string;
|
title: string;
|
||||||
onAddMenu: (parentId: string, menuType: string, level: number) => void;
|
onAddMenu: (parentId: string, menuType: string, level: number) => void;
|
||||||
onEditMenu: (menuId: string) => void;
|
onEditMenu: (menuId: string) => void;
|
||||||
|
onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가
|
||||||
onToggleStatus: (menuId: string) => void;
|
onToggleStatus: (menuId: string) => void;
|
||||||
selectedMenus: Set<string>;
|
selectedMenus: Set<string>;
|
||||||
onMenuSelectionChange: (menuId: string, checked: boolean) => void;
|
onMenuSelectionChange: (menuId: string, checked: boolean) => void;
|
||||||
|
|
@ -22,6 +23,7 @@ interface MenuTableProps {
|
||||||
onToggleExpand: (menuId: string) => void;
|
onToggleExpand: (menuId: string) => void;
|
||||||
// 다국어 텍스트 props 추가
|
// 다국어 텍스트 props 추가
|
||||||
uiTexts: Record<string, string>;
|
uiTexts: Record<string, string>;
|
||||||
|
isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MenuTable: React.FC<MenuTableProps> = ({
|
export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
|
|
@ -29,6 +31,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
title,
|
title,
|
||||||
onAddMenu,
|
onAddMenu,
|
||||||
onEditMenu,
|
onEditMenu,
|
||||||
|
onCopyMenu,
|
||||||
onToggleStatus,
|
onToggleStatus,
|
||||||
selectedMenus,
|
selectedMenus,
|
||||||
onMenuSelectionChange,
|
onMenuSelectionChange,
|
||||||
|
|
@ -36,6 +39,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
expandedMenus,
|
expandedMenus,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
uiTexts,
|
uiTexts,
|
||||||
|
isSuperAdmin = false, // 기본값 false
|
||||||
}) => {
|
}) => {
|
||||||
// 다국어 텍스트 가져오기 함수
|
// 다국어 텍스트 가져오기 함수
|
||||||
const getText = (key: string, fallback?: string): string => {
|
const getText = (key: string, fallback?: string): string => {
|
||||||
|
|
@ -281,14 +285,26 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
<TableCell className="h-16">
|
<TableCell className="h-16">
|
||||||
<div className="flex flex-nowrap gap-1">
|
<div className="flex flex-nowrap gap-1">
|
||||||
{lev === 1 && (
|
{lev === 1 && (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
className="min-w-[40px] px-1 py-1 text-xs"
|
variant="outline"
|
||||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
>
|
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
>
|
||||||
</Button>
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
||||||
|
</Button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
|
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{lev === 2 && (
|
{lev === 2 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -308,17 +324,39 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
||||||
>
|
>
|
||||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
|
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{lev > 2 && (
|
{lev > 2 && (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
className="min-w-[40px] px-1 py-1 text-xs"
|
variant="outline"
|
||||||
onClick={() => onEditMenu(objid)}
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
>
|
onClick={() => onEditMenu(objid)}
|
||||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
>
|
||||||
</Button>
|
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||||
|
</Button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="min-w-[40px] px-1 py-1 text-xs"
|
||||||
|
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||||
|
>
|
||||||
|
복사
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
export const INPUT_MODE = {
|
||||||
|
CUSTOMER_FIRST: "customer_first",
|
||||||
|
QUOTATION: "quotation",
|
||||||
|
UNIT_PRICE: "unit_price",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
|
||||||
|
|
||||||
|
export const SALES_TYPE = {
|
||||||
|
DOMESTIC: "domestic",
|
||||||
|
EXPORT: "export",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
|
||||||
|
|
||||||
|
export const PRICE_TYPE = {
|
||||||
|
STANDARD: "standard",
|
||||||
|
CUSTOMER: "customer",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];
|
||||||
|
|
@ -337,12 +337,22 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData}
|
originalData={originalData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
setFormData((prev) => ({
|
console.log("📝 SaveModal - formData 변경:", {
|
||||||
...prev,
|
fieldName,
|
||||||
[fieldName]: value,
|
value,
|
||||||
}));
|
componentType: component.type,
|
||||||
|
componentId: component.id,
|
||||||
|
});
|
||||||
|
setFormData((prev) => {
|
||||||
|
const newData = {
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
};
|
||||||
|
console.log("📦 새 formData:", newData);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
mode={initialData ? "edit" : "create"}
|
mode="edit"
|
||||||
isInModal={true}
|
isInModal={true}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,25 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
const [localHeight, setLocalHeight] = useState<string>("");
|
const [localHeight, setLocalHeight] = useState<string>("");
|
||||||
const [localWidth, setLocalWidth] = useState<string>("");
|
const [localWidth, setLocalWidth] = useState<string>("");
|
||||||
|
|
||||||
|
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
|
||||||
|
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||||
|
|
||||||
|
// 🆕 전체 테이블 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllTables = async () => {
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("전체 테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAllTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedComponent?.type === "component") {
|
if (selectedComponent?.type === "component") {
|
||||||
|
|
@ -279,14 +298,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfigChange = (newConfig: any) => {
|
const handleConfigChange = (newConfig: any) => {
|
||||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
// 기존 config와 병합하여 다른 속성 유지
|
||||||
|
const currentConfig = selectedComponent.componentConfig?.config || {};
|
||||||
|
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||||
|
onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||||
const componentId =
|
const componentId =
|
||||||
selectedComponent.componentType || // ⭐ section-card 등
|
selectedComponent.componentType || // ⭐ section-card 등
|
||||||
selectedComponent.componentConfig?.type ||
|
selectedComponent.componentConfig?.type ||
|
||||||
selectedComponent.componentConfig?.id;
|
selectedComponent.componentConfig?.id ||
|
||||||
|
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||||
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
const definition = ComponentRegistry.getComponent(componentId);
|
const definition = ComponentRegistry.getComponent(componentId);
|
||||||
|
|
@ -318,7 +341,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<Settings className="h-4 w-4 text-primary" />
|
<Settings className="h-4 w-4 text-primary" />
|
||||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||||
</div>
|
</div>
|
||||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
<ConfigPanelComponent
|
||||||
|
config={config}
|
||||||
|
onChange={handleConfigChange}
|
||||||
|
tables={tables} // 테이블 정보 전달
|
||||||
|
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||||
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||||
|
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -994,6 +1024,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
|
||||||
|
const definition = ComponentRegistry.getComponent(componentId);
|
||||||
|
if (definition?.configPanel) {
|
||||||
|
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
|
||||||
|
const configPanelContent = renderComponentConfigPanel();
|
||||||
|
if (configPanelContent) {
|
||||||
|
return configPanelContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 웹타입의 기본 입력 타입 추출
|
// 현재 웹타입의 기본 입력 타입 추출
|
||||||
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ interface Props {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
||||||
|
screenId?: number; // 화면 ID 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필터 타입별 연산자
|
// 필터 타입별 연산자
|
||||||
|
|
@ -69,7 +70,7 @@ interface ColumnFilterConfig {
|
||||||
selectOptions?: Array<{ label: string; value: string }>;
|
selectOptions?: Array<{ label: string; value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
|
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
|
||||||
const { getTable, selectedTableId } = useTableOptions();
|
const { getTable, selectedTableId } = useTableOptions();
|
||||||
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||||
|
|
||||||
|
|
@ -79,7 +80,10 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
// localStorage에서 저장된 필터 설정 불러오기
|
// localStorage에서 저장된 필터 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (table?.columns && table?.tableName) {
|
if (table?.columns && table?.tableName) {
|
||||||
const storageKey = `table_filters_${table.tableName}`;
|
// 화면별로 독립적인 필터 설정 저장
|
||||||
|
const storageKey = screenId
|
||||||
|
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||||
|
: `table_filters_${table.tableName}`;
|
||||||
const savedFilters = localStorage.getItem(storageKey);
|
const savedFilters = localStorage.getItem(storageKey);
|
||||||
|
|
||||||
let filters: ColumnFilterConfig[];
|
let filters: ColumnFilterConfig[];
|
||||||
|
|
@ -192,9 +196,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// localStorage에 저장
|
// localStorage에 저장 (화면별로 독립적)
|
||||||
if (table?.tableName) {
|
if (table?.tableName) {
|
||||||
const storageKey = `table_filters_${table.tableName}`;
|
const storageKey = screenId
|
||||||
|
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||||
|
: `table_filters_${table.tableName}`;
|
||||||
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,9 +222,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
||||||
setColumnFilters(clearedFilters);
|
setColumnFilters(clearedFilters);
|
||||||
setSelectAll(false);
|
setSelectAll(false);
|
||||||
|
|
||||||
// localStorage에서 제거
|
// localStorage에서 제거 (화면별로 독립적)
|
||||||
if (table?.tableName) {
|
if (table?.tableName) {
|
||||||
const storageKey = `table_filters_${table.tableName}`;
|
const storageKey = screenId
|
||||||
|
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||||
|
: `table_filters_${table.tableName}`;
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,4 +162,47 @@ export const menuApi = {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 메뉴 복사
|
||||||
|
copyMenu: async (
|
||||||
|
menuObjid: number,
|
||||||
|
targetCompanyCode: string,
|
||||||
|
screenNameConfig?: {
|
||||||
|
removeText?: string;
|
||||||
|
addPrefix?: string;
|
||||||
|
}
|
||||||
|
): Promise<ApiResponse<MenuCopyResult>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/admin/menus/${menuObjid}/copy`,
|
||||||
|
{
|
||||||
|
targetCompanyCode,
|
||||||
|
screenNameConfig
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 메뉴 복사 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다",
|
||||||
|
errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메뉴 복사 결과
|
||||||
|
*/
|
||||||
|
export interface MenuCopyResult {
|
||||||
|
copiedMenus: number;
|
||||||
|
copiedScreens: number;
|
||||||
|
copiedFlows: number;
|
||||||
|
copiedCategories: number;
|
||||||
|
copiedCodes: number;
|
||||||
|
menuIdMap: Record<number, number>;
|
||||||
|
screenIdMap: Record<number, number>;
|
||||||
|
flowIdMap: Record<number, number>;
|
||||||
|
warnings?: string[];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
const columnName = (component as any).columnName;
|
const columnName = (component as any).columnName;
|
||||||
|
|
||||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||||
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
|
||||||
|
if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") {
|
||||||
|
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
|
||||||
|
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||||
try {
|
try {
|
||||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||||
const fieldName = columnName || component.id;
|
const fieldName = columnName || component.id;
|
||||||
|
|
@ -213,6 +216,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||||
|
|
||||||
|
// 🔍 디버깅: select-basic 조회 결과 확인
|
||||||
|
if (componentType === "select-basic") {
|
||||||
|
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
|
||||||
|
componentType,
|
||||||
|
found: !!newComponent,
|
||||||
|
componentId: component.id,
|
||||||
|
componentConfig: component.componentConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (newComponent) {
|
if (newComponent) {
|
||||||
// 새 컴포넌트 시스템으로 렌더링
|
// 새 컴포넌트 시스템으로 렌더링
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,23 @@ import { Button } from "@/components/ui/button";
|
||||||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||||
import { EntitySearchResult } from "../entity-search-input/types";
|
import { EntitySearchResult } from "../entity-search-input/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AutocompleteSearchInputConfig, FieldMapping } from "./types";
|
import { AutocompleteSearchInputConfig } from "./types";
|
||||||
|
import { ComponentRendererProps } from "../../DynamicComponentRenderer";
|
||||||
|
|
||||||
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
|
export interface AutocompleteSearchInputProps extends ComponentRendererProps {
|
||||||
config?: AutocompleteSearchInputConfig;
|
config?: AutocompleteSearchInputConfig;
|
||||||
|
tableName?: string;
|
||||||
|
displayField?: string;
|
||||||
|
valueField?: string;
|
||||||
|
searchFields?: string[];
|
||||||
filterCondition?: Record<string, any>;
|
filterCondition?: Record<string, any>;
|
||||||
disabled?: boolean;
|
placeholder?: string;
|
||||||
value?: any;
|
showAdditionalInfo?: boolean;
|
||||||
onChange?: (value: any, fullData?: any) => void;
|
additionalFields?: string[];
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutocompleteSearchInputComponent({
|
export function AutocompleteSearchInputComponent({
|
||||||
|
component,
|
||||||
config,
|
config,
|
||||||
tableName: propTableName,
|
tableName: propTableName,
|
||||||
displayField: propDisplayField,
|
displayField: propDisplayField,
|
||||||
|
|
@ -29,9 +34,10 @@ export function AutocompleteSearchInputComponent({
|
||||||
disabled = false,
|
disabled = false,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
showAdditionalInfo: propShowAdditionalInfo,
|
|
||||||
additionalFields: propAdditionalFields,
|
|
||||||
className,
|
className,
|
||||||
|
isInteractive = false,
|
||||||
|
onFormDataChange,
|
||||||
|
formData,
|
||||||
}: AutocompleteSearchInputProps) {
|
}: AutocompleteSearchInputProps) {
|
||||||
// config prop 우선, 없으면 개별 prop 사용
|
// config prop 우선, 없으면 개별 prop 사용
|
||||||
const tableName = config?.tableName || propTableName || "";
|
const tableName = config?.tableName || propTableName || "";
|
||||||
|
|
@ -39,8 +45,7 @@ export function AutocompleteSearchInputComponent({
|
||||||
const valueField = config?.valueField || propValueField || "";
|
const valueField = config?.valueField || propValueField || "";
|
||||||
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
||||||
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
||||||
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
|
|
||||||
const additionalFields = config?.additionalFields || propAdditionalFields || [];
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||||
|
|
@ -52,15 +57,20 @@ export function AutocompleteSearchInputComponent({
|
||||||
filterCondition,
|
filterCondition,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
||||||
|
const currentValue = isInteractive && formData && component?.columnName
|
||||||
|
? formData[component.columnName]
|
||||||
|
: value;
|
||||||
|
|
||||||
// value가 변경되면 표시값 업데이트
|
// value가 변경되면 표시값 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value && selectedData) {
|
if (currentValue && selectedData) {
|
||||||
setInputValue(selectedData[displayField] || "");
|
setInputValue(selectedData[displayField] || "");
|
||||||
} else if (!value) {
|
} else if (!currentValue) {
|
||||||
setInputValue("");
|
setInputValue("");
|
||||||
setSelectedData(null);
|
setSelectedData(null);
|
||||||
}
|
}
|
||||||
}, [value, displayField]);
|
}, [currentValue, displayField, selectedData]);
|
||||||
|
|
||||||
// 외부 클릭 감지
|
// 외부 클릭 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -81,45 +91,61 @@ export function AutocompleteSearchInputComponent({
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 자동 매핑 처리
|
|
||||||
const applyFieldMappings = (item: EntitySearchResult) => {
|
|
||||||
if (!config?.enableFieldMapping || !config?.fieldMappings) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
config.fieldMappings.forEach((mapping: FieldMapping) => {
|
|
||||||
if (!mapping.sourceField || !mapping.targetField) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = item[mapping.sourceField];
|
|
||||||
|
|
||||||
// DOM에서 타겟 필드 찾기 (id로 검색)
|
|
||||||
const targetElement = document.getElementById(mapping.targetField);
|
|
||||||
|
|
||||||
if (targetElement) {
|
|
||||||
// input, textarea 등의 값 설정
|
|
||||||
if (
|
|
||||||
targetElement instanceof HTMLInputElement ||
|
|
||||||
targetElement instanceof HTMLTextAreaElement
|
|
||||||
) {
|
|
||||||
targetElement.value = value?.toString() || "";
|
|
||||||
|
|
||||||
// React의 change 이벤트 트리거
|
|
||||||
const event = new Event("input", { bubbles: true });
|
|
||||||
targetElement.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (item: EntitySearchResult) => {
|
const handleSelect = (item: EntitySearchResult) => {
|
||||||
setSelectedData(item);
|
setSelectedData(item);
|
||||||
setInputValue(item[displayField] || "");
|
setInputValue(item[displayField] || "");
|
||||||
onChange?.(item[valueField], item);
|
|
||||||
|
|
||||||
// 필드 자동 매핑 실행
|
console.log("🔍 AutocompleteSearchInput handleSelect:", {
|
||||||
applyFieldMappings(item);
|
item,
|
||||||
|
valueField,
|
||||||
|
value: item[valueField],
|
||||||
|
config,
|
||||||
|
isInteractive,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
columnName: component?.columnName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// isInteractive 모드에서만 저장
|
||||||
|
if (isInteractive && onFormDataChange) {
|
||||||
|
// 필드 매핑 처리
|
||||||
|
if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
|
||||||
|
console.log("📋 필드 매핑 처리 시작:", config.fieldMappings);
|
||||||
|
|
||||||
|
config.fieldMappings.forEach((mapping: any, index: number) => {
|
||||||
|
const targetField = mapping.targetField || mapping.targetColumn;
|
||||||
|
|
||||||
|
console.log(` 매핑 ${index + 1}:`, {
|
||||||
|
sourceField: mapping.sourceField,
|
||||||
|
targetField,
|
||||||
|
label: mapping.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mapping.sourceField && targetField) {
|
||||||
|
const sourceValue = item[mapping.sourceField];
|
||||||
|
|
||||||
|
console.log(` 값: ${mapping.sourceField} = ${sourceValue}`);
|
||||||
|
|
||||||
|
if (sourceValue !== undefined) {
|
||||||
|
console.log(` ✅ 저장: ${targetField} = ${sourceValue}`);
|
||||||
|
onFormDataChange(targetField, sourceValue);
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ sourceField "${mapping.sourceField}"의 값이 undefined입니다`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ 매핑 불완전: sourceField=${mapping.sourceField}, targetField=${targetField}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 필드 저장 (columnName이 설정된 경우)
|
||||||
|
if (component?.columnName) {
|
||||||
|
console.log(`💾 기본 필드 저장: ${component.columnName} = ${item[valueField]}`);
|
||||||
|
onFormDataChange(component.columnName, item[valueField]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onChange 콜백 호출 (호환성)
|
||||||
|
onChange?.(item[valueField], item);
|
||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
@ -149,9 +175,9 @@ export function AutocompleteSearchInputComponent({
|
||||||
onFocus={handleInputFocus}
|
onFocus={handleInputFocus}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
|
className="h-8 pr-16 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
<div className="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||||
{loading && (
|
{loading && (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({
|
||||||
|
|
||||||
{/* 드롭다운 결과 */}
|
{/* 드롭다운 결과 */}
|
||||||
{isOpen && (results.length > 0 || loading) && (
|
{isOpen && (results.length > 0 || loading) && (
|
||||||
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
|
<div className="absolute z-50 mt-1 max-h-[300px] w-full overflow-y-auto rounded-md border bg-background shadow-lg">
|
||||||
{loading && results.length === 0 ? (
|
{loading && results.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
|
||||||
검색 중...
|
검색 중...
|
||||||
</div>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
|
|
@ -189,37 +215,15 @@ export function AutocompleteSearchInputComponent({
|
||||||
key={index}
|
key={index}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(item)}
|
onClick={() => handleSelect(item)}
|
||||||
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
|
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
|
||||||
>
|
>
|
||||||
<div className="font-medium">{item[displayField]}</div>
|
<div className="font-medium">{item[displayField]}</div>
|
||||||
{additionalFields.length > 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
|
||||||
{additionalFields.map((field) => (
|
|
||||||
<div key={field}>
|
|
||||||
{field}: {item[field] || "-"}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 추가 정보 표시 */}
|
|
||||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
|
||||||
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
|
|
||||||
{additionalFields.map((field) => (
|
|
||||||
<div key={field} className="flex gap-2">
|
|
||||||
<span className="font-medium">{field}:</span>
|
|
||||||
<span>{selectedData[field] || "-"}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { AutocompleteSearchInputConfig, FieldMapping, ValueFieldStorage } from "./types";
|
import { AutocompleteSearchInputConfig } from "./types";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -24,83 +23,14 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
}: AutocompleteSearchInputConfigPanelProps) {
|
}: AutocompleteSearchInputConfigPanelProps) {
|
||||||
const [localConfig, setLocalConfig] = useState(config);
|
const [localConfig, setLocalConfig] = useState(config);
|
||||||
const [allTables, setAllTables] = useState<any[]>([]);
|
const [allTables, setAllTables] = useState<any[]>([]);
|
||||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
|
||||||
|
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
|
||||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
const [isLoadingSourceColumns, setIsLoadingSourceColumns] = useState(false);
|
||||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
const [isLoadingTargetColumns, setIsLoadingTargetColumns] = useState(false);
|
||||||
|
const [openSourceTableCombo, setOpenSourceTableCombo] = useState(false);
|
||||||
|
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
||||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||||
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
|
||||||
const [openStorageTableCombo, setOpenStorageTableCombo] = useState(false);
|
|
||||||
const [openStorageColumnCombo, setOpenStorageColumnCombo] = useState(false);
|
|
||||||
const [storageTableColumns, setStorageTableColumns] = useState<any[]>([]);
|
|
||||||
const [isLoadingStorageColumns, setIsLoadingStorageColumns] = useState(false);
|
|
||||||
|
|
||||||
// 전체 테이블 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadTables = async () => {
|
|
||||||
setIsLoadingTables(true);
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getTableList();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setAllTables(response.data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("테이블 목록 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTables(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadTables();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 선택된 테이블의 컬럼 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadColumns = async () => {
|
|
||||||
if (!localConfig.tableName) {
|
|
||||||
setTableColumns([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingColumns(true);
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setTableColumns(response.data.columns);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("컬럼 목록 로드 실패:", error);
|
|
||||||
setTableColumns([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingColumns(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadColumns();
|
|
||||||
}, [localConfig.tableName]);
|
|
||||||
|
|
||||||
// 저장 대상 테이블의 컬럼 목록 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadStorageColumns = async () => {
|
|
||||||
const storageTable = localConfig.valueFieldStorage?.targetTable;
|
|
||||||
if (!storageTable) {
|
|
||||||
setStorageTableColumns([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoadingStorageColumns(true);
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getColumnList(storageTable);
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setStorageTableColumns(response.data.columns);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("저장 테이블 컬럼 로드 실패:", error);
|
|
||||||
setStorageTableColumns([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingStorageColumns(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadStorageColumns();
|
|
||||||
}, [localConfig.valueFieldStorage?.targetTable]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalConfig(config);
|
setLocalConfig(config);
|
||||||
|
|
@ -112,52 +42,76 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSearchField = () => {
|
// 테이블 목록 로드
|
||||||
const fields = localConfig.searchFields || [];
|
useEffect(() => {
|
||||||
updateConfig({ searchFields: [...fields, ""] });
|
const loadTables = async () => {
|
||||||
};
|
setIsLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setAllTables([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTables(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateSearchField = (index: number, value: string) => {
|
// 외부 테이블 컬럼 로드
|
||||||
const fields = [...(localConfig.searchFields || [])];
|
useEffect(() => {
|
||||||
fields[index] = value;
|
const loadColumns = async () => {
|
||||||
updateConfig({ searchFields: fields });
|
if (!localConfig.tableName) {
|
||||||
};
|
setSourceTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingSourceColumns(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setSourceTableColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSourceTableColumns([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSourceColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [localConfig.tableName]);
|
||||||
|
|
||||||
const removeSearchField = (index: number) => {
|
// 저장 테이블 컬럼 로드
|
||||||
const fields = [...(localConfig.searchFields || [])];
|
useEffect(() => {
|
||||||
fields.splice(index, 1);
|
const loadTargetColumns = async () => {
|
||||||
updateConfig({ searchFields: fields });
|
if (!localConfig.targetTable) {
|
||||||
};
|
setTargetTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingTargetColumns(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getColumnList(localConfig.targetTable);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setTargetTableColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setTargetTableColumns([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTargetColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTargetColumns();
|
||||||
|
}, [localConfig.targetTable]);
|
||||||
|
|
||||||
const addAdditionalField = () => {
|
|
||||||
const fields = localConfig.additionalFields || [];
|
|
||||||
updateConfig({ additionalFields: [...fields, ""] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateAdditionalField = (index: number, value: string) => {
|
|
||||||
const fields = [...(localConfig.additionalFields || [])];
|
|
||||||
fields[index] = value;
|
|
||||||
updateConfig({ additionalFields: fields });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAdditionalField = (index: number) => {
|
|
||||||
const fields = [...(localConfig.additionalFields || [])];
|
|
||||||
fields.splice(index, 1);
|
|
||||||
updateConfig({ additionalFields: fields });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 매핑 관리 함수
|
|
||||||
const addFieldMapping = () => {
|
const addFieldMapping = () => {
|
||||||
const mappings = localConfig.fieldMappings || [];
|
const mappings = localConfig.fieldMappings || [];
|
||||||
updateConfig({
|
updateConfig({
|
||||||
fieldMappings: [
|
fieldMappings: [...mappings, { sourceField: "", targetField: "", label: "" }],
|
||||||
...mappings,
|
|
||||||
{ sourceField: "", targetField: "", label: "" },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateFieldMapping = (index: number, updates: Partial<FieldMapping>) => {
|
const updateFieldMapping = (index: number, updates: any) => {
|
||||||
const mappings = [...(localConfig.fieldMappings || [])];
|
const mappings = [...(localConfig.fieldMappings || [])];
|
||||||
mappings[index] = { ...mappings[index], ...updates };
|
mappings[index] = { ...mappings[index], ...updates };
|
||||||
updateConfig({ fieldMappings: mappings });
|
updateConfig({ fieldMappings: mappings });
|
||||||
|
|
@ -170,21 +124,22 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-6 p-4">
|
||||||
|
{/* 1. 외부 테이블 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
<Label className="text-xs font-semibold sm:text-sm">1. 외부 테이블 선택 *</Label>
|
||||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
<Popover open={openSourceTableCombo} onOpenChange={setOpenSourceTableCombo}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={openTableCombo}
|
aria-expanded={openSourceTableCombo}
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
disabled={isLoadingTables}
|
disabled={isLoadingTables}
|
||||||
>
|
>
|
||||||
{localConfig.tableName
|
{localConfig.tableName
|
||||||
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
|
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
|
||||||
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
|
: isLoadingTables ? "로딩 중..." : "데이터를 가져올 테이블 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -200,7 +155,7 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
value={table.tableName}
|
value={table.tableName}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateConfig({ tableName: table.tableName });
|
updateConfig({ tableName: table.tableName });
|
||||||
setOpenTableCombo(false);
|
setOpenSourceTableCombo(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
|
|
@ -216,13 +171,11 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
검색할 데이터가 저장된 테이블을 선택하세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 표시 필드 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">표시 필드 *</Label>
|
<Label className="text-xs font-semibold sm:text-sm">2. 표시 필드 *</Label>
|
||||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -230,11 +183,11 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={openDisplayFieldCombo}
|
aria-expanded={openDisplayFieldCombo}
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
disabled={!localConfig.tableName || isLoadingColumns}
|
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||||
>
|
>
|
||||||
{localConfig.displayField
|
{localConfig.displayField
|
||||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
: isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -244,7 +197,7 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{tableColumns.map((column) => (
|
{sourceTableColumns.map((column) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
value={column.columnName}
|
value={column.columnName}
|
||||||
|
|
@ -266,48 +219,46 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
사용자에게 보여줄 필드 (예: 거래처명)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 저장 대상 테이블 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
<Label className="text-xs font-semibold sm:text-sm">3. 저장 대상 테이블 *</Label>
|
||||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
<Popover open={openTargetTableCombo} onOpenChange={setOpenTargetTableCombo}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={openValueFieldCombo}
|
aria-expanded={openTargetTableCombo}
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||||
disabled={!localConfig.tableName || isLoadingColumns}
|
disabled={isLoadingTables}
|
||||||
>
|
>
|
||||||
{localConfig.valueField
|
{localConfig.targetTable
|
||||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
? allTables.find((t) => t.tableName === localConfig.targetTable)?.displayName || localConfig.targetTable
|
||||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
: "데이터를 저장할 테이블 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{tableColumns.map((column) => (
|
{allTables.map((table) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.columnName}
|
key={table.tableName}
|
||||||
value={column.columnName}
|
value={table.tableName}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
updateConfig({ valueField: column.columnName });
|
updateConfig({ targetTable: table.tableName });
|
||||||
setOpenValueFieldCombo(false);
|
setOpenTargetTableCombo(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-4 w-4", localConfig.targetTable === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -316,11 +267,124 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
검색 테이블에서 가져올 값의 컬럼 (예: customer_code)
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 4. 필드 매핑 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold sm:text-sm">4. 필드 매핑 *</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addFieldMapping}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!localConfig.tableName || !localConfig.targetTable || isLoadingSourceColumns || isLoadingTargetColumns}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(localConfig.fieldMappings || []).length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
매핑 추가 버튼을 눌러 필드 매핑을 설정하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(localConfig.fieldMappings || []).map((mapping, index) => (
|
||||||
|
<div key={index} className="rounded-lg border bg-card p-4 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"
|
||||||
|
onClick={() => removeFieldMapping(index)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">표시명</Label>
|
||||||
|
<Input
|
||||||
|
value={mapping.label || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFieldMapping(index, { label: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="예: 거래처 코드"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">외부 테이블 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceField}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFieldMapping(index, { sourceField: value })
|
||||||
|
}
|
||||||
|
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="가져올 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={mapping.targetField}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFieldMapping(index, { targetField: value })
|
||||||
|
}
|
||||||
|
disabled={!localConfig.targetTable || isLoadingTargetColumns}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="저장할 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapping.sourceField && mapping.targetField && (
|
||||||
|
<div className="rounded bg-blue-50 p-2 dark:bg-blue-950">
|
||||||
|
<p className="text-[10px] text-blue-700 dark:text-blue-300">
|
||||||
|
<code className="rounded bg-blue-100 px-1 font-mono dark:bg-blue-900">
|
||||||
|
{localConfig.tableName}.{mapping.sourceField}
|
||||||
|
</code>
|
||||||
|
{" → "}
|
||||||
|
<code className="rounded bg-blue-100 px-1 font-mono dark:bg-blue-900">
|
||||||
|
{localConfig.targetTable}.{mapping.targetField}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 플레이스홀더 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -331,471 +395,28 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 값 필드 저장 위치 설정 */}
|
{/* 설정 요약 */}
|
||||||
<div className="space-y-4 border rounded-lg p-4 bg-card">
|
{localConfig.tableName && localConfig.targetTable && (localConfig.fieldMappings || []).length > 0 && (
|
||||||
<div>
|
<div className="rounded-lg border bg-green-50 p-4 dark:bg-green-950">
|
||||||
<h3 className="text-sm font-semibold mb-1">값 필드 저장 위치 (고급)</h3>
|
<h3 className="mb-2 text-sm font-semibold text-green-800 dark:text-green-200">
|
||||||
<p className="text-xs text-muted-foreground">
|
설정 요약
|
||||||
위에서 선택한 "값 필드"의 데이터를 어느 테이블/컬럼에 저장할지 지정합니다.
|
</h3>
|
||||||
<br />
|
<div className="space-y-1 text-xs text-green-700 dark:text-green-300">
|
||||||
미설정 시 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 자동 저장됩니다.
|
<p>
|
||||||
</p>
|
<strong>외부 테이블:</strong> {localConfig.tableName}
|
||||||
</div>
|
</p>
|
||||||
|
<p>
|
||||||
{/* 저장 테이블 선택 */}
|
<strong>표시 필드:</strong> {localConfig.displayField}
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<Label className="text-xs sm:text-sm">저장 테이블</Label>
|
<p>
|
||||||
<Popover open={openStorageTableCombo} onOpenChange={setOpenStorageTableCombo}>
|
<strong>저장 테이블:</strong> {localConfig.targetTable}
|
||||||
<PopoverTrigger asChild>
|
</p>
|
||||||
<Button
|
<p>
|
||||||
variant="outline"
|
<strong>매핑 개수:</strong> {(localConfig.fieldMappings || []).length}개
|
||||||
role="combobox"
|
|
||||||
aria-expanded={openStorageTableCombo}
|
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
||||||
disabled={isLoadingTables}
|
|
||||||
>
|
|
||||||
{localConfig.valueFieldStorage?.targetTable
|
|
||||||
? allTables.find((t) => t.tableName === localConfig.valueFieldStorage?.targetTable)?.displayName ||
|
|
||||||
localConfig.valueFieldStorage.targetTable
|
|
||||||
: "기본값 (화면 연결 테이블)"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 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 sm:text-sm" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{/* 기본값 옵션 */}
|
|
||||||
<CommandItem
|
|
||||||
value=""
|
|
||||||
onSelect={() => {
|
|
||||||
updateConfig({
|
|
||||||
valueFieldStorage: {
|
|
||||||
...localConfig.valueFieldStorage,
|
|
||||||
targetTable: undefined,
|
|
||||||
targetColumn: undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setOpenStorageTableCombo(false);
|
|
||||||
}}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Check className={cn("mr-2 h-4 w-4", !localConfig.valueFieldStorage?.targetTable ? "opacity-100" : "opacity-0")} />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">기본값</span>
|
|
||||||
<span className="text-[10px] text-gray-500">화면의 연결 테이블 사용</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
{allTables.map((table) => (
|
|
||||||
<CommandItem
|
|
||||||
key={table.tableName}
|
|
||||||
value={table.tableName}
|
|
||||||
onSelect={() => {
|
|
||||||
updateConfig({
|
|
||||||
valueFieldStorage: {
|
|
||||||
...localConfig.valueFieldStorage,
|
|
||||||
targetTable: table.tableName,
|
|
||||||
targetColumn: undefined, // 테이블 변경 시 컬럼 초기화
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setOpenStorageTableCombo(false);
|
|
||||||
}}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
localConfig.valueFieldStorage?.targetTable === table.tableName ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
|
||||||
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
값을 저장할 테이블 (기본값: 화면 연결 테이블)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 저장 컬럼 선택 */}
|
|
||||||
{localConfig.valueFieldStorage?.targetTable && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs sm:text-sm">저장 컬럼</Label>
|
|
||||||
<Popover open={openStorageColumnCombo} onOpenChange={setOpenStorageColumnCombo}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={openStorageColumnCombo}
|
|
||||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
||||||
disabled={isLoadingStorageColumns}
|
|
||||||
>
|
|
||||||
{localConfig.valueFieldStorage?.targetColumn
|
|
||||||
? storageTableColumns.find((c) => c.columnName === localConfig.valueFieldStorage?.targetColumn)
|
|
||||||
?.displayName || localConfig.valueFieldStorage.targetColumn
|
|
||||||
: isLoadingStorageColumns
|
|
||||||
? "로딩 중..."
|
|
||||||
: "컬럼 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 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 sm:text-sm" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty className="text-xs sm:text-sm">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{storageTableColumns.map((column) => (
|
|
||||||
<CommandItem
|
|
||||||
key={column.columnName}
|
|
||||||
value={column.columnName}
|
|
||||||
onSelect={() => {
|
|
||||||
updateConfig({
|
|
||||||
valueFieldStorage: {
|
|
||||||
...localConfig.valueFieldStorage,
|
|
||||||
targetColumn: column.columnName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setOpenStorageColumnCombo(false);
|
|
||||||
}}
|
|
||||||
className="text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
localConfig.valueFieldStorage?.targetColumn === column.columnName ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
|
||||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
값을 저장할 컬럼명
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 설명 박스 */}
|
|
||||||
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
|
|
||||||
<p className="text-xs font-medium mb-2 text-blue-800 dark:text-blue-200">
|
|
||||||
저장 위치 동작
|
|
||||||
</p>
|
|
||||||
<div className="text-[10px] text-blue-700 dark:text-blue-300 space-y-1">
|
|
||||||
{localConfig.valueFieldStorage?.targetTable ? (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
선택한 값(<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">{localConfig.valueField}</code>)을
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
|
||||||
{localConfig.valueFieldStorage.targetTable}
|
|
||||||
</code>{" "}
|
|
||||||
테이블의{" "}
|
|
||||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
|
||||||
{localConfig.valueFieldStorage.targetColumn || "(컬럼 미지정)"}
|
|
||||||
</code>{" "}
|
|
||||||
컬럼에 저장합니다.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>기본값: 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 저장됩니다.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs sm:text-sm">검색 필드</Label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={addSearchField}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={!localConfig.tableName || isLoadingColumns}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(localConfig.searchFields || []).map((field, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={field}
|
|
||||||
onValueChange={(value) => updateSearchField(index, value)}
|
|
||||||
disabled={!localConfig.tableName || 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>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => removeSearchField(index)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs sm:text-sm">추가 정보 표시</Label>
|
|
||||||
<Switch
|
|
||||||
checked={localConfig.showAdditionalInfo || false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateConfig({ showAdditionalInfo: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{localConfig.showAdditionalInfo && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs sm:text-sm">추가 필드</Label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={addAdditionalField}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={!localConfig.tableName || isLoadingColumns}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(localConfig.additionalFields || []).map((field, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={field}
|
|
||||||
onValueChange={(value) => updateAdditionalField(index, value)}
|
|
||||||
disabled={!localConfig.tableName || 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>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => removeAdditionalField(index)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 필드 자동 매핑 설정 */}
|
|
||||||
<div className="space-y-4 border rounded-lg p-4 bg-card">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold mb-1">필드 자동 매핑</h3>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
선택한 항목의 필드를 화면의 다른 입력 필드에 자동으로 채워넣습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs sm:text-sm">필드 매핑 활성화</Label>
|
|
||||||
<Switch
|
|
||||||
checked={localConfig.enableFieldMapping || false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
updateConfig({ enableFieldMapping: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
활성화하면 항목 선택 시 설정된 필드들이 자동으로 채워집니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{localConfig.enableFieldMapping && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-xs sm:text-sm">매핑 필드 목록</Label>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={addFieldMapping}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={!localConfig.tableName || isLoadingColumns}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
|
||||||
매핑 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{(localConfig.fieldMappings || []).map((mapping, index) => (
|
|
||||||
<div key={index} className="border rounded-lg p-3 space-y-3 bg-background">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
매핑 #{index + 1}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => removeFieldMapping(index)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 표시명 */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">표시명</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.label || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFieldMapping(index, { label: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="예: 거래처명"
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
이 매핑의 설명 (선택사항)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 소스 필드 (테이블의 컬럼) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">
|
|
||||||
소스 필드 (테이블 컬럼) *
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={mapping.sourceField}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateFieldMapping(index, { sourceField: value })
|
|
||||||
}
|
|
||||||
disabled={!localConfig.tableName || isLoadingColumns}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{tableColumns.map((col) => (
|
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">
|
|
||||||
{col.displayName || col.columnName}
|
|
||||||
</span>
|
|
||||||
{col.displayName && (
|
|
||||||
<span className="text-[10px] text-gray-500">
|
|
||||||
{col.columnName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
가져올 데이터의 컬럼명
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 타겟 필드 (화면의 input ID) */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">
|
|
||||||
타겟 필드 (화면 컴포넌트 ID) *
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.targetField}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFieldMapping(index, { targetField: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="예: customer_name_input"
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
값을 채울 화면 컴포넌트의 ID (예: input의 id 속성)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 예시 설명 */}
|
|
||||||
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
|
|
||||||
<p className="text-[10px] text-blue-700 dark:text-blue-300">
|
|
||||||
{mapping.sourceField && mapping.targetField ? (
|
|
||||||
<>
|
|
||||||
<span className="font-semibold">{mapping.label || "이 필드"}</span>: 테이블의{" "}
|
|
||||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
|
||||||
{mapping.sourceField}
|
|
||||||
</code>{" "}
|
|
||||||
값을 화면의{" "}
|
|
||||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
|
||||||
{mapping.targetField}
|
|
||||||
</code>{" "}
|
|
||||||
컴포넌트에 자동으로 채웁니다
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"소스 필드와 타겟 필드를 모두 선택하세요"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 사용 안내 */}
|
|
||||||
{localConfig.fieldMappings && localConfig.fieldMappings.length > 0 && (
|
|
||||||
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded border border-amber-200 dark:border-amber-800">
|
|
||||||
<p className="text-xs font-medium mb-2 text-amber-800 dark:text-amber-200">
|
|
||||||
사용 방법
|
|
||||||
</p>
|
|
||||||
<ul className="text-[10px] text-amber-700 dark:text-amber-300 space-y-1 list-disc list-inside">
|
|
||||||
<li>화면에서 이 검색 컴포넌트로 항목을 선택하면</li>
|
|
||||||
<li>설정된 매핑에 따라 다른 입력 필드들이 자동으로 채워집니다</li>
|
|
||||||
<li>타겟 필드 ID는 화면 디자이너에서 설정한 컴포넌트 ID와 일치해야 합니다</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,5 +27,7 @@ export interface AutocompleteSearchInputConfig {
|
||||||
// 필드 자동 매핑 설정
|
// 필드 자동 매핑 설정
|
||||||
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
|
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
|
||||||
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
||||||
|
// 저장 대상 테이블 (간소화 버전)
|
||||||
|
targetTable?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,26 @@ import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./
|
||||||
import { useCalculation } from "./useCalculation";
|
import { useCalculation } from "./useCalculation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
|
||||||
interface ModalRepeaterTableComponentProps extends Partial<ModalRepeaterTableProps> {
|
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
|
||||||
|
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
|
||||||
config?: ModalRepeaterTableProps;
|
config?: ModalRepeaterTableProps;
|
||||||
|
// ModalRepeaterTableProps의 개별 prop들도 지원 (호환성)
|
||||||
|
sourceTable?: string;
|
||||||
|
sourceColumns?: string[];
|
||||||
|
sourceSearchFields?: string[];
|
||||||
|
targetTable?: string;
|
||||||
|
modalTitle?: string;
|
||||||
|
modalButtonText?: string;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
columns?: RepeaterColumnConfig[];
|
||||||
|
calculationRules?: any[];
|
||||||
|
value?: any[];
|
||||||
|
onChange?: (newData: any[]) => void;
|
||||||
|
uniqueField?: string;
|
||||||
|
filterCondition?: Record<string, any>;
|
||||||
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -122,10 +139,25 @@ async function fetchReferenceValue(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModalRepeaterTableComponent({
|
export function ModalRepeaterTableComponent({
|
||||||
|
// ComponentRendererProps (자동 전달)
|
||||||
|
component,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
isInteractive = false,
|
||||||
|
onClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
|
||||||
|
// ModalRepeaterTable 전용 props
|
||||||
config,
|
config,
|
||||||
sourceTable: propSourceTable,
|
sourceTable: propSourceTable,
|
||||||
sourceColumns: propSourceColumns,
|
sourceColumns: propSourceColumns,
|
||||||
sourceSearchFields: propSourceSearchFields,
|
sourceSearchFields: propSourceSearchFields,
|
||||||
|
targetTable: propTargetTable,
|
||||||
modalTitle: propModalTitle,
|
modalTitle: propModalTitle,
|
||||||
modalButtonText: propModalButtonText,
|
modalButtonText: propModalButtonText,
|
||||||
multiSelect: propMultiSelect,
|
multiSelect: propMultiSelect,
|
||||||
|
|
@ -136,36 +168,55 @@ export function ModalRepeaterTableComponent({
|
||||||
uniqueField: propUniqueField,
|
uniqueField: propUniqueField,
|
||||||
filterCondition: propFilterCondition,
|
filterCondition: propFilterCondition,
|
||||||
companyCode: propCompanyCode,
|
companyCode: propCompanyCode,
|
||||||
className,
|
|
||||||
|
...props
|
||||||
}: ModalRepeaterTableComponentProps) {
|
}: ModalRepeaterTableComponentProps) {
|
||||||
|
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||||
|
const componentConfig = {
|
||||||
|
...config,
|
||||||
|
...component?.config,
|
||||||
|
};
|
||||||
|
|
||||||
// config prop 우선, 없으면 개별 prop 사용
|
// config prop 우선, 없으면 개별 prop 사용
|
||||||
const sourceTable = config?.sourceTable || propSourceTable || "";
|
const sourceTable = componentConfig?.sourceTable || propSourceTable || "";
|
||||||
|
const targetTable = componentConfig?.targetTable || propTargetTable;
|
||||||
|
|
||||||
// sourceColumns에서 빈 문자열 필터링
|
// sourceColumns에서 빈 문자열 필터링
|
||||||
const rawSourceColumns = config?.sourceColumns || propSourceColumns || [];
|
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
|
||||||
const sourceColumns = rawSourceColumns.filter((col) => col && col.trim() !== "");
|
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
|
||||||
|
|
||||||
const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || [];
|
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
|
||||||
const modalTitle = config?.modalTitle || propModalTitle || "항목 검색";
|
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
|
||||||
const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색";
|
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
|
||||||
const multiSelect = config?.multiSelect ?? propMultiSelect ?? true;
|
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
|
||||||
const calculationRules = config?.calculationRules || propCalculationRules || [];
|
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||||
const value = config?.value || propValue || [];
|
|
||||||
const onChange = config?.onChange || propOnChange || (() => {});
|
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
||||||
|
const columnName = component?.columnName;
|
||||||
|
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||||
|
|
||||||
|
// ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리)
|
||||||
|
const handleChange = (newData: any[]) => {
|
||||||
|
// 기존 onChange 콜백 호출 (호환성)
|
||||||
|
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||||
|
if (externalOnChange) {
|
||||||
|
externalOnChange(newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
|
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
|
||||||
const rawUniqueField = config?.uniqueField || propUniqueField;
|
const rawUniqueField = componentConfig?.uniqueField || propUniqueField;
|
||||||
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
|
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
|
||||||
? "item_number"
|
? "item_number"
|
||||||
: rawUniqueField;
|
: rawUniqueField;
|
||||||
|
|
||||||
const filterCondition = config?.filterCondition || propFilterCondition || {};
|
const filterCondition = componentConfig?.filterCondition || propFilterCondition || {};
|
||||||
const companyCode = config?.companyCode || propCompanyCode;
|
const companyCode = componentConfig?.companyCode || propCompanyCode;
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||||
const configuredColumns = config?.columns || propColumns || [];
|
const configuredColumns = componentConfig?.columns || propColumns || [];
|
||||||
|
|
||||||
if (configuredColumns.length > 0) {
|
if (configuredColumns.length > 0) {
|
||||||
console.log("✅ 설정된 columns 사용:", configuredColumns);
|
console.log("✅ 설정된 columns 사용:", configuredColumns);
|
||||||
|
|
@ -188,7 +239,7 @@ export function ModalRepeaterTableComponent({
|
||||||
|
|
||||||
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
|
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
|
||||||
return [];
|
return [];
|
||||||
}, [config?.columns, propColumns, sourceColumns]);
|
}, [componentConfig?.columns, propColumns, sourceColumns]);
|
||||||
|
|
||||||
// 초기 props 로깅
|
// 초기 props 로깅
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -221,6 +272,59 @@ export function ModalRepeaterTableComponent({
|
||||||
});
|
});
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveRequest = async (event: Event) => {
|
||||||
|
const componentKey = columnName || component?.id || "modal_repeater_data";
|
||||||
|
|
||||||
|
console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", {
|
||||||
|
componentKey,
|
||||||
|
itemsCount: value.length,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
columnName,
|
||||||
|
componentId: component?.id,
|
||||||
|
targetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (value.length === 0) {
|
||||||
|
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 targetTable 메타데이터를 배열 항목에 추가
|
||||||
|
const dataWithTargetTable = targetTable
|
||||||
|
? value.map(item => ({
|
||||||
|
...item,
|
||||||
|
_targetTable: targetTable, // 백엔드가 인식할 메타데이터
|
||||||
|
}))
|
||||||
|
: value;
|
||||||
|
|
||||||
|
// ✅ CustomEvent의 detail에 데이터 추가
|
||||||
|
if (event instanceof CustomEvent && event.detail) {
|
||||||
|
event.detail.formData[componentKey] = dataWithTargetTable;
|
||||||
|
console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", {
|
||||||
|
key: componentKey,
|
||||||
|
itemCount: dataWithTargetTable.length,
|
||||||
|
targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)",
|
||||||
|
sampleItem: dataWithTargetTable[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 onFormDataChange도 호출 (호환성)
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(componentKey, dataWithTargetTable);
|
||||||
|
console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 버튼 클릭 시 데이터 수집
|
||||||
|
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
|
};
|
||||||
|
}, [value, columnName, component?.id, onFormDataChange, targetTable]);
|
||||||
|
|
||||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||||
|
|
||||||
// 초기 데이터에 계산 필드 적용
|
// 초기 데이터에 계산 필드 적용
|
||||||
|
|
@ -338,7 +442,8 @@ export function ModalRepeaterTableComponent({
|
||||||
const newData = [...value, ...calculatedItems];
|
const newData = [...value, ...calculatedItems];
|
||||||
console.log("✅ 최종 데이터:", newData.length, "개 항목");
|
console.log("✅ 최종 데이터:", newData.length, "개 항목");
|
||||||
|
|
||||||
onChange(newData);
|
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||||
|
handleChange(newData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowChange = (index: number, newRow: any) => {
|
const handleRowChange = (index: number, newRow: any) => {
|
||||||
|
|
@ -348,12 +453,16 @@ export function ModalRepeaterTableComponent({
|
||||||
// 데이터 업데이트
|
// 데이터 업데이트
|
||||||
const newData = [...value];
|
const newData = [...value];
|
||||||
newData[index] = calculatedRow;
|
newData[index] = calculatedRow;
|
||||||
onChange(newData);
|
|
||||||
|
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||||
|
handleChange(newData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowDelete = (index: number) => {
|
const handleRowDelete = (index: number) => {
|
||||||
const newData = value.filter((_, i) => i !== index);
|
const newData = value.filter((_, i) => i !== index);
|
||||||
onChange(newData);
|
|
||||||
|
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||||
|
handleChange(newData);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼명 -> 라벨명 매핑 생성
|
// 컬럼명 -> 라벨명 매핑 생성
|
||||||
|
|
@ -382,7 +491,7 @@ export function ModalRepeaterTableComponent({
|
||||||
<RepeaterTable
|
<RepeaterTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={value}
|
data={value}
|
||||||
onDataChange={onChange}
|
onDataChange={handleChange}
|
||||||
onRowChange={handleRowChange}
|
onRowChange={handleRowChange}
|
||||||
onRowDelete={handleRowDelete}
|
onRowDelete={handleRowDelete}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -7,40 +7,15 @@ import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ModalRepeaterTable 렌더러
|
* ModalRepeaterTable 렌더러
|
||||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
* ✅ 단순 전달만 수행 (TextInput 패턴 따름)
|
||||||
*/
|
*/
|
||||||
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
|
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
|
||||||
static componentDefinition = ModalRepeaterTableDefinition;
|
static componentDefinition = ModalRepeaterTableDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
render(): React.ReactElement {
|
||||||
// onChange 콜백을 명시적으로 전달
|
// ✅ props를 그대로 전달 (Component에서 모든 로직 처리)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
return <ModalRepeaterTableComponent {...this.props} />;
|
||||||
const handleChange = (newValue: any[]) => {
|
|
||||||
console.log("🔄 ModalRepeaterTableRenderer onChange:", newValue.length, "개 항목");
|
|
||||||
|
|
||||||
// 컴포넌트 업데이트
|
|
||||||
this.updateComponent({ value: newValue });
|
|
||||||
|
|
||||||
// 원본 onChange 콜백도 호출 (있다면)
|
|
||||||
if (this.props.onChange) {
|
|
||||||
this.props.onChange(newValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// renderer prop 제거 (불필요)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { onChange, ...restProps } = this.props;
|
|
||||||
|
|
||||||
return <ModalRepeaterTableComponent {...restProps} onChange={handleChange} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 값 변경 처리 (레거시 메서드 - 호환성 유지)
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
protected handleValueChange = (value: any) => {
|
|
||||||
this.updateComponent({ value });
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자동 등록 실행
|
// 자동 등록 실행
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
menuObjid, // 🆕 메뉴 OBJID
|
menuObjid, // 🆕 메뉴 OBJID
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
|
||||||
|
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
|
||||||
|
componentId: component.id,
|
||||||
|
componentType: (component as any).componentType,
|
||||||
|
columnName: (component as any).columnName,
|
||||||
|
"props.multiple": (props as any).multiple,
|
||||||
|
"componentConfig.multiple": componentConfig?.multiple,
|
||||||
|
});
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||||
const config = (props as any).webTypeConfig || componentConfig || {};
|
const config = (props as any).webTypeConfig || componentConfig || {};
|
||||||
|
|
||||||
|
// 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위
|
||||||
|
const isMultiple = (props as any).multiple ?? config?.multiple ?? false;
|
||||||
|
|
||||||
|
// 🔍 디버깅: config 및 multiple 확인
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 ==========");
|
||||||
|
console.log(" 컴포넌트 ID:", component.id);
|
||||||
|
console.log(" 최종 isMultiple 값:", isMultiple);
|
||||||
|
console.log(" ----------------------------------------");
|
||||||
|
console.log(" props.multiple:", (props as any).multiple);
|
||||||
|
console.log(" config.multiple:", config?.multiple);
|
||||||
|
console.log(" componentConfig.multiple:", componentConfig?.multiple);
|
||||||
|
console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple);
|
||||||
|
console.log(" ----------------------------------------");
|
||||||
|
console.log(" config 전체:", config);
|
||||||
|
console.log(" componentConfig 전체:", componentConfig);
|
||||||
|
console.log(" component.componentConfig 전체:", component.componentConfig);
|
||||||
|
console.log(" =======================================");
|
||||||
|
|
||||||
|
// 다중선택이 활성화되었는지 알림
|
||||||
|
if (isMultiple) {
|
||||||
|
console.log("✅ 다중선택 모드 활성화됨!");
|
||||||
|
} else {
|
||||||
|
console.log("❌ 단일선택 모드 (다중선택 비활성화)");
|
||||||
|
}
|
||||||
|
}, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]);
|
||||||
|
|
||||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||||
const webType = component.componentConfig?.webType || "select";
|
const webType = component.componentConfig?.webType || "select";
|
||||||
|
|
||||||
|
|
@ -62,8 +98,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
||||||
const [selectedLabel, setSelectedLabel] = useState("");
|
const [selectedLabel, setSelectedLabel] = useState("");
|
||||||
|
|
||||||
// multiselect의 경우 배열로 관리
|
// multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
|
||||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
const [selectedValues, setSelectedValues] = useState<string[]>(() => {
|
||||||
|
const initialValue = externalValue || config?.value || "";
|
||||||
|
if (isMultiple && typeof initialValue === "string" && initialValue) {
|
||||||
|
return initialValue.split(",").map(v => v.trim()).filter(v => v);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
// autocomplete의 경우 검색어 관리
|
// autocomplete의 경우 검색어 관리
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -96,6 +138,58 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
isFetching,
|
isFetching,
|
||||||
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
|
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
|
||||||
|
|
||||||
|
// 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
||||||
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (webType === "category" && component.tableName && component.columnName) {
|
||||||
|
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
||||||
|
tableName: component.tableName,
|
||||||
|
columnName: component.columnName,
|
||||||
|
webType,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoadingCategories(true);
|
||||||
|
|
||||||
|
import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => {
|
||||||
|
getCategoryValues(component.tableName!, component.columnName!)
|
||||||
|
.then((response) => {
|
||||||
|
console.log("🔍 [SelectBasic] 카테고리 API 응답:", response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
console.log("🔍 [SelectBasic] 원본 데이터 샘플:", {
|
||||||
|
firstItem: response.data[0],
|
||||||
|
keys: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeValues = response.data.filter((v) => v.isActive !== false);
|
||||||
|
const options = activeValues.map((v) => ({
|
||||||
|
value: v.valueCode,
|
||||||
|
label: v.valueLabel || v.valueCode,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("✅ [SelectBasic] 카테고리 옵션 설정:", {
|
||||||
|
activeValuesCount: activeValues.length,
|
||||||
|
options,
|
||||||
|
sampleOption: options[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
setCategoryOptions(options);
|
||||||
|
} else {
|
||||||
|
console.error("❌ [SelectBasic] 카테고리 응답 실패:", response);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingCategories(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [webType, component.tableName, component.columnName]);
|
||||||
|
|
||||||
// 디버깅: menuObjid가 제대로 전달되는지 확인
|
// 디버깅: menuObjid가 제대로 전달되는지 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (codeCategory && codeCategory !== "none") {
|
if (codeCategory && codeCategory !== "none") {
|
||||||
|
|
@ -113,11 +207,42 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// 외부 value prop 변경 시 selectedValue 업데이트
|
// 외부 value prop 변경 시 selectedValue 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newValue = externalValue || config?.value || "";
|
const newValue = externalValue || config?.value || "";
|
||||||
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
|
||||||
if (newValue !== selectedValue) {
|
console.log("🔍 [SelectBasic] 외부 값 변경 감지:", {
|
||||||
setSelectedValue(newValue);
|
componentId: component.id,
|
||||||
|
columnName: (component as any).columnName,
|
||||||
|
isMultiple,
|
||||||
|
newValue,
|
||||||
|
selectedValue,
|
||||||
|
selectedValues,
|
||||||
|
externalValue,
|
||||||
|
"config.value": config?.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다중선택 모드인 경우
|
||||||
|
if (isMultiple) {
|
||||||
|
if (typeof newValue === "string" && newValue) {
|
||||||
|
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
|
||||||
|
const currentValuesStr = selectedValues.join(",");
|
||||||
|
|
||||||
|
if (newValue !== currentValuesStr) {
|
||||||
|
console.log("✅ [SelectBasic] 다중선택 값 업데이트:", {
|
||||||
|
from: selectedValues,
|
||||||
|
to: values,
|
||||||
|
});
|
||||||
|
setSelectedValues(values);
|
||||||
|
}
|
||||||
|
} else if (!newValue && selectedValues.length > 0) {
|
||||||
|
console.log("✅ [SelectBasic] 다중선택 값 초기화");
|
||||||
|
setSelectedValues([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 단일선택 모드인 경우
|
||||||
|
if (newValue !== selectedValue) {
|
||||||
|
setSelectedValue(newValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [externalValue, config?.value]);
|
}, [externalValue, config?.value, isMultiple]);
|
||||||
|
|
||||||
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
|
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
|
||||||
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
|
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
|
||||||
|
|
@ -128,7 +253,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...configOptions];
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = getAllOptions();
|
const options = getAllOptions();
|
||||||
|
|
@ -204,12 +329,24 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
const configOptions = config.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...configOptions];
|
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const allOptions = getAllOptions();
|
const allOptions = getAllOptions();
|
||||||
const placeholder = componentConfig.placeholder || "선택하세요";
|
const placeholder = componentConfig.placeholder || "선택하세요";
|
||||||
|
|
||||||
|
// 🔍 디버깅: 최종 옵션 확인
|
||||||
|
useEffect(() => {
|
||||||
|
if (webType === "category" && allOptions.length > 0) {
|
||||||
|
console.log("🔍 [SelectBasic] 최종 allOptions:", {
|
||||||
|
count: allOptions.length,
|
||||||
|
categoryOptionsCount: categoryOptions.length,
|
||||||
|
codeOptionsCount: codeOptions.length,
|
||||||
|
sampleOptions: allOptions.slice(0, 3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [webType, allOptions.length, categoryOptions.length, codeOptions.length]);
|
||||||
|
|
||||||
// DOM props에서 React 전용 props 필터링
|
// DOM props에서 React 전용 props 필터링
|
||||||
const {
|
const {
|
||||||
component: _component,
|
component: _component,
|
||||||
|
|
@ -500,6 +637,96 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// select (기본 선택박스)
|
// select (기본 선택박스)
|
||||||
|
// 다중선택 모드인 경우
|
||||||
|
if (isMultiple) {
|
||||||
|
return (
|
||||||
|
<div className="w-full" style={{ height: "100%" }}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"box-border flex w-full flex-wrap items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||||
|
!isDesignMode && "hover:border-orange-400",
|
||||||
|
isSelected && "ring-2 ring-orange-500",
|
||||||
|
)}
|
||||||
|
onClick={() => !isDesignMode && setIsOpen(true)}
|
||||||
|
style={{
|
||||||
|
pointerEvents: isDesignMode ? "none" : "auto",
|
||||||
|
height: "100%"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedValues.map((val, idx) => {
|
||||||
|
const opt = allOptions.find((o) => o.value === val);
|
||||||
|
return (
|
||||||
|
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
|
||||||
|
{opt?.label || val}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newVals = selectedValues.filter((v) => v !== val);
|
||||||
|
setSelectedValues(newVals);
|
||||||
|
const newValue = newVals.join(",");
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{selectedValues.length === 0 && (
|
||||||
|
<span className="text-gray-500">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isOpen && !isDesignMode && (
|
||||||
|
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||||
|
{(isLoadingCodes || isLoadingCategories) ? (
|
||||||
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
|
) : allOptions.length > 0 ? (
|
||||||
|
allOptions.map((option, index) => {
|
||||||
|
const isSelected = selectedValues.includes(option.value);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${option.value}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||||
|
isSelected && "bg-blue-50 font-medium"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
const newVals = isSelected
|
||||||
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
|
: [...selectedValues, option.value];
|
||||||
|
setSelectedValues(newVals);
|
||||||
|
const newValue = newVals.join(",");
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span>{option.label || option.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단일선택 모드
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||||
|
const newConfig = { ...config, [key]: value };
|
||||||
|
onChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -67,6 +69,15 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="multiple">다중 선택</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="multiple"
|
||||||
|
checked={config.multiple || false}
|
||||||
|
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,117 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
|
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
|
||||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||||
|
|
||||||
|
// 🆕 추가 입력 필드별 자동 채우기 테이블 컬럼 상태
|
||||||
|
const [autoFillTableColumns, setAutoFillTableColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||||
|
|
||||||
|
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
|
||||||
|
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
|
||||||
|
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
|
||||||
|
|
||||||
|
// 🆕 원본 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.sourceTable) {
|
||||||
|
setLoadedSourceTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadColumns = async () => {
|
||||||
|
try {
|
||||||
|
console.log("🔍 원본 테이블 컬럼 로드:", config.sourceTable);
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getColumnList(config.sourceTable);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const columns = response.data.columns || [];
|
||||||
|
setLoadedSourceTableColumns(columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType,
|
||||||
|
})));
|
||||||
|
console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 원본 테이블 컬럼 로드 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
}, [config.sourceTable]);
|
||||||
|
|
||||||
|
// 🆕 대상 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.targetTable) {
|
||||||
|
setLoadedTargetTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadColumns = async () => {
|
||||||
|
try {
|
||||||
|
console.log("🔍 대상 테이블 컬럼 로드:", config.targetTable);
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getColumnList(config.targetTable);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const columns = response.data.columns || [];
|
||||||
|
setLoadedTargetTableColumns(columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType,
|
||||||
|
})));
|
||||||
|
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 대상 테이블 컬럼 로드 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
}, [config.targetTable]);
|
||||||
|
|
||||||
|
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localFields || localFields.length === 0) return;
|
||||||
|
|
||||||
|
localFields.forEach((field, index) => {
|
||||||
|
if (field.autoFillFromTable && !autoFillTableColumns[index]) {
|
||||||
|
console.log(`🔍 [초기화] 필드 ${index}의 기존 테이블 컬럼 로드:`, field.autoFillFromTable);
|
||||||
|
loadAutoFillTableColumns(field.autoFillFromTable, index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []); // 초기 한 번만 실행
|
||||||
|
|
||||||
|
// 🆕 자동 채우기 테이블 선택 시 컬럼 로드
|
||||||
|
const loadAutoFillTableColumns = async (tableName: string, fieldIndex: number) => {
|
||||||
|
if (!tableName) {
|
||||||
|
setAutoFillTableColumns(prev => ({ ...prev, [fieldIndex]: [] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔍 [필드 ${fieldIndex}] 자동 채우기 테이블 컬럼 로드:`, tableName);
|
||||||
|
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const columns = response.data.columns || [];
|
||||||
|
setAutoFillTableColumns(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fieldIndex]: columns.map((col: any) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType,
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
console.log(`✅ [필드 ${fieldIndex}] 컬럼 로드 성공:`, columns.length);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 실패:`, response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 오류:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 🆕 소스 테이블 선택 시 컬럼 로드
|
// 🆕 소스 테이블 선택 시 컬럼 로드
|
||||||
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
|
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -180,7 +291,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
|
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
|
||||||
|
|
||||||
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
|
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
// 🔧 기존 config와 병합하여 다른 속성 유지
|
||||||
|
onChange({ ...config, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
|
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
|
||||||
|
|
@ -261,15 +373,19 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
|
|
||||||
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
|
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
|
||||||
const availableColumns = useMemo(() => {
|
const availableColumns = useMemo(() => {
|
||||||
|
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
|
||||||
|
const columns = loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns;
|
||||||
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
|
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
|
||||||
return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName));
|
return columns.filter((col) => !usedColumns.has(col.columnName));
|
||||||
}, [sourceTableColumns, displayColumns, localFields]);
|
}, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]);
|
||||||
|
|
||||||
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
|
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
|
||||||
const availableTargetColumns = useMemo(() => {
|
const availableTargetColumns = useMemo(() => {
|
||||||
|
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
|
||||||
|
const columns = loadedTargetTableColumns.length > 0 ? loadedTargetTableColumns : targetTableColumns;
|
||||||
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
|
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
|
||||||
return targetTableColumns.filter((col) => !usedColumns.has(col.columnName));
|
return columns.filter((col) => !usedColumns.has(col.columnName));
|
||||||
}, [targetTableColumns, displayColumns, localFields]);
|
}, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]);
|
||||||
|
|
||||||
// 🆕 원본 테이블 필터링
|
// 🆕 원본 테이블 필터링
|
||||||
const filteredSourceTables = useMemo(() => {
|
const filteredSourceTables = useMemo(() => {
|
||||||
|
|
@ -403,7 +519,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={sourceTableSelectOpen}
|
aria-expanded={sourceTableSelectOpen}
|
||||||
className="h-8 w-full justify-between text-xs sm:text-sm"
|
className="h-8 w-full justify-between text-xs sm:text-sm"
|
||||||
disabled={allTables.length === 0}
|
|
||||||
>
|
>
|
||||||
{selectedSourceTableLabel}
|
{selectedSourceTableLabel}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
||||||
|
|
@ -677,15 +792,66 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-[10px] sm:text-xs">자동 채우기 (선택)</Label>
|
<Label className="text-[10px] sm:text-xs">자동 채우기 (선택)</Label>
|
||||||
|
|
||||||
{/* 테이블명 입력 */}
|
{/* 테이블 선택 드롭다운 */}
|
||||||
<Input
|
<Popover>
|
||||||
value={field.autoFillFromTable || ""}
|
<PopoverTrigger asChild>
|
||||||
onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })}
|
<Button
|
||||||
placeholder="비워두면 주 데이터 (예: item_price)"
|
variant="outline"
|
||||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
role="combobox"
|
||||||
/>
|
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
||||||
|
>
|
||||||
|
{field.autoFillFromTable
|
||||||
|
? allTables.find(t => t.tableName === field.autoFillFromTable)?.displayName || field.autoFillFromTable
|
||||||
|
: "원본 테이블 (기본)"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0 sm:w-[300px]">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
||||||
|
<CommandEmpty className="text-[10px] sm:text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
<CommandItem
|
||||||
|
value=""
|
||||||
|
onSelect={() => {
|
||||||
|
updateField(index, { autoFillFromTable: undefined, autoFillFrom: undefined });
|
||||||
|
setAutoFillTableColumns(prev => ({ ...prev, [index]: [] }));
|
||||||
|
}}
|
||||||
|
className="text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||||
|
!field.autoFillFromTable ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
원본 테이블 ({config.sourceTable || "미설정"})
|
||||||
|
</CommandItem>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={table.tableName}
|
||||||
|
onSelect={(value) => {
|
||||||
|
updateField(index, { autoFillFromTable: value, autoFillFrom: undefined });
|
||||||
|
loadAutoFillTableColumns(value, index);
|
||||||
|
}}
|
||||||
|
className="text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||||
|
field.autoFillFromTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<p className="text-[9px] text-gray-500 sm:text-[10px]">
|
<p className="text-[9px] text-gray-500 sm:text-[10px]">
|
||||||
다른 테이블에서 가져올 경우 테이블명 입력
|
다른 테이블에서 가져올 경우 테이블 선택
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 필드 선택 */}
|
{/* 필드 선택 */}
|
||||||
|
|
@ -696,16 +862,26 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
||||||
>
|
>
|
||||||
{field.autoFillFrom
|
{(() => {
|
||||||
? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom
|
if (!field.autoFillFrom) return "필드 선택 안 함";
|
||||||
: "필드 선택 안 함"}
|
|
||||||
|
// 선택된 테이블의 컬럼에서 찾기
|
||||||
|
const columns = field.autoFillFromTable
|
||||||
|
? (autoFillTableColumns[index] || [])
|
||||||
|
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
|
||||||
|
|
||||||
|
const found = columns.find(c => c.columnName === field.autoFillFrom);
|
||||||
|
return found?.columnLabel || field.autoFillFrom;
|
||||||
|
})()}
|
||||||
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
|
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
||||||
<CommandEmpty className="text-[10px] sm:text-xs">원본 테이블을 먼저 선택하세요.</CommandEmpty>
|
<CommandEmpty className="text-[10px] sm:text-xs">
|
||||||
|
{field.autoFillFromTable ? "컬럼을 찾을 수 없습니다" : "원본 테이블을 먼저 선택하세요"}
|
||||||
|
</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
|
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value=""
|
value=""
|
||||||
|
|
@ -720,25 +896,32 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
/>
|
/>
|
||||||
선택 안 함
|
선택 안 함
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{sourceTableColumns.map((column) => (
|
{(() => {
|
||||||
<CommandItem
|
// 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼
|
||||||
key={column.columnName}
|
const columns = field.autoFillFromTable
|
||||||
value={column.columnName}
|
? (autoFillTableColumns[index] || [])
|
||||||
onSelect={() => updateField(index, { autoFillFrom: column.columnName })}
|
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
|
||||||
className="text-[10px] sm:text-xs"
|
|
||||||
>
|
return columns.map((column) => (
|
||||||
<Check
|
<CommandItem
|
||||||
className={cn(
|
key={column.columnName}
|
||||||
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
value={column.columnName}
|
||||||
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
|
onSelect={(value) => updateField(index, { autoFillFrom: value })}
|
||||||
)}
|
className="text-[10px] sm:text-xs"
|
||||||
/>
|
>
|
||||||
<div>
|
<Check
|
||||||
<div className="font-medium">{column.columnLabel}</div>
|
className={cn(
|
||||||
<div className="text-[9px] text-gray-500">{column.columnName}</div>
|
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||||
</div>
|
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
|
||||||
</CommandItem>
|
)}
|
||||||
))}
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{column.columnLabel || column.columnName}</div>
|
||||||
|
{column.dataType && <div className="text-[8px] text-gray-500">{column.dataType}</div>}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|
|
||||||
|
|
@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 요약 표시 설정 (LIST 모드에서만) */}
|
{/* 요약 표시 설정 (LIST 모드에서만) */}
|
||||||
{config.rightPanel?.displayMode === "list" && (
|
{(config.rightPanel?.displayMode || "list") === "list" && (
|
||||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ export interface TableListComponentProps {
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
screenId?: string;
|
screenId?: number | string; // 화면 ID (필터 설정 저장용)
|
||||||
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||||||
onSelectedRowsChange?: (
|
onSelectedRowsChange?: (
|
||||||
selectedRows: any[],
|
selectedRows: any[],
|
||||||
|
|
@ -183,6 +183,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
refreshKey,
|
refreshKey,
|
||||||
tableName,
|
tableName,
|
||||||
userId,
|
userId,
|
||||||
|
screenId, // 화면 ID 추출
|
||||||
}) => {
|
}) => {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 설정 및 스타일
|
// 설정 및 스타일
|
||||||
|
|
@ -1227,8 +1228,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 체크박스 컬럼 (나중에 위치 결정)
|
// 체크박스 컬럼 (나중에 위치 결정)
|
||||||
|
// 기본값: enabled가 undefined면 true로 처리
|
||||||
let checkboxCol: ColumnConfig | null = null;
|
let checkboxCol: ColumnConfig | null = null;
|
||||||
if (tableConfig.checkbox?.enabled) {
|
if (tableConfig.checkbox?.enabled ?? true) {
|
||||||
checkboxCol = {
|
checkboxCol = {
|
||||||
columnName: "__checkbox__",
|
columnName: "__checkbox__",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
|
|
@ -1257,7 +1259,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 체크박스를 맨 앞 또는 맨 뒤에 추가
|
// 체크박스를 맨 앞 또는 맨 뒤에 추가
|
||||||
if (checkboxCol) {
|
if (checkboxCol) {
|
||||||
if (tableConfig.checkbox.position === "right") {
|
if (tableConfig.checkbox?.position === "right") {
|
||||||
cols = [...cols, checkboxCol];
|
cols = [...cols, checkboxCol];
|
||||||
} else {
|
} else {
|
||||||
cols = [checkboxCol, ...cols];
|
cols = [checkboxCol, ...cols];
|
||||||
|
|
@ -1423,33 +1425,73 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||||||
if (inputType === "category") {
|
if (inputType === "category") {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
|
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
const categoryData = mapping?.[String(value)];
|
const { Badge } = require("@/components/ui/badge");
|
||||||
|
|
||||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
// 다중 값 처리: 콤마로 구분된 값들을 분리
|
||||||
const displayLabel = categoryData?.label || String(value);
|
const valueStr = String(value);
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
const values = valueStr.includes(",")
|
||||||
|
? valueStr.split(",").map(v => v.trim()).filter(v => v)
|
||||||
|
: [valueStr];
|
||||||
|
|
||||||
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
// 단일 값인 경우 (기존 로직)
|
||||||
if (displayColor === "none") {
|
if (values.length === 1) {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
const categoryData = mapping?.[values[0]];
|
||||||
|
const displayLabel = categoryData?.label || values[0];
|
||||||
|
const displayColor = categoryData?.color || "#64748b";
|
||||||
|
|
||||||
|
if (displayColor === "none") {
|
||||||
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
style={{
|
||||||
|
backgroundColor: displayColor,
|
||||||
|
borderColor: displayColor,
|
||||||
|
}}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{displayLabel}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Badge } = require("@/components/ui/badge");
|
// 다중 값인 경우: 여러 배지 렌더링
|
||||||
return (
|
return (
|
||||||
<Badge
|
<div className="flex flex-wrap gap-1">
|
||||||
style={{
|
{values.map((val, idx) => {
|
||||||
backgroundColor: displayColor,
|
const categoryData = mapping?.[val];
|
||||||
borderColor: displayColor,
|
const displayLabel = categoryData?.label || val;
|
||||||
}}
|
const displayColor = categoryData?.color || "#64748b";
|
||||||
className="text-white"
|
|
||||||
>
|
if (displayColor === "none") {
|
||||||
{displayLabel}
|
return (
|
||||||
</Badge>
|
<span key={idx} className="text-sm">
|
||||||
|
{displayLabel}
|
||||||
|
{idx < values.length - 1 && ", "}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
backgroundColor: displayColor,
|
||||||
|
borderColor: displayColor,
|
||||||
|
}}
|
||||||
|
className="text-white"
|
||||||
|
>
|
||||||
|
{displayLabel}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1535,17 +1577,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// useEffect 훅
|
// useEffect 훅
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// 필터 설정 localStorage 키 생성
|
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
|
||||||
const filterSettingKey = useMemo(() => {
|
const filterSettingKey = useMemo(() => {
|
||||||
if (!tableConfig.selectedTable) return null;
|
if (!tableConfig.selectedTable) return null;
|
||||||
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
return screenId
|
||||||
}, [tableConfig.selectedTable]);
|
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||||||
|
: `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||||
|
}, [tableConfig.selectedTable, screenId]);
|
||||||
|
|
||||||
// 그룹 설정 localStorage 키 생성
|
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
|
||||||
const groupSettingKey = useMemo(() => {
|
const groupSettingKey = useMemo(() => {
|
||||||
if (!tableConfig.selectedTable) return null;
|
if (!tableConfig.selectedTable) return null;
|
||||||
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
return screenId
|
||||||
}, [tableConfig.selectedTable]);
|
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||||||
|
: `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||||
|
}, [tableConfig.selectedTable, screenId]);
|
||||||
|
|
||||||
// 저장된 필터 설정 불러오기
|
// 저장된 필터 설정 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
// });
|
// });
|
||||||
|
|
||||||
const parentValue = config[parentKey] as any;
|
const parentValue = config[parentKey] as any;
|
||||||
|
// 전체 config와 병합하여 다른 속성 유지
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
|
...config,
|
||||||
[parentKey]: {
|
[parentKey]: {
|
||||||
...parentValue,
|
...parentValue,
|
||||||
[childKey]: value,
|
[childKey]: value,
|
||||||
|
|
@ -754,6 +756,52 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 체크박스 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">체크박스 설정</h3>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="checkboxEnabled"
|
||||||
|
checked={config.checkbox?.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("checkbox", "enabled", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="checkboxEnabled">체크박스 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.checkbox?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="checkboxSelectAll"
|
||||||
|
checked={config.checkbox?.selectAll ?? true}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("checkbox", "selectAll", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="checkboxSelectAll">전체 선택 체크박스 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="checkboxPosition" className="text-xs">
|
||||||
|
체크박스 위치
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="checkboxPosition"
|
||||||
|
value={config.checkbox?.position || "left"}
|
||||||
|
onChange={(e) => handleNestedChange("checkbox", "position", e.target.value)}
|
||||||
|
className="w-full h-8 text-xs border rounded-md px-2"
|
||||||
|
>
|
||||||
|
<option value="left">왼쪽</option>
|
||||||
|
<option value="right">오른쪽</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 가로 스크롤 및 컬럼 고정 */}
|
{/* 가로 스크롤 및 컬럼 고정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
||||||
import { TableFilter } from "@/types/table-options";
|
import { TableFilter } from "@/types/table-options";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface PresetFilter {
|
||||||
|
id: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
filterType: "text" | "number" | "date" | "select";
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface TableSearchWidgetProps {
|
interface TableSearchWidgetProps {
|
||||||
component: {
|
component: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -25,6 +33,8 @@ interface TableSearchWidgetProps {
|
||||||
componentConfig?: {
|
componentConfig?: {
|
||||||
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
|
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
|
||||||
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
||||||
|
filterMode?: "dynamic" | "preset"; // 필터 모드
|
||||||
|
presetFilters?: PresetFilter[]; // 고정 필터 목록
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
screenId?: number; // 화면 ID
|
screenId?: number; // 화면 ID
|
||||||
|
|
@ -63,6 +73,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
|
|
||||||
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
||||||
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||||
|
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
|
||||||
|
const presetFilters = component.componentConfig?.presetFilters ?? [];
|
||||||
|
|
||||||
// Map을 배열로 변환
|
// Map을 배열로 변환
|
||||||
const tableList = Array.from(registeredTables.values());
|
const tableList = Array.from(registeredTables.values());
|
||||||
|
|
@ -77,41 +89,58 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
}
|
}
|
||||||
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
||||||
|
|
||||||
// 현재 테이블의 저장된 필터 불러오기
|
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTable?.tableName) {
|
if (!currentTable?.tableName) return;
|
||||||
const storageKey = `table_filters_${currentTable.tableName}`;
|
|
||||||
const savedFilters = localStorage.getItem(storageKey);
|
|
||||||
|
|
||||||
if (savedFilters) {
|
// 고정 모드: presetFilters를 activeFilters로 설정
|
||||||
try {
|
if (filterMode === "preset") {
|
||||||
const parsed = JSON.parse(savedFilters) as Array<{
|
const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({
|
||||||
columnName: string;
|
columnName: f.columnName,
|
||||||
columnLabel: string;
|
operator: "contains",
|
||||||
inputType: string;
|
value: "",
|
||||||
enabled: boolean;
|
filterType: f.filterType,
|
||||||
filterType: "text" | "number" | "date" | "select";
|
width: f.width || 200,
|
||||||
width?: number;
|
}));
|
||||||
}>;
|
setActiveFilters(activeFiltersList);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// enabled된 필터들만 activeFilters로 설정
|
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
||||||
const activeFiltersList: TableFilter[] = parsed
|
const storageKey = screenId
|
||||||
.filter((f) => f.enabled)
|
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
||||||
.map((f) => ({
|
: `table_filters_${currentTable.tableName}`;
|
||||||
columnName: f.columnName,
|
const savedFilters = localStorage.getItem(storageKey);
|
||||||
operator: "contains",
|
|
||||||
value: "",
|
|
||||||
filterType: f.filterType,
|
|
||||||
width: f.width || 200, // 저장된 너비 포함
|
|
||||||
}));
|
|
||||||
|
|
||||||
setActiveFilters(activeFiltersList);
|
if (savedFilters) {
|
||||||
} catch (error) {
|
try {
|
||||||
console.error("저장된 필터 불러오기 실패:", error);
|
const parsed = JSON.parse(savedFilters) as Array<{
|
||||||
}
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
inputType: string;
|
||||||
|
enabled: boolean;
|
||||||
|
filterType: "text" | "number" | "date" | "select";
|
||||||
|
width?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// enabled된 필터들만 activeFilters로 설정
|
||||||
|
const activeFiltersList: TableFilter[] = parsed
|
||||||
|
.filter((f) => f.enabled)
|
||||||
|
.map((f) => ({
|
||||||
|
columnName: f.columnName,
|
||||||
|
operator: "contains",
|
||||||
|
value: "",
|
||||||
|
filterType: f.filterType,
|
||||||
|
width: f.width || 200, // 저장된 너비 포함
|
||||||
|
}));
|
||||||
|
|
||||||
|
setActiveFilters(activeFiltersList);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장된 필터 불러오기 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentTable?.tableName]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
|
||||||
|
|
||||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -362,7 +391,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
{/* 필터가 없을 때는 빈 공간 */}
|
{/* 필터가 없을 때는 빈 공간 */}
|
||||||
{activeFilters.length === 0 && <div className="flex-1" />}
|
{activeFilters.length === 0 && <div className="flex-1" />}
|
||||||
|
|
||||||
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
|
{/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
{/* 데이터 건수 표시 */}
|
{/* 데이터 건수 표시 */}
|
||||||
{currentTable?.dataCount !== undefined && (
|
{currentTable?.dataCount !== undefined && (
|
||||||
|
|
@ -371,38 +400,43 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
{/* 동적 모드일 때만 설정 버튼들 표시 */}
|
||||||
variant="outline"
|
{filterMode === "dynamic" && (
|
||||||
size="sm"
|
<>
|
||||||
onClick={() => setColumnVisibilityOpen(true)}
|
<Button
|
||||||
disabled={!selectedTableId}
|
variant="outline"
|
||||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
size="sm"
|
||||||
>
|
onClick={() => setColumnVisibilityOpen(true)}
|
||||||
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
disabled={!selectedTableId}
|
||||||
테이블 옵션
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
</Button>
|
>
|
||||||
|
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
테이블 옵션
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setFilterOpen(true)}
|
onClick={() => setFilterOpen(true)}
|
||||||
disabled={!selectedTableId}
|
disabled={!selectedTableId}
|
||||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
>
|
>
|
||||||
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
필터 설정
|
필터 설정
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setGroupingOpen(true)}
|
onClick={() => setGroupingOpen(true)}
|
||||||
disabled={!selectedTableId}
|
disabled={!selectedTableId}
|
||||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
>
|
>
|
||||||
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
그룹 설정
|
그룹 설정
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 패널들 */}
|
{/* 패널들 */}
|
||||||
|
|
@ -411,6 +445,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
isOpen={filterOpen}
|
isOpen={filterOpen}
|
||||||
onClose={() => setFilterOpen(false)}
|
onClose={() => setFilterOpen(false)}
|
||||||
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
||||||
|
screenId={screenId}
|
||||||
/>
|
/>
|
||||||
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,126 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
interface TableSearchWidgetConfigPanelProps {
|
interface TableSearchWidgetConfigPanelProps {
|
||||||
component: any;
|
component?: any; // 레거시 지원
|
||||||
onUpdateProperty: (property: string, value: any) => void;
|
config?: any; // 새 인터페이스
|
||||||
|
onUpdateProperty?: (property: string, value: any) => void; // 레거시 지원
|
||||||
|
onChange?: (newConfig: any) => void; // 새 인터페이스
|
||||||
|
tables?: any[]; // 화면의 테이블 정보
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetFilter {
|
||||||
|
id: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
filterType: "text" | "number" | "date" | "select";
|
||||||
|
width?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableSearchWidgetConfigPanel({
|
export function TableSearchWidgetConfigPanel({
|
||||||
component,
|
component,
|
||||||
|
config,
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
|
onChange,
|
||||||
|
tables = [],
|
||||||
}: TableSearchWidgetConfigPanelProps) {
|
}: TableSearchWidgetConfigPanelProps) {
|
||||||
|
// 레거시와 새 인터페이스 모두 지원
|
||||||
|
const currentConfig = config || component?.componentConfig || {};
|
||||||
|
const updateConfig = onChange || ((key: string, value: any) => {
|
||||||
|
if (onUpdateProperty) {
|
||||||
|
onUpdateProperty(`componentConfig.${key}`, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 첫 번째 테이블의 컬럼 목록 가져오기
|
||||||
|
const availableColumns = tables.length > 0 && tables[0].columns ? tables[0].columns : [];
|
||||||
|
|
||||||
|
// inputType에서 filterType 추출 헬퍼 함수
|
||||||
|
const getFilterTypeFromInputType = (inputType: string): "text" | "number" | "date" | "select" => {
|
||||||
|
if (inputType.includes("number") || inputType.includes("decimal") || inputType.includes("integer")) {
|
||||||
|
return "number";
|
||||||
|
}
|
||||||
|
if (inputType.includes("date") || inputType.includes("time")) {
|
||||||
|
return "date";
|
||||||
|
}
|
||||||
|
if (inputType.includes("select") || inputType.includes("dropdown") || inputType.includes("code") || inputType.includes("category")) {
|
||||||
|
return "select";
|
||||||
|
}
|
||||||
|
return "text";
|
||||||
|
};
|
||||||
|
|
||||||
const [localAutoSelect, setLocalAutoSelect] = useState(
|
const [localAutoSelect, setLocalAutoSelect] = useState(
|
||||||
component.componentConfig?.autoSelectFirstTable ?? true
|
currentConfig.autoSelectFirstTable ?? true
|
||||||
);
|
);
|
||||||
const [localShowSelector, setLocalShowSelector] = useState(
|
const [localShowSelector, setLocalShowSelector] = useState(
|
||||||
component.componentConfig?.showTableSelector ?? true
|
currentConfig.showTableSelector ?? true
|
||||||
|
);
|
||||||
|
const [localFilterMode, setLocalFilterMode] = useState<"dynamic" | "preset">(
|
||||||
|
currentConfig.filterMode ?? "dynamic"
|
||||||
|
);
|
||||||
|
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
|
||||||
|
currentConfig.presetFilters ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
|
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
|
||||||
setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
|
setLocalShowSelector(currentConfig.showTableSelector ?? true);
|
||||||
}, [component.componentConfig]);
|
setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
|
||||||
|
setLocalPresetFilters(currentConfig.presetFilters ?? []);
|
||||||
|
}, [currentConfig]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const handleUpdate = (key: string, value: any) => {
|
||||||
|
if (onChange) {
|
||||||
|
// 새 인터페이스: 전체 config 업데이트
|
||||||
|
onChange({ ...currentConfig, [key]: value });
|
||||||
|
} else if (onUpdateProperty) {
|
||||||
|
// 레거시: 개별 속성 업데이트
|
||||||
|
onUpdateProperty(`componentConfig.${key}`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 추가
|
||||||
|
const addFilter = () => {
|
||||||
|
const newFilter: PresetFilter = {
|
||||||
|
id: `filter_${Date.now()}`,
|
||||||
|
columnName: "",
|
||||||
|
columnLabel: "",
|
||||||
|
filterType: "text",
|
||||||
|
width: 200,
|
||||||
|
};
|
||||||
|
const updatedFilters = [...localPresetFilters, newFilter];
|
||||||
|
setLocalPresetFilters(updatedFilters);
|
||||||
|
handleUpdate("presetFilters", updatedFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 삭제
|
||||||
|
const removeFilter = (id: string) => {
|
||||||
|
const updatedFilters = localPresetFilters.filter((f) => f.id !== id);
|
||||||
|
setLocalPresetFilters(updatedFilters);
|
||||||
|
handleUpdate("presetFilters", updatedFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 업데이트
|
||||||
|
const updateFilter = (id: string, field: keyof PresetFilter, value: any) => {
|
||||||
|
const updatedFilters = localPresetFilters.map((f) =>
|
||||||
|
f.id === id ? { ...f, [field]: value } : f
|
||||||
|
);
|
||||||
|
setLocalPresetFilters(updatedFilters);
|
||||||
|
handleUpdate("presetFilters", updatedFilters);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -41,7 +140,7 @@ export function TableSearchWidgetConfigPanel({
|
||||||
checked={localAutoSelect}
|
checked={localAutoSelect}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
setLocalAutoSelect(checked as boolean);
|
setLocalAutoSelect(checked as boolean);
|
||||||
onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
|
handleUpdate("autoSelectFirstTable", checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
|
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
|
||||||
|
|
@ -56,7 +155,7 @@ export function TableSearchWidgetConfigPanel({
|
||||||
checked={localShowSelector}
|
checked={localShowSelector}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
setLocalShowSelector(checked as boolean);
|
setLocalShowSelector(checked as boolean);
|
||||||
onUpdateProperty("componentConfig.showTableSelector", checked);
|
handleUpdate("showTableSelector", checked);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
|
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
|
||||||
|
|
@ -64,12 +163,178 @@ export function TableSearchWidgetConfigPanel({
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 모드 선택 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label className="text-xs sm:text-sm font-medium">필터 모드</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={localFilterMode}
|
||||||
|
onValueChange={(value: "dynamic" | "preset") => {
|
||||||
|
setLocalFilterMode(value);
|
||||||
|
handleUpdate("filterMode", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="dynamic" id="mode-dynamic" />
|
||||||
|
<Label htmlFor="mode-dynamic" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||||
|
동적 모드 (사용자가 필터 설정 버튼으로 선택)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="preset" id="mode-preset" />
|
||||||
|
<Label htmlFor="mode-preset" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||||
|
고정 모드 (디자이너가 미리 필터 지정)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 고정 모드일 때만 필터 설정 UI 표시 */}
|
||||||
|
{localFilterMode === "preset" && (
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm font-medium">고정 필터 목록</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addFilter}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localPresetFilters.length === 0 ? (
|
||||||
|
<div className="rounded-md bg-muted p-3 text-center text-xs text-muted-foreground">
|
||||||
|
필터가 없습니다. 필터 추가 버튼을 클릭하세요.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{localPresetFilters.map((filter) => (
|
||||||
|
<div
|
||||||
|
key={filter.id}
|
||||||
|
className="rounded-md border bg-card p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">필터 설정</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeFilter(filter.id)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs mb-1">컬럼 선택</Label>
|
||||||
|
{availableColumns.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={filter.columnName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
// 선택된 컬럼 정보 가져오기
|
||||||
|
const selectedColumn = availableColumns.find(
|
||||||
|
(col: any) => col.columnName === value
|
||||||
|
);
|
||||||
|
// 컬럼명과 라벨 동시 업데이트
|
||||||
|
const updatedFilters = localPresetFilters.map((f) =>
|
||||||
|
f.id === filter.id
|
||||||
|
? {
|
||||||
|
...f,
|
||||||
|
columnName: value,
|
||||||
|
columnLabel: selectedColumn?.columnLabel || value,
|
||||||
|
filterType: getFilterTypeFromInputType(selectedColumn?.inputType || "text"),
|
||||||
|
}
|
||||||
|
: f
|
||||||
|
);
|
||||||
|
setLocalPresetFilters(updatedFilters);
|
||||||
|
handleUpdate("presetFilters", updatedFilters);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((col: any) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{col.columnLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
({col.columnName})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={filter.columnName}
|
||||||
|
onChange={(e) => updateFilter(filter.id, "columnName", e.target.value)}
|
||||||
|
placeholder="예: customer_name"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filter.columnLabel && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
표시명: {filter.columnLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 타입 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs mb-1">필터 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.filterType}
|
||||||
|
onValueChange={(value: "text" | "number" | "date" | "select") =>
|
||||||
|
updateFilter(filter.id, "filterType", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
<SelectItem value="number">숫자</SelectItem>
|
||||||
|
<SelectItem value="date">날짜</SelectItem>
|
||||||
|
<SelectItem value="select">선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 너비 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[10px] sm:text-xs mb-1">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={filter.width || 200}
|
||||||
|
onChange={(e) => updateFilter(filter.id, "width", parseInt(e.target.value))}
|
||||||
|
placeholder="200"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
min={100}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-md bg-muted p-3 text-xs">
|
<div className="rounded-md bg-muted p-3 text-xs">
|
||||||
<p className="font-medium mb-1">참고사항:</p>
|
<p className="font-medium mb-1">참고사항:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
<li>테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다</li>
|
<li>테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다</li>
|
||||||
<li>여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다</li>
|
<li>여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다</li>
|
||||||
<li>선택한 테이블의 컬럼 정보가 자동으로 로드됩니다</li>
|
{localFilterMode === "dynamic" ? (
|
||||||
|
<li>사용자가 필터 설정 버튼을 클릭하여 원하는 필터를 선택합니다</li>
|
||||||
|
) : (
|
||||||
|
<li>고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시됩니다</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import React from "react";
|
||||||
import { TableSearchWidget } from "./TableSearchWidget";
|
import { TableSearchWidget } from "./TableSearchWidget";
|
||||||
|
|
||||||
export class TableSearchWidgetRenderer {
|
export class TableSearchWidgetRenderer {
|
||||||
static render(component: any) {
|
static render(component: any, props?: any) {
|
||||||
return <TableSearchWidget component={component} />;
|
return <TableSearchWidget component={component} screenId={props?.screenId} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -345,11 +345,11 @@ export class ButtonActionExecutor {
|
||||||
// console.log("👤 [buttonActions] 사용자 정보:", {
|
// console.log("👤 [buttonActions] 사용자 정보:", {
|
||||||
// userId: context.userId,
|
// userId: context.userId,
|
||||||
// userName: context.userName,
|
// userName: context.userName,
|
||||||
// companyCode: context.companyCode, // ✅ 회사 코드
|
// companyCode: context.companyCode,
|
||||||
// formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값
|
// formDataWriter: formData.writer,
|
||||||
// formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값
|
// formDataCompanyCode: formData.company_code,
|
||||||
// defaultWriterValue: writerValue,
|
// defaultWriterValue: writerValue,
|
||||||
// companyCodeValue, // ✅ 최종 회사 코드 값
|
// companyCodeValue,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue