Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
6fe708505a
|
|
@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService";
|
|||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
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
|
||||
*/
|
||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
|
|
@ -184,14 +184,14 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
|
||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`writer LIKE $${paramIndex}`);
|
||||
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
||||
params.push(`%${companyCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (searchText) {
|
||||
whereConditions.push(`objid LIKE $${paramIndex}`);
|
||||
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
|
@ -201,16 +201,47 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
? `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 total = parseInt(countResult.rows[0]?.count || "0");
|
||||
|
||||
// 데이터 쿼리
|
||||
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
||||
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}
|
||||
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}
|
||||
`;
|
||||
|
||||
|
|
@ -219,6 +250,13 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("수주 목록 조회 성공", {
|
||||
companyCode,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
itemCount: dataResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
deleteMenu, // 메뉴 삭제
|
||||
deleteMenusBatch, // 메뉴 일괄 삭제
|
||||
toggleMenuStatus, // 메뉴 상태 토글
|
||||
copyMenu, // 메뉴 복사
|
||||
getUserList,
|
||||
getUserInfo, // 사용자 상세 조회
|
||||
getUserHistory, // 사용자 변경이력 조회
|
||||
|
|
@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus);
|
|||
router.get("/user-menus", getUserMenus);
|
||||
router.get("/menus/:menuId", getMenuInfo);
|
||||
router.post("/menus", saveMenu); // 메뉴 추가
|
||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
|
||||
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
|
||||
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)
|
||||
|
|
|
|||
|
|
@ -320,19 +320,34 @@ export class DynamicFormService {
|
|||
Object.keys(dataToInsert).forEach((key) => {
|
||||
const value = dataToInsert[key];
|
||||
|
||||
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
|
||||
if (
|
||||
// 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열)
|
||||
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" &&
|
||||
value.trim().startsWith("[") &&
|
||||
value.trim().endsWith("]")
|
||||
) {
|
||||
try {
|
||||
const parsedArray = JSON.parse(value);
|
||||
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||
parsedArray = JSON.parse(value);
|
||||
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를 통해)
|
||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||
let targetTable: string | undefined;
|
||||
|
|
@ -352,13 +367,34 @@ export class DynamicFormService {
|
|||
componentId: key,
|
||||
});
|
||||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||||
|
||||
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
||||
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
||||
itemCount: actualData.length,
|
||||
firstItem: actualData[0],
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 🔥 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) => {
|
||||
if (!tableColumns.includes(key)) {
|
||||
|
|
@ -369,9 +405,6 @@ export class DynamicFormService {
|
|||
}
|
||||
});
|
||||
|
||||
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
|
||||
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
|
||||
|
||||
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
||||
tableName,
|
||||
dataToInsert,
|
||||
|
|
@ -452,28 +485,106 @@ export class DynamicFormService {
|
|||
const userId = data.updated_by || data.created_by || "system";
|
||||
const clientIp = ipAddress || "unknown";
|
||||
|
||||
const result = await transaction(async (client) => {
|
||||
// 세션 변수 설정
|
||||
let result: any[];
|
||||
|
||||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||
if (mergedRepeaterData.length > 0) {
|
||||
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
||||
|
||||
result = [];
|
||||
|
||||
for (const repeater of mergedRepeaterData) {
|
||||
for (const item of repeater.data) {
|
||||
// 헤더 + 품목을 병합
|
||||
const rawMergedData = { ...dataToInsert, ...item };
|
||||
|
||||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||||
const mergedData: Record<string, any> = {};
|
||||
|
||||
Object.keys(rawMergedData).forEach((columnName) => {
|
||||
// 실제 테이블 컬럼인지 확인
|
||||
if (validColumnNames.includes(columnName)) {
|
||||
const column = columnInfo.find((col) => col.column_name === columnName);
|
||||
if (column) {
|
||||
// 타입 변환
|
||||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||
rawMergedData[columnName],
|
||||
column.data_type
|
||||
);
|
||||
} else {
|
||||
mergedData[columnName] = rawMergedData[columnName];
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
|
||||
}
|
||||
});
|
||||
|
||||
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];
|
||||
});
|
||||
|
||||
// UPSERT 실행
|
||||
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;
|
||||
|
||||
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
|
||||
if (repeaterData.length > 0) {
|
||||
// 📝 별도 테이블 Repeater 데이터 저장
|
||||
if (separateRepeaterData.length > 0) {
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater`
|
||||
`🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개`
|
||||
);
|
||||
|
||||
for (const repeater of repeaterData) {
|
||||
for (const repeater of separateRepeaterData) {
|
||||
const targetTableName = repeater.targetTable || tableName;
|
||||
console.log(
|
||||
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
||||
|
|
@ -497,8 +608,13 @@ export class DynamicFormService {
|
|||
created_by,
|
||||
updated_by,
|
||||
regdate: new Date(),
|
||||
// 🔥 멀티테넌시: company_code 필수 추가
|
||||
company_code: data.company_code || company_code,
|
||||
};
|
||||
|
||||
// 🔥 별도 테이블인 경우에만 외래키 추가
|
||||
// (같은 테이블이면 이미 병합 모드에서 처리됨)
|
||||
|
||||
// 대상 테이블에 존재하는 컬럼만 필터링
|
||||
Object.keys(itemData).forEach((key) => {
|
||||
if (!targetColumnNames.includes(key)) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -300,10 +300,9 @@ class NumberingRuleService {
|
|||
FROM numbering_rules
|
||||
WHERE
|
||||
scope_type = 'global'
|
||||
OR scope_type = 'table'
|
||||
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 IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
|
|
@ -313,9 +312,9 @@ class NumberingRuleService {
|
|||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -336,10 +335,9 @@ class NumberingRuleService {
|
|||
WHERE company_code = $1
|
||||
AND (
|
||||
scope_type = 'global'
|
||||
OR scope_type = 'table'
|
||||
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 IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -350,7 +348,7 @@ class NumberingRuleService {
|
|||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
}
|
||||
|
||||
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 { MenuTable } from "./MenuTable";
|
||||
import { MenuFormModal } from "./MenuFormModal";
|
||||
import { MenuCopyDialog } from "./MenuCopyDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext";
|
|||
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
|
||||
|
||||
type MenuType = "admin" | "user";
|
||||
|
||||
export const MenuManagement: React.FC = () => {
|
||||
const { adminMenus, userMenus, refreshMenus } = useMenu();
|
||||
const { user } = useAuth(); // 현재 사용자 정보 가져오기
|
||||
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [formModalOpen, setFormModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
||||
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
|
||||
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
||||
|
||||
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
|
||||
|
|
@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => {
|
|||
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
|
||||
const { userLang } = useMultiLang({ companyCode: "*" });
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
|
||||
|
||||
// 다국어 텍스트 상태
|
||||
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||
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) => {
|
||||
try {
|
||||
const response = await menuApi.toggleMenuStatus(menuId);
|
||||
|
|
@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => {
|
|||
title=""
|
||||
onAddMenu={handleAddMenu}
|
||||
onEditMenu={handleEditMenu}
|
||||
onCopyMenu={handleCopyMenu}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
selectedMenus={selectedMenus}
|
||||
onMenuSelectionChange={handleMenuSelectionChange}
|
||||
|
|
@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => {
|
|||
expandedMenus={expandedMenus}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
uiTexts={uiTexts}
|
||||
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<MenuCopyDialog
|
||||
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
|
||||
menuName={selectedMenuName}
|
||||
open={copyDialogOpen}
|
||||
onOpenChange={setCopyDialogOpen}
|
||||
onCopyComplete={handleCopyComplete}
|
||||
/>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface MenuTableProps {
|
|||
title: string;
|
||||
onAddMenu: (parentId: string, menuType: string, level: number) => void;
|
||||
onEditMenu: (menuId: string) => void;
|
||||
onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가
|
||||
onToggleStatus: (menuId: string) => void;
|
||||
selectedMenus: Set<string>;
|
||||
onMenuSelectionChange: (menuId: string, checked: boolean) => void;
|
||||
|
|
@ -22,6 +23,7 @@ interface MenuTableProps {
|
|||
onToggleExpand: (menuId: string) => void;
|
||||
// 다국어 텍스트 props 추가
|
||||
uiTexts: Record<string, string>;
|
||||
isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가
|
||||
}
|
||||
|
||||
export const MenuTable: React.FC<MenuTableProps> = ({
|
||||
|
|
@ -29,6 +31,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
title,
|
||||
onAddMenu,
|
||||
onEditMenu,
|
||||
onCopyMenu,
|
||||
onToggleStatus,
|
||||
selectedMenus,
|
||||
onMenuSelectionChange,
|
||||
|
|
@ -36,6 +39,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
expandedMenus,
|
||||
onToggleExpand,
|
||||
uiTexts,
|
||||
isSuperAdmin = false, // 기본값 false
|
||||
}) => {
|
||||
// 다국어 텍스트 가져오기 함수
|
||||
const getText = (key: string, fallback?: string): string => {
|
||||
|
|
@ -281,6 +285,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
<TableCell className="h-16">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{lev === 1 && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -289,6 +294,17 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
>
|
||||
{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 && (
|
||||
<>
|
||||
|
|
@ -308,9 +324,20 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lev > 2 && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -319,6 +346,17 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
>
|
||||
{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>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
||||
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
|
||||
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(null); // 드래그 중인 Location 정보
|
||||
const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
|
@ -779,9 +780,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return;
|
||||
}
|
||||
|
||||
// 부모 ID 설정
|
||||
// 부모 ID 설정 및 논리적 유효성 검사
|
||||
if (validation.parent) {
|
||||
// 1. 부모 객체 찾기
|
||||
const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우)
|
||||
if (parentObj && parentObj.externalKey && newObject.parentKey) {
|
||||
if (parentObj.externalKey !== newObject.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newObject.parentId = validation.parent.id;
|
||||
} else if (newObject.parentKey) {
|
||||
// DB 데이터인데 부모 영역 위에 놓이지 않은 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -810,7 +834,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
|
||||
// Location의 자재 목록 로드
|
||||
const loadMaterialsForLocation = async (locaKey: string) => {
|
||||
console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material });
|
||||
|
||||
if (!selectedDbConnection || !hierarchyConfig?.material) {
|
||||
console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "자재 조회 실패",
|
||||
|
|
@ -822,10 +849,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
setLoadingMaterials(true);
|
||||
setShowMaterialPanel(true);
|
||||
const response = await getMaterials(selectedDbConnection, {
|
||||
|
||||
const materialConfig = {
|
||||
...hierarchyConfig.material,
|
||||
locaKey: locaKey,
|
||||
});
|
||||
};
|
||||
console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig });
|
||||
|
||||
const response = await getMaterials(selectedDbConnection, materialConfig);
|
||||
console.log("📦 API 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
// layerColumn이 있으면 정렬
|
||||
const sortedMaterials = hierarchyConfig.material.layerColumn
|
||||
|
|
@ -965,7 +997,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return obj;
|
||||
});
|
||||
|
||||
// 2. 그룹 이동: 자식 객체들도 함께 이동
|
||||
// 2. 하위 계층 객체 이동 시 논리적 키 검증
|
||||
if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) {
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
size: obj.size,
|
||||
hierarchyLevel: obj.hierarchyLevel || 1,
|
||||
parentId: obj.parentId,
|
||||
}));
|
||||
|
||||
const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId);
|
||||
if (targetSpatialObj) {
|
||||
const validation = validateSpatialContainment(
|
||||
targetSpatialObj,
|
||||
spatialObjects.filter((obj) => obj.id !== objectId),
|
||||
);
|
||||
|
||||
// 새로운 부모 영역 찾기
|
||||
if (validation.parent) {
|
||||
const newParentObj = prev.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// DB에서 가져온 데이터인 경우 논리적 키 검증
|
||||
if (newParentObj && newParentObj.externalKey && targetObj.parentKey) {
|
||||
if (newParentObj.externalKey !== targetObj.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 업데이트
|
||||
updatedObjects = updatedObjects.map((obj) => {
|
||||
if (obj.id === objectId) {
|
||||
return { ...obj, parentId: validation.parent!.id };
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
} else if (targetObj.parentKey) {
|
||||
// DB 데이터인데 부모 영역 밖으로 이동하려는 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 그룹 이동: 자식 객체들도 함께 이동
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
|
|
@ -1493,13 +1577,22 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 배치된 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* 배치된 객체 목록 (계층 구조) */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
||||
|
||||
{placedObjects.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
||||
) : (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{/* Area별로 그룹핑 */}
|
||||
{(() => {
|
||||
// Area 객체들
|
||||
const areaObjects = placedObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
// Area가 없으면 기존 방식으로 표시
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{placedObjects.map((obj) => (
|
||||
<div
|
||||
|
|
@ -1516,19 +1609,116 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{obj.areaKey && <p className="text-muted-foreground mt-1 text-xs">Area: {obj.areaKey}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area별로 Location들을 그룹핑
|
||||
return areaObjects.map((areaObj) => {
|
||||
// 이 Area의 자식 Location들 찾기
|
||||
const childLocations = placedObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: areaObj.color }} />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Key: {locationObj.locaKey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div className="relative h-full flex-1 bg-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
previewTool={draggedTool}
|
||||
previewPosition={previewPosition}
|
||||
onPreviewPositionUpdate={setPreviewPosition}
|
||||
/>
|
||||
{/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */}
|
||||
{draggedTool && (
|
||||
<div
|
||||
className="h-full flex-1 bg-gray-100"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
className="pointer-events-auto absolute inset-0"
|
||||
style={{ zIndex: 10 }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
||||
|
|
@ -1538,7 +1728,6 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const gridSize = 5;
|
||||
|
||||
// 그리드에 스냅
|
||||
// Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
|
||||
|
|
@ -1548,23 +1737,26 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
snappedZ += gridSize / 2;
|
||||
}
|
||||
|
||||
handleCanvasDrop(snappedX, snappedZ);
|
||||
setPreviewPosition({ x: snappedX, z: snappedZ });
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setPreviewPosition(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (previewPosition) {
|
||||
handleCanvasDrop(previewPosition.x, previewPosition.z);
|
||||
setPreviewPosition(null);
|
||||
}
|
||||
setDraggedTool(null);
|
||||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 객체 속성 편집 or 자재 목록 */}
|
||||
|
|
|
|||
|
|
@ -78,37 +78,52 @@ export default function HierarchyConfigPanel({
|
|||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
|
||||
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
if (hierarchyConfig) {
|
||||
setLocalConfig(hierarchyConfig);
|
||||
|
||||
// 저장된 설정이 있으면 해당 테이블들의 컬럼을 자동 로드
|
||||
// 저장된 설정의 테이블들에 대한 컬럼 자동 로드
|
||||
const loadSavedColumns = async () => {
|
||||
// 창고 테이블 컬럼 로드
|
||||
const tablesToLoad: string[] = [];
|
||||
|
||||
// 창고 테이블
|
||||
if (hierarchyConfig.warehouse?.tableName) {
|
||||
await handleTableChange(hierarchyConfig.warehouse.tableName, "warehouse");
|
||||
tablesToLoad.push(hierarchyConfig.warehouse.tableName);
|
||||
}
|
||||
|
||||
// 레벨 테이블 컬럼 로드
|
||||
if (hierarchyConfig.levels) {
|
||||
for (const level of hierarchyConfig.levels) {
|
||||
// 계층 레벨 테이블들
|
||||
hierarchyConfig.levels?.forEach((level) => {
|
||||
if (level.tableName) {
|
||||
await handleTableChange(level.tableName, level.level);
|
||||
}
|
||||
tablesToLoad.push(level.tableName);
|
||||
}
|
||||
});
|
||||
|
||||
// 자재 테이블
|
||||
if (hierarchyConfig.material?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.material.tableName);
|
||||
}
|
||||
|
||||
// 자재 테이블 컬럼 로드
|
||||
if (hierarchyConfig.material?.tableName) {
|
||||
await handleTableChange(hierarchyConfig.material.tableName, "material");
|
||||
// 중복 제거 후 로드
|
||||
const uniqueTables = [...new Set(tablesToLoad)];
|
||||
for (const tableName of uniqueTables) {
|
||||
if (!columnsCache[tableName]) {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
loadSavedColumns();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hierarchyConfig]);
|
||||
}
|
||||
}, [hierarchyConfig, externalDbConnectionId]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
||||
|
|
@ -229,7 +244,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{table.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -237,7 +252,7 @@ export default function HierarchyConfigPanel({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{!localConfig.warehouse?.tableName && (
|
||||
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-1 text-[9px]">
|
||||
ℹ️ 창고 테이블을 선택하고 "설정 적용"을 눌러주세요
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -261,7 +276,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[8px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -285,7 +300,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[8px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -349,7 +364,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-[10px] text-muted-foreground">{table.description}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -375,7 +390,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -399,7 +414,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -423,7 +438,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -450,7 +465,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -496,7 +511,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-[10px] text-muted-foreground">{table.description}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -522,7 +537,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -546,7 +561,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -571,7 +586,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -596,7 +611,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[9px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -634,7 +649,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex w-24 shrink-0 flex-col">
|
||||
<span className="text-[10px]">{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-[8px] text-muted-foreground">{col.description}</span>
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ interface Yard3DCanvasProps {
|
|||
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
||||
previewTool?: string | null; // 드래그 중인 도구 타입
|
||||
previewPosition?: { x: number; z: number } | null; // 프리뷰 위치
|
||||
onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void;
|
||||
}
|
||||
|
||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||
|
|
@ -1066,10 +1069,26 @@ function Scene({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
}: Yard3DCanvasProps) {
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
|
||||
// 프리뷰 박스 크기 계산
|
||||
const getPreviewSize = (tool: string) => {
|
||||
if (tool === "area") return { x: 20, y: 0.1, z: 20 };
|
||||
return { x: 5, y: 5, z: 5 };
|
||||
};
|
||||
|
||||
// 프리뷰 박스 색상
|
||||
const getPreviewColor = (tool: string) => {
|
||||
if (tool === "area") return "#3b82f6";
|
||||
if (tool === "location-bed") return "#10b981";
|
||||
if (tool === "location-stp") return "#f59e0b";
|
||||
return "#9ca3af";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 카메라 포커스 컨트롤러 */}
|
||||
|
|
@ -1128,6 +1147,30 @@ function Scene({
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 드래그 프리뷰 박스 */}
|
||||
{previewTool && previewPosition && (
|
||||
<Box
|
||||
args={[
|
||||
getPreviewSize(previewTool).x,
|
||||
getPreviewSize(previewTool).y,
|
||||
getPreviewSize(previewTool).z,
|
||||
]}
|
||||
position={[
|
||||
previewPosition.x,
|
||||
previewTool === "area" ? 0.05 : getPreviewSize(previewTool).y / 2,
|
||||
previewPosition.z,
|
||||
]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={getPreviewColor(previewTool)}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
roughness={0.5}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 카메라 컨트롤 */}
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
|
|
@ -1154,6 +1197,9 @@ export default function Yard3DCanvas({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
onPreviewPositionUpdate,
|
||||
}: Yard3DCanvasProps) {
|
||||
const handleCanvasClick = (e: any) => {
|
||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||
|
|
@ -1182,6 +1228,8 @@ export default function Yard3DCanvas({
|
|||
gridSize={gridSize}
|
||||
onCollisionDetected={onCollisionDetected}
|
||||
focusOnPlacementId={focusOnPlacementId}
|
||||
previewTool={previewTool}
|
||||
previewPosition={previewPosition}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
|
|
|||
|
|
@ -57,16 +57,18 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 추가
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록)
|
||||
const continuousModeRef = useRef(false);
|
||||
const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음)
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
// localStorage에서 연속 모드 상태 복원
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
continuousModeRef.current = true;
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
setContinuousMode(true);
|
||||
console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -162,29 +164,39 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
continuousModeRef.current = false;
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
// console.log("🔄 연속 모드 초기화: false");
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
};
|
||||
|
||||
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
|
||||
const handleSaveSuccess = () => {
|
||||
const isContinuousMode = continuousModeRef.current;
|
||||
// console.log("💾 저장 성공 이벤트 수신");
|
||||
// console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode);
|
||||
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
const isContinuousMode = continuousMode;
|
||||
console.log("💾 저장 성공 이벤트 수신");
|
||||
console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
// console.log("✅ 연속 모드 활성화 - 폼만 초기화");
|
||||
console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
|
||||
|
||||
// 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨)
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey(prev => prev + 1);
|
||||
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
loadScreenData(modalState.screenId);
|
||||
}
|
||||
|
||||
toast.success("저장되었습니다. 계속 입력하세요.");
|
||||
} else {
|
||||
// 일반 모드: 모달 닫기
|
||||
// console.log("❌ 일반 모드 - 모달 닫기");
|
||||
console.log("❌ 일반 모드 - 모달 닫기");
|
||||
handleCloseModal();
|
||||
}
|
||||
};
|
||||
|
|
@ -198,7 +210,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.removeEventListener("closeSaveModal", handleCloseModal);
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, []); // 의존성 제거 (ref 사용으로 최신 상태 참조)
|
||||
}, [continuousMode]); // continuousMode 의존성 추가
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -415,18 +427,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setFormData({}); // 폼 데이터 초기화
|
||||
};
|
||||
|
||||
// 모달 크기 설정 - 화면 내용에 맞게 동적 조정
|
||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
||||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: {},
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
};
|
||||
}
|
||||
|
||||
// 헤더 높이를 최소화 (제목 영역만)
|
||||
const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩)
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
|
|
@ -504,7 +519,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
style={modalStyle.style}
|
||||
{...(modalStyle.style && { style: modalStyle.style })} // undefined일 때는 prop 자체를 전달하지 않음
|
||||
defaultWidth={600}
|
||||
defaultHeight={800}
|
||||
minWidth={500}
|
||||
|
|
@ -530,7 +545,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</ResizableDialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -568,7 +583,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
|
|
@ -607,13 +622,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="continuous-mode"
|
||||
checked={continuousModeRef.current}
|
||||
checked={continuousMode}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true;
|
||||
continuousModeRef.current = isChecked;
|
||||
setContinuousMode(isChecked);
|
||||
localStorage.setItem("screenModal_continuousMode", String(isChecked));
|
||||
setForceUpdate((prev) => prev + 1); // 체크박스 UI 업데이트를 위한 강제 리렌더링
|
||||
// console.log("🔄 연속 모드 변경:", isChecked);
|
||||
console.log("🔄 연속 모드 변경:", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -24,6 +24,8 @@ interface EditModalState {
|
|||
modalSize: "sm" | "md" | "lg" | "xl";
|
||||
editData: Record<string, any>;
|
||||
onSave?: () => void;
|
||||
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
|
||||
tableName?: string; // 🆕 테이블명 (그룹 조회용)
|
||||
}
|
||||
|
||||
interface EditModalProps {
|
||||
|
|
@ -40,6 +42,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
modalSize: "md",
|
||||
editData: {},
|
||||
onSave: undefined,
|
||||
groupByColumns: undefined,
|
||||
tableName: undefined,
|
||||
});
|
||||
|
||||
const [screenData, setScreenData] = useState<{
|
||||
|
|
@ -59,6 +63,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||
|
||||
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
if (components.length === 0) {
|
||||
|
|
@ -92,25 +100,25 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
|
||||
// 적절한 여백 추가
|
||||
const paddingX = 40;
|
||||
const paddingY = 40;
|
||||
// 적절한 여백 추가 (주석처리 - 사용자 설정 크기 그대로 사용)
|
||||
// const paddingX = 40;
|
||||
// const paddingY = 40;
|
||||
|
||||
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
||||
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
||||
const finalWidth = Math.max(contentWidth, 400); // padding 제거
|
||||
const finalHeight = Math.max(contentHeight, 300); // padding 제거
|
||||
|
||||
return {
|
||||
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
||||
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
||||
offsetX: Math.max(0, minX - paddingX / 2),
|
||||
offsetY: Math.max(0, minY - paddingY / 2),
|
||||
offsetX: Math.max(0, minX), // paddingX 제거
|
||||
offsetY: Math.max(0, minY), // paddingY 제거
|
||||
};
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail;
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
|
|
@ -120,6 +128,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
modalSize: modalSize || "lg",
|
||||
editData: editData || {},
|
||||
onSave,
|
||||
groupByColumns, // 🆕 그룹핑 컬럼
|
||||
tableName, // 🆕 테이블명
|
||||
});
|
||||
|
||||
// 편집 데이터로 폼 데이터 초기화
|
||||
|
|
@ -154,9 +164,78 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
useEffect(() => {
|
||||
if (modalState.isOpen && modalState.screenId) {
|
||||
loadScreenData(modalState.screenId);
|
||||
|
||||
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
|
||||
loadGroupData();
|
||||
}
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId]);
|
||||
|
||||
// 🆕 그룹 데이터 조회 함수
|
||||
const loadGroupData = async () => {
|
||||
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
|
||||
console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 그룹 데이터 조회 시작:", {
|
||||
tableName: modalState.tableName,
|
||||
groupByColumns: modalState.groupByColumns,
|
||||
editData: modalState.editData,
|
||||
});
|
||||
|
||||
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
|
||||
const groupValues: Record<string, any> = {};
|
||||
modalState.groupByColumns.forEach((column) => {
|
||||
if (modalState.editData[column]) {
|
||||
groupValues[column] = modalState.editData[column];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(groupValues).length === 0) {
|
||||
console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔍 그룹 조회 요청:", {
|
||||
tableName: modalState.tableName,
|
||||
groupValues,
|
||||
});
|
||||
|
||||
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
search: groupValues, // search 파라미터로 전달 (백엔드에서 WHERE 조건으로 처리)
|
||||
enableEntityJoin: true,
|
||||
});
|
||||
|
||||
console.log("🔍 그룹 조회 응답:", response);
|
||||
|
||||
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
||||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray);
|
||||
setGroupData(dataArray);
|
||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||
} else {
|
||||
console.warn("그룹 데이터가 없습니다:", response);
|
||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 그룹 데이터 조회 오류:", error);
|
||||
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadScreenData = async (screenId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
|
@ -208,10 +287,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
modalSize: "md",
|
||||
editData: {},
|
||||
onSave: undefined,
|
||||
groupByColumns: undefined,
|
||||
tableName: undefined,
|
||||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setOriginalData({});
|
||||
setGroupData([]); // 🆕
|
||||
setOriginalGroupData([]); // 🆕
|
||||
};
|
||||
|
||||
// 저장 버튼 클릭 시 - UPDATE 액션 실행
|
||||
|
|
@ -222,8 +305,105 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정
|
||||
if (groupData.length > 0) {
|
||||
console.log("🔄 그룹 데이터 일괄 수정 시작:", {
|
||||
groupDataLength: groupData.length,
|
||||
originalGroupDataLength: originalGroupData.length,
|
||||
});
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (let i = 0; i < groupData.length; i++) {
|
||||
const currentData = groupData[i];
|
||||
const originalItemData = originalGroupData[i];
|
||||
|
||||
if (!originalItemData) {
|
||||
console.warn(`원본 데이터가 없습니다 (index: ${i})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const changedData: Record<string, any> = {};
|
||||
|
||||
// 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외)
|
||||
const salesOrderColumns = [
|
||||
"id",
|
||||
"order_no",
|
||||
"customer_code",
|
||||
"customer_name",
|
||||
"order_date",
|
||||
"delivery_date",
|
||||
"item_code",
|
||||
"quantity",
|
||||
"unit_price",
|
||||
"amount",
|
||||
"status",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"company_code",
|
||||
];
|
||||
|
||||
Object.keys(currentData).forEach((key) => {
|
||||
// sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외)
|
||||
if (!salesOrderColumns.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentData[key] !== originalItemData[key]) {
|
||||
changedData[key] = currentData[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 변경사항이 없으면 스킵
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
console.log(`변경사항 없음 (index: ${i})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 기본키 확인
|
||||
const recordId = originalItemData.id || Object.values(originalItemData)[0];
|
||||
|
||||
// UPDATE 실행
|
||||
const response = await dynamicFormApi.updateFormDataPartial(
|
||||
recordId,
|
||||
originalItemData,
|
||||
changedData,
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
updatedCount++;
|
||||
console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`);
|
||||
} else {
|
||||
console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
toast.success(`${updatedCount}개의 품목이 수정되었습니다.`);
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("⚠️ onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
toast.info("변경된 내용이 없습니다.");
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 로직: 단일 레코드 수정
|
||||
const changedData: Record<string, any> = {};
|
||||
Object.keys(formData).forEach((key) => {
|
||||
if (formData[key] !== originalData[key]) {
|
||||
changedData[key] = formData[key];
|
||||
|
|
@ -269,16 +449,18 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 모달 크기 설정 - ScreenModal과 동일
|
||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더
|
||||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: {},
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
};
|
||||
}
|
||||
|
||||
const headerHeight = 60;
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
|
||||
return {
|
||||
|
|
@ -339,6 +521,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
|
||||
{groupData.length > 1 && (
|
||||
<div className="absolute left-4 top-4 z-10 rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700 shadow-sm">
|
||||
{groupData.length}개의 관련 품목을 함께 수정합니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
|
|
@ -353,23 +542,51 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
|
||||
if (component.id === screenData.components[0]?.id) {
|
||||
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
|
||||
componentId: component.id,
|
||||
groupDataLength: groupData.length,
|
||||
groupData: groupData,
|
||||
formData: groupData.length > 0 ? groupData[0] : formData,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
formData={groupData.length > 0 ? groupData[0] : formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// 🆕 그룹 데이터가 있으면 처리
|
||||
if (groupData.length > 0) {
|
||||
// ModalRepeaterTable의 경우 배열 전체를 받음
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
// 일반 필드는 모든 항목에 동일하게 적용
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
onSave={handleSave}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupData.length > 0 ? groupData : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ interface InteractiveScreenViewerProps {
|
|||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
// 🆕 그룹 데이터 (EditModal에서 전달)
|
||||
groupedData?: Record<string, any>[];
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -61,6 +63,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
userId: externalUserId,
|
||||
userName: externalUserName,
|
||||
companyCode: externalCompanyCode,
|
||||
groupedData,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
|
@ -332,6 +335,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
|
||||
groupedData={groupedData}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData, stepId) => {
|
||||
|
|
|
|||
|
|
@ -216,10 +216,16 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
return y + height;
|
||||
}));
|
||||
|
||||
const padding = 40;
|
||||
// 컨텐츠 영역 크기 (화면관리 설정 크기)
|
||||
const contentWidth = Math.max(maxX, 400);
|
||||
const contentHeight = Math.max(maxY, 300);
|
||||
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
|
||||
return {
|
||||
width: Math.max(maxX + padding, 400),
|
||||
height: Math.max(maxY + padding, 300),
|
||||
width: contentWidth,
|
||||
height: contentHeight + headerHeight, // 헤더 높이 포함
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -229,8 +235,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<ResizableDialogContent
|
||||
modalId={`save-modal-${screenId}`}
|
||||
defaultWidth={dynamicSize.width + 48}
|
||||
defaultHeight={dynamicSize.height + 120}
|
||||
style={{
|
||||
width: `${dynamicSize.width}px`,
|
||||
height: `${dynamicSize.height}px`, // 화면관리 설정 크기 그대로 사용
|
||||
}}
|
||||
defaultWidth={600} // 폴백용 기본값
|
||||
defaultHeight={400} // 폴백용 기본값
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
className="gap-0 p-0"
|
||||
|
|
@ -337,12 +347,22 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
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}
|
||||
isInteractive={true}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -119,6 +119,25 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const [localHeight, setLocalHeight] = 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 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.type === "component") {
|
||||
|
|
@ -279,14 +298,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
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 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id;
|
||||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
|
@ -318,7 +341,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ interface Props {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
||||
screenId?: number; // 화면 ID 추가
|
||||
}
|
||||
|
||||
// 필터 타입별 연산자
|
||||
|
|
@ -69,7 +70,7 @@ interface ColumnFilterConfig {
|
|||
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 table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
|
|
@ -79,7 +80,10 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
// localStorage에서 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
let filters: ColumnFilterConfig[];
|
||||
|
|
@ -192,9 +196,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||
}));
|
||||
|
||||
// localStorage에 저장
|
||||
// localStorage에 저장 (화면별로 독립적)
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -216,9 +222,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
setColumnFilters(clearedFilters);
|
||||
setSelectAll(false);
|
||||
|
||||
// localStorage에서 제거
|
||||
// localStorage에서 제거 (화면별로 독립적)
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ const ResizableDialogContent = React.forwardRef<
|
|||
|
||||
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
|
||||
if (userStyle) {
|
||||
console.log("🔍 userStyle 감지:", userStyle);
|
||||
console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width);
|
||||
console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height);
|
||||
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
|
|
@ -129,24 +133,41 @@ const ResizableDialogContent = React.forwardRef<
|
|||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
console.log("📏 파싱된 크기:", {
|
||||
styleWidth,
|
||||
styleHeight,
|
||||
"styleWidth truthy?": !!styleWidth,
|
||||
"styleHeight truthy?": !!styleHeight,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight
|
||||
});
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
return {
|
||||
const finalSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
console.log("✅ userStyle 크기 사용:", finalSize);
|
||||
return finalSize;
|
||||
} else {
|
||||
console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight });
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용
|
||||
if (contentRef.current) {
|
||||
const rect = contentRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
return {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight });
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
|
||||
// if (contentRef.current) {
|
||||
// const rect = contentRef.current.getBoundingClientRect();
|
||||
// if (rect.width > 0 && rect.height > 0) {
|
||||
// return {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
// 3순위: defaultWidth/defaultHeight 사용
|
||||
return { width: defaultWidth, height: defaultHeight };
|
||||
|
|
@ -156,6 +177,58 @@ const ResizableDialogContent = React.forwardRef<
|
|||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const [resizeDirection, setResizeDirection] = React.useState<string>("");
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
|
||||
// userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시)
|
||||
React.useEffect(() => {
|
||||
// 1. localStorage에서 사용자가 리사이징한 크기 확인
|
||||
let savedSize: { width: number; height: number; userResized: boolean } | null = null;
|
||||
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.userResized) {
|
||||
savedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
userResized: true,
|
||||
};
|
||||
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 모달 크기 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 우선순위: 사용자 리사이징 > userStyle > 기본값
|
||||
if (savedSize && savedSize.userResized) {
|
||||
// 사용자가 리사이징한 크기 우선
|
||||
setSize({ width: savedSize.width, height: savedSize.height });
|
||||
setUserResized(true);
|
||||
console.log("✅ 사용자 리사이징 크기 적용:", savedSize);
|
||||
} else if (userStyle && userStyle.width && userStyle.height) {
|
||||
// 화면관리에서 설정한 크기
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
const styleHeight = typeof userStyle.height === 'string'
|
||||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const newSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
console.log("🔄 userStyle 크기 적용:", newSize);
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
}, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]);
|
||||
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
|
||||
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
|
||||
|
||||
|
|
@ -192,97 +265,98 @@ const ResizableDialogContent = React.forwardRef<
|
|||
}, [effectiveModalId, lastModalId, isInitialized]);
|
||||
|
||||
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
|
||||
React.useEffect(() => {
|
||||
// console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
||||
|
||||
if (!isInitialized) {
|
||||
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
||||
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const measureContent = () => {
|
||||
attempts++;
|
||||
|
||||
// scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
||||
let contentWidth = defaultWidth;
|
||||
let contentHeight = defaultHeight;
|
||||
|
||||
if (contentRef.current) {
|
||||
// scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
||||
contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
||||
contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
||||
|
||||
// console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
||||
} else {
|
||||
// console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
||||
|
||||
// contentRef가 아직 없으면 재시도
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(measureContent, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 패딩 추가 (p-6 * 2 = 48px)
|
||||
const paddingAndMargin = 48;
|
||||
const initialSize = getInitialSize();
|
||||
|
||||
// 내용 크기 기반 최소 크기 계산
|
||||
const contentBasedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
||||
};
|
||||
|
||||
// console.log("📐 내용 기반 크기:", contentBasedSize);
|
||||
|
||||
// localStorage에서 저장된 크기 확인
|
||||
let finalSize = contentBasedSize;
|
||||
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
|
||||
// console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
||||
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
// userResized 플래그 확인
|
||||
if (parsed.userResized) {
|
||||
const savedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
};
|
||||
|
||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
|
||||
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
||||
// (사용자가 의도적으로 작게 만든 것을 존중)
|
||||
finalSize = savedSize;
|
||||
setUserResized(true);
|
||||
|
||||
// console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
||||
} else {
|
||||
// console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
||||
}
|
||||
} else {
|
||||
// console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("❌ 모달 크기 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setSize(finalSize);
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
// 첫 시도는 300ms 후에 시작
|
||||
setTimeout(measureContent, 300);
|
||||
}
|
||||
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
||||
// 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경
|
||||
// React.useEffect(() => {
|
||||
// // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
||||
//
|
||||
// if (!isInitialized) {
|
||||
// // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
||||
// // 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
||||
// let attempts = 0;
|
||||
// const maxAttempts = 10;
|
||||
//
|
||||
// const measureContent = () => {
|
||||
// attempts++;
|
||||
//
|
||||
// // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
||||
// let contentWidth = defaultWidth;
|
||||
// let contentHeight = defaultHeight;
|
||||
//
|
||||
// // if (contentRef.current) {
|
||||
// // // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
||||
// // contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
||||
// // contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
||||
// //
|
||||
// // // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
||||
// // } else {
|
||||
// // // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
||||
// //
|
||||
// // // contentRef가 아직 없으면 재시도
|
||||
// // if (attempts < maxAttempts) {
|
||||
// // setTimeout(measureContent, 100);
|
||||
// // return;
|
||||
// // }
|
||||
// // }
|
||||
//
|
||||
// // 패딩 추가 (p-6 * 2 = 48px)
|
||||
// const paddingAndMargin = 48;
|
||||
// const initialSize = getInitialSize();
|
||||
//
|
||||
// // 내용 크기 기반 최소 크기 계산
|
||||
// const contentBasedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
||||
// };
|
||||
//
|
||||
// // console.log("📐 내용 기반 크기:", contentBasedSize);
|
||||
//
|
||||
// // localStorage에서 저장된 크기 확인
|
||||
// let finalSize = contentBasedSize;
|
||||
//
|
||||
// if (effectiveModalId && typeof window !== 'undefined') {
|
||||
// try {
|
||||
// const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
// const saved = localStorage.getItem(storageKey);
|
||||
//
|
||||
// // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
||||
//
|
||||
// if (saved) {
|
||||
// const parsed = JSON.parse(saved);
|
||||
//
|
||||
// // userResized 플래그 확인
|
||||
// if (parsed.userResized) {
|
||||
// const savedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
// };
|
||||
//
|
||||
// // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
//
|
||||
// // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
||||
// // (사용자가 의도적으로 작게 만든 것을 존중)
|
||||
// finalSize = savedSize;
|
||||
// setUserResized(true);
|
||||
//
|
||||
// // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
||||
// } else {
|
||||
// // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
||||
// }
|
||||
// } else {
|
||||
// // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // console.error("❌ 모달 크기 복원 실패:", error);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// setSize(finalSize);
|
||||
// setIsInitialized(true);
|
||||
// };
|
||||
//
|
||||
// // 첫 시도는 300ms 후에 시작
|
||||
// setTimeout(measureContent, 300);
|
||||
// }
|
||||
// }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
||||
|
||||
const startResize = (direction: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -433,6 +507,37 @@ const ResizableDialogContent = React.forwardRef<
|
|||
onMouseDown={startResize("nw")}
|
||||
/>
|
||||
|
||||
{/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */}
|
||||
{userResized && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// localStorage에서 저장된 크기 삭제
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
console.log("🗑️ 저장된 모달 크기 삭제:", storageKey);
|
||||
}
|
||||
|
||||
// 화면관리 설정 크기로 복원
|
||||
const initialSize = getInitialSize();
|
||||
setSize(initialSize);
|
||||
setUserResized(false);
|
||||
console.log("🔄 기본 크기로 리셋:", initialSize);
|
||||
}}
|
||||
className="absolute right-12 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
style={{ zIndex: 20 }}
|
||||
title="기본 크기로 리셋"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M3 21v-5h5"/>
|
||||
</svg>
|
||||
<span className="sr-only">기본 크기로 리셋</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
style={{ zIndex: 20 }}
|
||||
|
|
|
|||
|
|
@ -162,4 +162,47 @@ export const menuApi = {
|
|||
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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export interface DynamicComponentRendererProps {
|
|||
onClose?: () => void;
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
// 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
|
||||
groupedData?: Record<string, any>[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
|
|
@ -150,7 +152,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const columnName = (component as any).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 {
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const fieldName = columnName || component.id;
|
||||
|
|
@ -213,6 +218,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||
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) {
|
||||
// 새 컴포넌트 시스템으로 렌더링
|
||||
try {
|
||||
|
|
@ -266,7 +281,17 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||
let currentValue;
|
||||
if (componentType === "modal-repeater-table") {
|
||||
currentValue = formData?.[fieldName] || [];
|
||||
// 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||
|
||||
// 디버깅 로그
|
||||
console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", {
|
||||
hasGroupedData: !!props.groupedData,
|
||||
groupedDataLength: props.groupedData?.length || 0,
|
||||
fieldName,
|
||||
formDataValue: formData?.[fieldName],
|
||||
finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0,
|
||||
});
|
||||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
|
@ -367,6 +392,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
isPreview,
|
||||
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분
|
||||
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
|
||||
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
|
||||
groupedData: props.groupedData,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
|
|
|
|||
|
|
@ -7,18 +7,23 @@ import { Button } from "@/components/ui/button";
|
|||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||
import { EntitySearchResult } from "../entity-search-input/types";
|
||||
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;
|
||||
tableName?: string;
|
||||
displayField?: string;
|
||||
valueField?: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
value?: any;
|
||||
onChange?: (value: any, fullData?: any) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
}
|
||||
|
||||
export function AutocompleteSearchInputComponent({
|
||||
component,
|
||||
config,
|
||||
tableName: propTableName,
|
||||
displayField: propDisplayField,
|
||||
|
|
@ -29,9 +34,10 @@ export function AutocompleteSearchInputComponent({
|
|||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
showAdditionalInfo: propShowAdditionalInfo,
|
||||
additionalFields: propAdditionalFields,
|
||||
className,
|
||||
isInteractive = false,
|
||||
onFormDataChange,
|
||||
formData,
|
||||
}: AutocompleteSearchInputProps) {
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const tableName = config?.tableName || propTableName || "";
|
||||
|
|
@ -39,8 +45,7 @@ export function AutocompleteSearchInputComponent({
|
|||
const valueField = config?.valueField || propValueField || "";
|
||||
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
||||
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
||||
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
|
||||
const additionalFields = config?.additionalFields || propAdditionalFields || [];
|
||||
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||
|
|
@ -52,15 +57,20 @@ export function AutocompleteSearchInputComponent({
|
|||
filterCondition,
|
||||
});
|
||||
|
||||
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
||||
const currentValue = isInteractive && formData && component?.columnName
|
||||
? formData[component.columnName]
|
||||
: value;
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
if (currentValue && selectedData) {
|
||||
setInputValue(selectedData[displayField] || "");
|
||||
} else if (!value) {
|
||||
} else if (!currentValue) {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
}, [currentValue, displayField, selectedData]);
|
||||
|
||||
// 외부 클릭 감지
|
||||
useEffect(() => {
|
||||
|
|
@ -81,45 +91,61 @@ export function AutocompleteSearchInputComponent({
|
|||
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) => {
|
||||
setSelectedData(item);
|
||||
setInputValue(item[displayField] || "");
|
||||
onChange?.(item[valueField], item);
|
||||
|
||||
// 필드 자동 매핑 실행
|
||||
applyFieldMappings(item);
|
||||
console.log("🔍 AutocompleteSearchInput handleSelect:", {
|
||||
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);
|
||||
};
|
||||
|
|
@ -149,9 +175,9 @@ export function AutocompleteSearchInputComponent({
|
|||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
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 !bg-background"
|
||||
/>
|
||||
<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 && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
|
@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({
|
|||
|
||||
{/* 드롭다운 결과 */}
|
||||
{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-[100] mt-1 max-h-[300px] w-full overflow-y-auto rounded-md border bg-background shadow-lg">
|
||||
{loading && results.length === 0 ? (
|
||||
<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>
|
||||
) : results.length === 0 ? (
|
||||
|
|
@ -189,37 +215,15 @@ export function AutocompleteSearchInputComponent({
|
|||
key={index}
|
||||
type="button"
|
||||
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>
|
||||
{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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -24,83 +23,14 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
}: AutocompleteSearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
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 [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [isLoadingSourceColumns, setIsLoadingSourceColumns] = useState(false);
|
||||
const [isLoadingTargetColumns, setIsLoadingTargetColumns] = useState(false);
|
||||
const [openSourceTableCombo, setOpenSourceTableCombo] = useState(false);
|
||||
const [openTargetTableCombo, setOpenTargetTableCombo] = 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(() => {
|
||||
setLocalConfig(config);
|
||||
|
|
@ -112,52 +42,76 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.searchFields || [];
|
||||
updateConfig({ searchFields: [...fields, ""] });
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
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 || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ searchFields: fields });
|
||||
// 외부 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
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 || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ searchFields: fields });
|
||||
// 저장 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadTargetColumns = async () => {
|
||||
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 mappings = localConfig.fieldMappings || [];
|
||||
updateConfig({
|
||||
fieldMappings: [
|
||||
...mappings,
|
||||
{ sourceField: "", targetField: "", label: "" },
|
||||
],
|
||||
fieldMappings: [...mappings, { sourceField: "", targetField: "", label: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
const updateFieldMapping = (index: number, updates: Partial<FieldMapping>) => {
|
||||
const updateFieldMapping = (index: number, updates: any) => {
|
||||
const mappings = [...(localConfig.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], ...updates };
|
||||
updateConfig({ fieldMappings: mappings });
|
||||
|
|
@ -170,21 +124,22 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 1. 외부 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<Label className="text-xs font-semibold sm:text-sm">1. 외부 테이블 선택 *</Label>
|
||||
<Popover open={openSourceTableCombo} onOpenChange={setOpenSourceTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
aria-expanded={openSourceTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -200,7 +155,7 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({ tableName: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
setOpenSourceTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
|
|
@ -216,13 +171,11 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
검색할 데이터가 저장된 테이블을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -230,11 +183,11 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
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
|
||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -244,7 +197,7 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
{sourceTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
|
|
@ -266,98 +219,23 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
사용자에게 보여줄 필드 (예: 거래처명)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3. 저장 대상 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
||||
<Label className="text-xs font-semibold sm:text-sm">3. 저장 대상 테이블 *</Label>
|
||||
<Popover open={openTargetTableCombo} onOpenChange={setOpenTargetTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openValueFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.valueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<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>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
setOpenValueFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === 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">
|
||||
검색 테이블에서 가져올 값의 컬럼 (예: customer_code)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig({ placeholder: e.target.value })}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</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">
|
||||
위에서 선택한 "값 필드"의 데이터를 어느 테이블/컬럼에 저장할지 지정합니다.
|
||||
<br />
|
||||
미설정 시 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 자동 저장됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 저장 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">저장 테이블</Label>
|
||||
<Popover open={openStorageTableCombo} onOpenChange={setOpenStorageTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openStorageTableCombo}
|
||||
aria-expanded={openTargetTableCombo}
|
||||
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
|
||||
: "기본값 (화면 연결 테이블)"}
|
||||
{localConfig.targetTable
|
||||
? allTables.find((t) => t.tableName === localConfig.targetTable)?.displayName || localConfig.targetTable
|
||||
: "데이터를 저장할 테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -367,49 +245,17 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
<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);
|
||||
updateConfig({ targetTable: table.tableName });
|
||||
setOpenTargetTableCombo(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"
|
||||
)}
|
||||
/>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.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>}
|
||||
|
|
@ -421,255 +267,35 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
</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>
|
||||
</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">
|
||||
{/* 4. 필드 매핑 */}
|
||||
<div className="space-y-4">
|
||||
<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 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>
|
||||
<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 || isLoadingColumns}
|
||||
disabled={!localConfig.tableName || !localConfig.targetTable || isLoadingSourceColumns || isLoadingTargetColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
<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="border rounded-lg p-3 space-y-3 bg-background">
|
||||
<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}
|
||||
|
|
@ -684,7 +310,6 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 표시명 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">표시명</Label>
|
||||
<Input
|
||||
|
|
@ -692,110 +317,106 @@ export function AutocompleteSearchInputConfigPanel({
|
|||
onChange={(e) =>
|
||||
updateFieldMapping(index, { label: e.target.value })
|
||||
}
|
||||
placeholder="예: 거래처명"
|
||||
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>
|
||||
<Label className="text-xs">외부 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.sourceField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { sourceField: value })
|
||||
}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
<SelectValue placeholder="가져올 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
{sourceTableColumns.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
|
||||
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onChange={(e) =>
|
||||
updateFieldMapping(index, { targetField: e.target.value })
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { targetField: 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>
|
||||
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>
|
||||
|
||||
{/* 예시 설명 */}
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
|
||||
{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">
|
||||
{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>{" "}
|
||||
컴포넌트에 자동으로 채웁니다
|
||||
</>
|
||||
) : (
|
||||
"소스 필드와 타겟 필드를 모두 선택하세요"
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* 사용 안내 */}
|
||||
{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">
|
||||
사용 방법
|
||||
{/* 플레이스홀더 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig({ placeholder: e.target.value })}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설정 요약 */}
|
||||
{localConfig.tableName && localConfig.targetTable && (localConfig.fieldMappings || []).length > 0 && (
|
||||
<div className="rounded-lg border bg-green-50 p-4 dark:bg-green-950">
|
||||
<h3 className="mb-2 text-sm font-semibold text-green-800 dark:text-green-200">
|
||||
설정 요약
|
||||
</h3>
|
||||
<div className="space-y-1 text-xs text-green-700 dark:text-green-300">
|
||||
<p>
|
||||
<strong>외부 테이블:</strong> {localConfig.tableName}
|
||||
</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>
|
||||
<p>
|
||||
<strong>표시 필드:</strong> {localConfig.displayField}
|
||||
</p>
|
||||
<p>
|
||||
<strong>저장 테이블:</strong> {localConfig.targetTable}
|
||||
</p>
|
||||
<p>
|
||||
<strong>매핑 개수:</strong> {(localConfig.fieldMappings || []).length}개
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,5 +27,7 @@ export interface AutocompleteSearchInputConfig {
|
|||
// 필드 자동 매핑 설정
|
||||
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
|
||||
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
||||
// 저장 대상 테이블 (간소화 버전)
|
||||
targetTable?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export function ConditionalContainerComponent({
|
|||
componentId,
|
||||
style,
|
||||
className,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
}: ConditionalContainerProps) {
|
||||
console.log("🎯 ConditionalContainerComponent 렌더링!", {
|
||||
isDesignMode,
|
||||
|
|
@ -177,6 +178,7 @@ export function ConditionalContainerComponent({
|
|||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -196,6 +198,7 @@ export function ConditionalContainerComponent({
|
|||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { ConditionalSectionViewerProps } from "./types";
|
||||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
|
@ -24,6 +25,7 @@ export function ConditionalSectionViewer({
|
|||
showBorder = true,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -135,13 +137,24 @@ export function ConditionalSectionViewer({
|
|||
minHeight: "200px",
|
||||
}}
|
||||
>
|
||||
{components.map((component) => (
|
||||
<RealtimePreview
|
||||
{components.map((component) => {
|
||||
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: position.x || 0,
|
||||
top: position.y || 0,
|
||||
width: size.width || 200,
|
||||
height: size.height || 40,
|
||||
zIndex: position.z || 1,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
isInteractive={true}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
|
|
@ -149,8 +162,11 @@ export function ConditionalSectionViewer({
|
|||
companyCode={user?.companyCode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export interface ConditionalContainerProps {
|
|||
onChange?: (value: string) => void;
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
|
||||
|
||||
// 화면 편집기 관련
|
||||
isDesignMode?: boolean; // 디자인 모드 여부
|
||||
|
|
@ -75,5 +76,6 @@ export interface ConditionalSectionViewerProps {
|
|||
// 폼 데이터 전달
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,26 @@ import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./
|
|||
import { useCalculation } from "./useCalculation";
|
||||
import { cn } from "@/lib/utils";
|
||||
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;
|
||||
// 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({
|
||||
// ComponentRendererProps (자동 전달)
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
|
||||
// ModalRepeaterTable 전용 props
|
||||
config,
|
||||
sourceTable: propSourceTable,
|
||||
sourceColumns: propSourceColumns,
|
||||
sourceSearchFields: propSourceSearchFields,
|
||||
targetTable: propTargetTable,
|
||||
modalTitle: propModalTitle,
|
||||
modalButtonText: propModalButtonText,
|
||||
multiSelect: propMultiSelect,
|
||||
|
|
@ -136,36 +168,55 @@ export function ModalRepeaterTableComponent({
|
|||
uniqueField: propUniqueField,
|
||||
filterCondition: propFilterCondition,
|
||||
companyCode: propCompanyCode,
|
||||
className,
|
||||
|
||||
...props
|
||||
}: ModalRepeaterTableComponentProps) {
|
||||
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component?.config,
|
||||
};
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const sourceTable = config?.sourceTable || propSourceTable || "";
|
||||
const sourceTable = componentConfig?.sourceTable || propSourceTable || "";
|
||||
const targetTable = componentConfig?.targetTable || propTargetTable;
|
||||
|
||||
// sourceColumns에서 빈 문자열 필터링
|
||||
const rawSourceColumns = config?.sourceColumns || propSourceColumns || [];
|
||||
const sourceColumns = rawSourceColumns.filter((col) => col && col.trim() !== "");
|
||||
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
|
||||
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
|
||||
|
||||
const sourceSearchFields = config?.sourceSearchFields || propSourceSearchFields || [];
|
||||
const modalTitle = config?.modalTitle || propModalTitle || "항목 검색";
|
||||
const modalButtonText = config?.modalButtonText || propModalButtonText || "품목 검색";
|
||||
const multiSelect = config?.multiSelect ?? propMultiSelect ?? true;
|
||||
const calculationRules = config?.calculationRules || propCalculationRules || [];
|
||||
const value = config?.value || propValue || [];
|
||||
const onChange = config?.onChange || propOnChange || (() => {});
|
||||
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
|
||||
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
|
||||
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
|
||||
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
|
||||
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||
|
||||
// ✅ 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로 변경
|
||||
const rawUniqueField = config?.uniqueField || propUniqueField;
|
||||
const rawUniqueField = componentConfig?.uniqueField || propUniqueField;
|
||||
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
|
||||
? "item_number"
|
||||
: rawUniqueField;
|
||||
|
||||
const filterCondition = config?.filterCondition || propFilterCondition || {};
|
||||
const companyCode = config?.companyCode || propCompanyCode;
|
||||
const filterCondition = componentConfig?.filterCondition || propFilterCondition || {};
|
||||
const companyCode = componentConfig?.companyCode || propCompanyCode;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||
const configuredColumns = config?.columns || propColumns || [];
|
||||
const configuredColumns = componentConfig?.columns || propColumns || [];
|
||||
|
||||
if (configuredColumns.length > 0) {
|
||||
console.log("✅ 설정된 columns 사용:", configuredColumns);
|
||||
|
|
@ -188,7 +239,7 @@ export function ModalRepeaterTableComponent({
|
|||
|
||||
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
|
||||
return [];
|
||||
}, [config?.columns, propColumns, sourceColumns]);
|
||||
}, [componentConfig?.columns, propColumns, sourceColumns]);
|
||||
|
||||
// 초기 props 로깅
|
||||
useEffect(() => {
|
||||
|
|
@ -221,6 +272,93 @@ export function ModalRepeaterTableComponent({
|
|||
});
|
||||
}, [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;
|
||||
}
|
||||
|
||||
// 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
|
||||
console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", {
|
||||
sourceColumns,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
sampleItem: value[0],
|
||||
itemKeys: value[0] ? Object.keys(value[0]) : [],
|
||||
});
|
||||
|
||||
const filteredData = value.map((item: any) => {
|
||||
const filtered: Record<string, any> = {};
|
||||
|
||||
Object.keys(item).forEach((key) => {
|
||||
// sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼)
|
||||
if (sourceColumns.includes(key)) {
|
||||
console.log(` ⛔ ${key} 제외 (sourceColumn)`);
|
||||
return;
|
||||
}
|
||||
// 메타데이터 필드도 제외
|
||||
if (key.startsWith("_")) {
|
||||
console.log(` ⛔ ${key} 제외 (메타데이터)`);
|
||||
return;
|
||||
}
|
||||
filtered[key] = item[key];
|
||||
});
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", {
|
||||
filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [],
|
||||
sampleFilteredItem: filteredData[0],
|
||||
});
|
||||
|
||||
// 🔥 targetTable 메타데이터를 배열 항목에 추가
|
||||
const dataWithTargetTable = targetTable
|
||||
? filteredData.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: targetTable, // 백엔드가 인식할 메타데이터
|
||||
}))
|
||||
: filteredData;
|
||||
|
||||
// ✅ 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);
|
||||
|
||||
// 초기 데이터에 계산 필드 적용
|
||||
|
|
@ -229,9 +367,10 @@ export function ModalRepeaterTableComponent({
|
|||
const calculated = calculateAll(value);
|
||||
// 값이 실제로 변경된 경우만 업데이트
|
||||
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
||||
onChange(calculated);
|
||||
handleChange(calculated);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleAddItems = async (items: any[]) => {
|
||||
|
|
@ -338,7 +477,8 @@ export function ModalRepeaterTableComponent({
|
|||
const newData = [...value, ...calculatedItems];
|
||||
console.log("✅ 최종 데이터:", newData.length, "개 항목");
|
||||
|
||||
onChange(newData);
|
||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const handleRowChange = (index: number, newRow: any) => {
|
||||
|
|
@ -348,12 +488,16 @@ export function ModalRepeaterTableComponent({
|
|||
// 데이터 업데이트
|
||||
const newData = [...value];
|
||||
newData[index] = calculatedRow;
|
||||
onChange(newData);
|
||||
|
||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const handleRowDelete = (index: number) => {
|
||||
const newData = value.filter((_, i) => i !== index);
|
||||
onChange(newData);
|
||||
|
||||
// ✅ 통합 onChange 호출 (formData 반영 포함)
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
// 컬럼명 -> 라벨명 매핑 생성
|
||||
|
|
@ -382,7 +526,7 @@ export function ModalRepeaterTableComponent({
|
|||
<RepeaterTable
|
||||
columns={columns}
|
||||
data={value}
|
||||
onDataChange={onChange}
|
||||
onDataChange={handleChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,40 +7,15 @@ import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
|||
|
||||
/**
|
||||
* ModalRepeaterTable 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
* ✅ 단순 전달만 수행 (TextInput 패턴 따름)
|
||||
*/
|
||||
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = ModalRepeaterTableDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
// onChange 콜백을 명시적으로 전달
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleChange = (newValue: any[]) => {
|
||||
console.log("🔄 ModalRepeaterTableRenderer onChange:", newValue.length, "개 항목");
|
||||
|
||||
// 컴포넌트 업데이트
|
||||
this.updateComponent({ value: newValue });
|
||||
|
||||
// 원본 onChange 콜백도 호출 (있다면)
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(newValue);
|
||||
// ✅ props를 그대로 전달 (Component에서 모든 로직 처리)
|
||||
return <ModalRepeaterTableComponent {...this.props} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 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 });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
|
|
|
|||
|
|
@ -118,10 +118,10 @@ export function RepeaterTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="border rounded-md overflow-hidden bg-background">
|
||||
<div className="overflow-x-auto max-h-[240px] overflow-y-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
#
|
||||
|
|
@ -141,7 +141,7 @@ export function RepeaterTable({
|
|||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="bg-background">
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ export function SectionPaperComponent({
|
|||
|
||||
// 배경색 매핑
|
||||
const backgroundColorMap = {
|
||||
default: "bg-muted/20",
|
||||
muted: "bg-muted/30",
|
||||
accent: "bg-accent/20",
|
||||
primary: "bg-primary/5",
|
||||
default: "bg-muted/40",
|
||||
muted: "bg-muted/50",
|
||||
accent: "bg-accent/30",
|
||||
primary: "bg-primary/10",
|
||||
custom: "",
|
||||
};
|
||||
|
||||
|
|
@ -74,7 +74,7 @@ export function SectionPaperComponent({
|
|||
const padding = config.padding || "md";
|
||||
const rounded = config.roundedCorners || "md";
|
||||
const shadow = config.shadow || "none";
|
||||
const showBorder = config.showBorder || false;
|
||||
const showBorder = config.showBorder !== undefined ? config.showBorder : true;
|
||||
const borderStyle = config.borderStyle || "subtle";
|
||||
|
||||
// 커스텀 배경색 처리
|
||||
|
|
@ -87,7 +87,7 @@ export function SectionPaperComponent({
|
|||
<div
|
||||
className={cn(
|
||||
// 기본 스타일
|
||||
"relative transition-colors",
|
||||
"relative transition-colors overflow-visible",
|
||||
|
||||
// 배경색
|
||||
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
|
||||
|
|
|
|||
|
|
@ -50,11 +50,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
menuObjid, // 🆕 메뉴 OBJID
|
||||
...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);
|
||||
|
||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||
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와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "select";
|
||||
|
||||
|
|
@ -62,8 +98,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
||||
const [selectedLabel, setSelectedLabel] = useState("");
|
||||
|
||||
// multiselect의 경우 배열로 관리
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
// multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
|
||||
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의 경우 검색어 관리
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -96,6 +138,58 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
isFetching,
|
||||
} = 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가 제대로 전달되는지 확인
|
||||
useEffect(() => {
|
||||
if (codeCategory && codeCategory !== "none") {
|
||||
|
|
@ -113,11 +207,42 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// 외부 value prop 변경 시 selectedValue 업데이트
|
||||
useEffect(() => {
|
||||
const newValue = externalValue || config?.value || "";
|
||||
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
||||
|
||||
console.log("🔍 [SelectBasic] 외부 값 변경 감지:", {
|
||||
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가 자동 관리 (10분 staleTime, 30분 gcTime)
|
||||
|
|
@ -128,7 +253,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
useEffect(() => {
|
||||
const getAllOptions = () => {
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...configOptions];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
};
|
||||
|
||||
const options = getAllOptions();
|
||||
|
|
@ -204,12 +329,24 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// 모든 옵션 가져오기
|
||||
const getAllOptions = () => {
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...configOptions];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
};
|
||||
|
||||
const allOptions = getAllOptions();
|
||||
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 필터링
|
||||
const {
|
||||
component: _component,
|
||||
|
|
@ -500,6 +637,96 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
}
|
||||
|
||||
// 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 (
|
||||
<div className="w-full">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||
const newConfig = { ...config, [key]: value };
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -67,6 +69,15 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="multiple">다중 선택</Label>
|
||||
<Checkbox
|
||||
id="multiple"
|
||||
checked={config.multiple || false}
|
||||
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||
/>
|
||||
</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 [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) => {
|
||||
try {
|
||||
|
|
@ -180,7 +291,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
|
||||
|
||||
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
// 🔧 기존 config와 병합하여 다른 속성 유지
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
|
||||
|
|
@ -261,15 +373,19 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
|
||||
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)]);
|
||||
return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [sourceTableColumns, displayColumns, localFields]);
|
||||
return columns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]);
|
||||
|
||||
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
|
||||
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)]);
|
||||
return targetTableColumns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [targetTableColumns, displayColumns, localFields]);
|
||||
return columns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]);
|
||||
|
||||
// 🆕 원본 테이블 필터링
|
||||
const filteredSourceTables = useMemo(() => {
|
||||
|
|
@ -403,7 +519,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
role="combobox"
|
||||
aria-expanded={sourceTableSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:text-sm"
|
||||
disabled={allTables.length === 0}
|
||||
>
|
||||
{selectedSourceTableLabel}
|
||||
<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">
|
||||
<Label className="text-[10px] sm:text-xs">자동 채우기 (선택)</Label>
|
||||
|
||||
{/* 테이블명 입력 */}
|
||||
<Input
|
||||
value={field.autoFillFromTable || ""}
|
||||
onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })}
|
||||
placeholder="비워두면 주 데이터 (예: item_price)"
|
||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||
{/* 테이블 선택 드롭다운 */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
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>
|
||||
|
||||
{/* 필드 선택 */}
|
||||
|
|
@ -696,16 +862,26 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
role="combobox"
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
|
||||
<Command>
|
||||
<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]">
|
||||
<CommandItem
|
||||
value=""
|
||||
|
|
@ -720,11 +896,17 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
/>
|
||||
선택 안 함
|
||||
</CommandItem>
|
||||
{sourceTableColumns.map((column) => (
|
||||
{(() => {
|
||||
// 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼
|
||||
const columns = field.autoFillFromTable
|
||||
? (autoFillTableColumns[index] || [])
|
||||
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
|
||||
|
||||
return columns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => updateField(index, { autoFillFrom: column.columnName })}
|
||||
onSelect={(value) => updateField(index, { autoFillFrom: value })}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
<Check
|
||||
|
|
@ -734,11 +916,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel}</div>
|
||||
<div className="text-[9px] text-gray-500">{column.columnName}</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>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
|
||||
{/* 요약 표시 설정 (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">
|
||||
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ export interface TableListComponentProps {
|
|||
tableName?: string;
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
screenId?: string;
|
||||
screenId?: number | string; // 화면 ID (필터 설정 저장용)
|
||||
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
|
|
@ -183,6 +183,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
refreshKey,
|
||||
tableName,
|
||||
userId,
|
||||
screenId, // 화면 ID 추출
|
||||
}) => {
|
||||
// ========================================
|
||||
// 설정 및 스타일
|
||||
|
|
@ -1227,8 +1228,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
// 체크박스 컬럼 (나중에 위치 결정)
|
||||
// 기본값: enabled가 undefined면 true로 처리
|
||||
let checkboxCol: ColumnConfig | null = null;
|
||||
if (tableConfig.checkbox?.enabled) {
|
||||
if (tableConfig.checkbox?.enabled ?? true) {
|
||||
checkboxCol = {
|
||||
columnName: "__checkbox__",
|
||||
displayName: "",
|
||||
|
|
@ -1257,7 +1259,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 체크박스를 맨 앞 또는 맨 뒤에 추가
|
||||
if (checkboxCol) {
|
||||
if (tableConfig.checkbox.position === "right") {
|
||||
if (tableConfig.checkbox?.position === "right") {
|
||||
cols = [...cols, checkboxCol];
|
||||
} else {
|
||||
cols = [checkboxCol, ...cols];
|
||||
|
|
@ -1423,23 +1425,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||||
if (inputType === "category") {
|
||||
if (!value) return "";
|
||||
|
||||
const mapping = categoryMappings[column.columnName];
|
||||
const categoryData = mapping?.[String(value)];
|
||||
const { Badge } = require("@/components/ui/badge");
|
||||
|
||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
||||
const displayLabel = categoryData?.label || String(value);
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
||||
// 다중 값 처리: 콤마로 구분된 값들을 분리
|
||||
const valueStr = String(value);
|
||||
const values = valueStr.includes(",")
|
||||
? valueStr.split(",").map(v => v.trim()).filter(v => v)
|
||||
: [valueStr];
|
||||
|
||||
// 단일 값인 경우 (기존 로직)
|
||||
if (values.length === 1) {
|
||||
const categoryData = mapping?.[values[0]];
|
||||
const displayLabel = categoryData?.label || values[0];
|
||||
const displayColor = categoryData?.color || "#64748b";
|
||||
|
||||
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
const { Badge } = require("@/components/ui/badge");
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
|
|
@ -1453,6 +1461,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 다중 값인 경우: 여러 배지 렌더링
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.map((val, idx) => {
|
||||
const categoryData = mapping?.[val];
|
||||
const displayLabel = categoryData?.label || val;
|
||||
const displayColor = categoryData?.color || "#64748b";
|
||||
|
||||
if (displayColor === "none") {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 코드 타입: 코드 값 → 코드명 변환
|
||||
if (inputType === "code" && meta?.codeCategory && value) {
|
||||
try {
|
||||
|
|
@ -1535,17 +1577,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// useEffect 훅
|
||||
// ========================================
|
||||
|
||||
// 필터 설정 localStorage 키 생성
|
||||
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
|
||||
const filterSettingKey = useMemo(() => {
|
||||
if (!tableConfig.selectedTable) return null;
|
||||
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
return screenId
|
||||
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||||
: `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable, screenId]);
|
||||
|
||||
// 그룹 설정 localStorage 키 생성
|
||||
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
|
||||
const groupSettingKey = useMemo(() => {
|
||||
if (!tableConfig.selectedTable) return null;
|
||||
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
return screenId
|
||||
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||||
: `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable, screenId]);
|
||||
|
||||
// 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -269,7 +269,9 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
// });
|
||||
|
||||
const parentValue = config[parentKey] as any;
|
||||
// 전체 config와 병합하여 다른 속성 유지
|
||||
const newConfig = {
|
||||
...config,
|
||||
[parentKey]: {
|
||||
...parentValue,
|
||||
[childKey]: value,
|
||||
|
|
@ -754,6 +756,52 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
|||
import { TableFilter } from "@/types/table-options";
|
||||
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 {
|
||||
component: {
|
||||
id: string;
|
||||
|
|
@ -25,6 +33,8 @@ interface TableSearchWidgetProps {
|
|||
componentConfig?: {
|
||||
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
|
||||
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
||||
filterMode?: "dynamic" | "preset"; // 필터 모드
|
||||
presetFilters?: PresetFilter[]; // 고정 필터 목록
|
||||
};
|
||||
};
|
||||
screenId?: number; // 화면 ID
|
||||
|
|
@ -63,6 +73,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
|
||||
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
||||
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
|
||||
const presetFilters = component.componentConfig?.presetFilters ?? [];
|
||||
|
||||
// Map을 배열로 변환
|
||||
const tableList = Array.from(registeredTables.values());
|
||||
|
|
@ -77,10 +89,27 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
}
|
||||
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
||||
|
||||
// 현재 테이블의 저장된 필터 불러오기
|
||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||
useEffect(() => {
|
||||
if (currentTable?.tableName) {
|
||||
const storageKey = `table_filters_${currentTable.tableName}`;
|
||||
if (!currentTable?.tableName) return;
|
||||
|
||||
// 고정 모드: presetFilters를 activeFilters로 설정
|
||||
if (filterMode === "preset") {
|
||||
const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({
|
||||
columnName: f.columnName,
|
||||
operator: "contains",
|
||||
value: "",
|
||||
filterType: f.filterType,
|
||||
width: f.width || 200,
|
||||
}));
|
||||
setActiveFilters(activeFiltersList);
|
||||
return;
|
||||
}
|
||||
|
||||
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
||||
const storageKey = screenId
|
||||
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
||||
: `table_filters_${currentTable.tableName}`;
|
||||
const savedFilters = localStorage.getItem(storageKey);
|
||||
|
||||
if (savedFilters) {
|
||||
|
|
@ -110,8 +139,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
console.error("저장된 필터 불러오기 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentTable?.tableName]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
|
||||
|
||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||
useEffect(() => {
|
||||
|
|
@ -362,7 +391,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
{/* 필터가 없을 때는 빈 공간 */}
|
||||
{activeFilters.length === 0 && <div className="flex-1" />}
|
||||
|
||||
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
|
||||
{/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{/* 데이터 건수 표시 */}
|
||||
{currentTable?.dataCount !== undefined && (
|
||||
|
|
@ -371,6 +400,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 동적 모드일 때만 설정 버튼들 표시 */}
|
||||
{filterMode === "dynamic" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -403,6 +435,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
그룹 설정
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 패널들 */}
|
||||
|
|
@ -411,6 +445,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
isOpen={filterOpen}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
||||
screenId={screenId}
|
||||
/>
|
||||
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,27 +3,126 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 {
|
||||
component: any;
|
||||
onUpdateProperty: (property: string, value: any) => void;
|
||||
component?: any; // 레거시 지원
|
||||
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({
|
||||
component,
|
||||
config,
|
||||
onUpdateProperty,
|
||||
onChange,
|
||||
tables = [],
|
||||
}: 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(
|
||||
component.componentConfig?.autoSelectFirstTable ?? true
|
||||
currentConfig.autoSelectFirstTable ?? true
|
||||
);
|
||||
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(() => {
|
||||
setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
|
||||
setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
|
||||
}, [component.componentConfig]);
|
||||
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
|
||||
setLocalShowSelector(currentConfig.showTableSelector ?? true);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -41,7 +140,7 @@ export function TableSearchWidgetConfigPanel({
|
|||
checked={localAutoSelect}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalAutoSelect(checked as boolean);
|
||||
onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
|
||||
handleUpdate("autoSelectFirstTable", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
|
||||
|
|
@ -56,7 +155,7 @@ export function TableSearchWidgetConfigPanel({
|
|||
checked={localShowSelector}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalShowSelector(checked as boolean);
|
||||
onUpdateProperty("componentConfig.showTableSelector", checked);
|
||||
handleUpdate("showTableSelector", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
|
||||
|
|
@ -64,12 +163,178 @@ export function TableSearchWidgetConfigPanel({
|
|||
</Label>
|
||||
</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">
|
||||
<p className="font-medium mb-1">참고사항:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다</li>
|
||||
<li>여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다</li>
|
||||
<li>선택한 테이블의 컬럼 정보가 자동으로 로드됩니다</li>
|
||||
{localFilterMode === "dynamic" ? (
|
||||
<li>사용자가 필터 설정 버튼을 클릭하여 원하는 필터를 선택합니다</li>
|
||||
) : (
|
||||
<li>고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시됩니다</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import React from "react";
|
|||
import { TableSearchWidget } from "./TableSearchWidget";
|
||||
|
||||
export class TableSearchWidgetRenderer {
|
||||
static render(component: any) {
|
||||
return <TableSearchWidget component={component} />;
|
||||
static render(component: any, props?: any) {
|
||||
return <TableSearchWidget component={component} screenId={props?.screenId} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ export interface ButtonActionConfig {
|
|||
|
||||
// 모달/팝업 관련
|
||||
modalTitle?: string;
|
||||
modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음)
|
||||
modalTitleBlocks?: Array<{
|
||||
// 🆕 블록 기반 제목 (우선순위 높음)
|
||||
id: string;
|
||||
type: "text" | "field";
|
||||
value: string; // type=text: 텍스트 내용, type=field: 컬럼명
|
||||
|
|
@ -88,6 +89,12 @@ export interface ButtonActionConfig {
|
|||
// 코드 병합 관련
|
||||
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
|
||||
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
|
||||
|
||||
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
|
||||
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
|
||||
editModalTitle?: string; // 편집 모달 제목
|
||||
editModalDescription?: string; // 편집 모달 설명
|
||||
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -345,11 +352,11 @@ export class ButtonActionExecutor {
|
|||
// console.log("👤 [buttonActions] 사용자 정보:", {
|
||||
// userId: context.userId,
|
||||
// userName: context.userName,
|
||||
// companyCode: context.companyCode, // ✅ 회사 코드
|
||||
// formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값
|
||||
// formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값
|
||||
// companyCode: context.companyCode,
|
||||
// formDataWriter: formData.writer,
|
||||
// formDataCompanyCode: formData.company_code,
|
||||
// defaultWriterValue: writerValue,
|
||||
// companyCodeValue, // ✅ 최종 회사 코드 값
|
||||
// companyCodeValue,
|
||||
// });
|
||||
|
||||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||
|
|
@ -1256,14 +1263,6 @@ export class ButtonActionExecutor {
|
|||
// 플로우 선택 데이터 우선 사용
|
||||
let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
|
||||
|
||||
console.log("🔍 handleEdit - 데이터 소스 확인:", {
|
||||
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
|
||||
flowSelectedDataLength: flowSelectedData?.length || 0,
|
||||
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
|
||||
selectedRowsDataLength: selectedRowsData?.length || 0,
|
||||
dataToEditLength: dataToEdit?.length || 0,
|
||||
});
|
||||
|
||||
// 선택된 데이터가 없는 경우
|
||||
if (!dataToEdit || dataToEdit.length === 0) {
|
||||
toast.error("수정할 항목을 선택해주세요.");
|
||||
|
|
@ -1276,26 +1275,15 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
console.log(`📝 편집 액션 실행: ${dataToEdit.length}개 항목`, {
|
||||
dataToEdit,
|
||||
targetScreenId: config.targetScreenId,
|
||||
editMode: config.editMode,
|
||||
});
|
||||
|
||||
if (dataToEdit.length === 1) {
|
||||
// 단일 항목 편집
|
||||
const rowData = dataToEdit[0];
|
||||
console.log("📝 단일 항목 편집:", rowData);
|
||||
|
||||
await this.openEditForm(config, rowData, context);
|
||||
} else {
|
||||
// 다중 항목 편집 - 현재는 단일 편집만 지원
|
||||
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
|
||||
return false;
|
||||
|
||||
// TODO: 향후 다중 편집 지원
|
||||
// console.log("📝 다중 항목 편집:", selectedRowsData);
|
||||
// this.openBulkEditForm(config, selectedRowsData, context);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -1329,7 +1317,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
default:
|
||||
// 기본값: 모달
|
||||
this.openEditModal(config, rowData, context);
|
||||
await this.openEditModal(config, rowData, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1341,11 +1329,17 @@ export class ButtonActionExecutor {
|
|||
rowData: any,
|
||||
context: ButtonActionContext,
|
||||
): Promise<void> {
|
||||
console.log("🎭 편집 모달 열기:", {
|
||||
targetScreenId: config.targetScreenId,
|
||||
modalSize: config.modalSize,
|
||||
rowData,
|
||||
});
|
||||
const { groupByColumns = [] } = config;
|
||||
|
||||
// PK 값 추출 (우선순위: id > ID > 첫 번째 필드)
|
||||
let primaryKeyValue: any;
|
||||
if (rowData.id !== undefined && rowData.id !== null) {
|
||||
primaryKeyValue = rowData.id;
|
||||
} else if (rowData.ID !== undefined && rowData.ID !== null) {
|
||||
primaryKeyValue = rowData.ID;
|
||||
} else {
|
||||
primaryKeyValue = Object.values(rowData)[0];
|
||||
}
|
||||
|
||||
// 1. config에 editModalDescription이 있으면 우선 사용
|
||||
let description = config.editModalDescription || "";
|
||||
|
|
@ -1360,7 +1354,7 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 모달 열기 이벤트 발생
|
||||
// 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리)
|
||||
const modalEvent = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: config.targetScreenId,
|
||||
|
|
@ -1368,16 +1362,15 @@ export class ButtonActionExecutor {
|
|||
description: description,
|
||||
modalSize: config.modalSize || "lg",
|
||||
editData: rowData,
|
||||
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
|
||||
tableName: context.tableName, // 🆕 테이블명 전달
|
||||
onSave: () => {
|
||||
// 저장 후 테이블 새로고침
|
||||
console.log("💾 편집 저장 완료 - 테이블 새로고침");
|
||||
context.onRefresh?.();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
window.dispatchEvent(modalEvent);
|
||||
// 편집 모달 열기는 조용히 처리 (토스트 없음)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue