Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
c87a1db81e
|
|
@ -3245,6 +3245,7 @@ export const resetUserPassword = async (
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
|
||||||
|
* column_labels 테이블에서 라벨 정보도 함께 가져옴
|
||||||
*/
|
*/
|
||||||
export async function getTableSchema(
|
export async function getTableSchema(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -3264,20 +3265,25 @@ export async function getTableSchema(
|
||||||
|
|
||||||
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
logger.info("테이블 스키마 조회", { tableName, companyCode });
|
||||||
|
|
||||||
// information_schema에서 컬럼 정보 가져오기
|
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
|
||||||
const schemaQuery = `
|
const schemaQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
column_name,
|
ic.column_name,
|
||||||
data_type,
|
ic.data_type,
|
||||||
is_nullable,
|
ic.is_nullable,
|
||||||
column_default,
|
ic.column_default,
|
||||||
character_maximum_length,
|
ic.character_maximum_length,
|
||||||
numeric_precision,
|
ic.numeric_precision,
|
||||||
numeric_scale
|
ic.numeric_scale,
|
||||||
FROM information_schema.columns
|
cl.column_label,
|
||||||
WHERE table_schema = 'public'
|
cl.display_order
|
||||||
AND table_name = $1
|
FROM information_schema.columns ic
|
||||||
ORDER BY ordinal_position
|
LEFT JOIN column_labels cl
|
||||||
|
ON cl.table_name = ic.table_name
|
||||||
|
AND cl.column_name = ic.column_name
|
||||||
|
WHERE ic.table_schema = 'public'
|
||||||
|
AND ic.table_name = $1
|
||||||
|
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const columns = await query<any>(schemaQuery, [tableName]);
|
const columns = await query<any>(schemaQuery, [tableName]);
|
||||||
|
|
@ -3290,9 +3296,10 @@ export async function getTableSchema(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 정보를 간단한 형태로 변환
|
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
|
||||||
const columnList = columns.map((col: any) => ({
|
const columnList = columns.map((col: any) => ({
|
||||||
name: col.column_name,
|
name: col.column_name,
|
||||||
|
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
|
||||||
type: col.data_type,
|
type: col.data_type,
|
||||||
nullable: col.is_nullable === "YES",
|
nullable: col.is_nullable === "YES",
|
||||||
default: col.column_default,
|
default: col.column_default,
|
||||||
|
|
|
||||||
|
|
@ -424,18 +424,16 @@ export class EntityJoinController {
|
||||||
config.referenceTable
|
config.referenceTable
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 display_column으로 사용 중인 컬럼 제외
|
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||||
const currentDisplayColumn =
|
const currentDisplayColumn =
|
||||||
config.displayColumn || config.displayColumns[0];
|
config.displayColumn || config.displayColumns[0];
|
||||||
const availableColumns = columns.filter(
|
|
||||||
(col) => col.columnName !== currentDisplayColumn
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// 모든 컬럼 표시 (기본 표시 컬럼도 포함)
|
||||||
return {
|
return {
|
||||||
joinConfig: config,
|
joinConfig: config,
|
||||||
tableName: config.referenceTable,
|
tableName: config.referenceTable,
|
||||||
currentDisplayColumn: currentDisplayColumn,
|
currentDisplayColumn: currentDisplayColumn,
|
||||||
availableColumns: availableColumns.map((col) => ({
|
availableColumns: columns.map((col) => ({
|
||||||
columnName: col.columnName,
|
columnName: col.columnName,
|
||||||
columnLabel: col.displayName || col.columnName,
|
columnLabel: col.displayName || col.columnName,
|
||||||
dataType: col.dataType,
|
dataType: col.dataType,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
*/
|
||||||
|
export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user!.companyCode;
|
||||||
|
|
||||||
|
const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -767,11 +767,12 @@ export async function getTableData(
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
|
||||||
// 🆕 현재 사용자 필터 적용
|
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
|
||||||
let enhancedSearch = { ...search };
|
let enhancedSearch = { ...search };
|
||||||
if (autoFilter?.enabled && req.user) {
|
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
|
||||||
const filterColumn = autoFilter.filterColumn || "company_code";
|
if (shouldApplyAutoFilter && req.user) {
|
||||||
const userField = autoFilter.userField || "companyCode";
|
const filterColumn = autoFilter?.filterColumn || "company_code";
|
||||||
|
const userField = autoFilter?.userField || "companyCode";
|
||||||
const userValue = (req.user as any)[userField];
|
const userValue = (req.user as any)[userField];
|
||||||
|
|
||||||
if (userValue) {
|
if (userValue) {
|
||||||
|
|
@ -877,7 +878,17 @@ export async function addTableData(
|
||||||
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
|
||||||
if (hasCompanyCodeColumn) {
|
if (hasCompanyCodeColumn) {
|
||||||
data.company_code = companyCode;
|
data.company_code = companyCode;
|
||||||
logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (userId && !data.writer) {
|
||||||
|
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
|
||||||
|
if (hasWriterColumn) {
|
||||||
|
data.writer = userId;
|
||||||
|
logger.info(`writer 자동 추가 - ${userId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,5 @@ router.get("/data/:groupCode", getAutoFillData);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,5 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,5 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,5 @@ router.get("/options/:exclusionCode", getExcludedOptions);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
getCategoryColumns,
|
getCategoryColumns,
|
||||||
|
getAllCategoryColumns,
|
||||||
getCategoryValues,
|
getCategoryValues,
|
||||||
addCategoryValue,
|
addCategoryValue,
|
||||||
updateCategoryValue,
|
updateCategoryValue,
|
||||||
|
|
@ -22,6 +23,10 @@ const router = Router();
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
// 주의: 더 구체적인 라우트보다 먼저 와야 함
|
||||||
|
router.get("/all-columns", getAllCategoryColumns);
|
||||||
|
|
||||||
// 테이블의 카테고리 컬럼 목록 조회
|
// 테이블의 카테고리 컬럼 목록 조회
|
||||||
router.get("/:tableName/columns", getCategoryColumns);
|
router.get("/:tableName/columns", getCategoryColumns);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,12 @@ export class CommonCodeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
// 회사별 필터링 (최고 관리자가 아닌 경우)
|
||||||
|
// company_code = '*'인 공통 데이터도 함께 조회
|
||||||
if (userCompanyCode && userCompanyCode !== "*") {
|
if (userCompanyCode && userCompanyCode !== "*") {
|
||||||
whereConditions.push(`company_code = $${paramIndex}`);
|
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
|
||||||
values.push(userCompanyCode);
|
values.push(userCompanyCode);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`);
|
logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`);
|
||||||
} else if (userCompanyCode === "*") {
|
} else if (userCompanyCode === "*") {
|
||||||
// 최고 관리자는 모든 데이터 조회 가능
|
// 최고 관리자는 모든 데이터 조회 가능
|
||||||
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
logger.info(`최고 관리자: 모든 코드 카테고리 조회`);
|
||||||
|
|
@ -116,7 +117,7 @@ export class CommonCodeService {
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 카테고리 조회
|
// code_category 테이블에서만 조회 (comm_code 제거)
|
||||||
const categories = await query<CodeCategory>(
|
const categories = await query<CodeCategory>(
|
||||||
`SELECT * FROM code_category
|
`SELECT * FROM code_category
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -134,7 +135,7 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
`카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -224,7 +225,7 @@ export class CommonCodeService {
|
||||||
paramIndex,
|
paramIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 코드 조회
|
// code_info 테이블에서만 코드 조회 (comm_code fallback 제거)
|
||||||
const codes = await query<CodeInfo>(
|
const codes = await query<CodeInfo>(
|
||||||
`SELECT * FROM code_info
|
`SELECT * FROM code_info
|
||||||
${whereClause}
|
${whereClause}
|
||||||
|
|
@ -242,20 +243,9 @@ export class CommonCodeService {
|
||||||
const total = parseInt(countResult?.count || "0");
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
|
|
||||||
categoryCode,
|
|
||||||
menuObjid,
|
|
||||||
codes: codes.map((c) => ({
|
|
||||||
code_value: c.code_value,
|
|
||||||
code_name: c.code_name,
|
|
||||||
menu_objid: c.menu_objid,
|
|
||||||
company_code: c.company_code,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { data: codes, total };
|
return { data: codes, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -854,6 +854,11 @@ export class DynamicFormService {
|
||||||
if (tableColumns.includes("updated_at")) {
|
if (tableColumns.includes("updated_at")) {
|
||||||
changedFields.updated_at = new Date();
|
changedFields.updated_at = new Date();
|
||||||
}
|
}
|
||||||
|
// updated_date 컬럼도 지원 (sales_order_mng 등)
|
||||||
|
if (tableColumns.includes("updated_date")) {
|
||||||
|
changedFields.updated_date = new Date();
|
||||||
|
console.log("📅 updated_date 자동 추가:", changedFields.updated_date);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
console.log("🎯 실제 업데이트할 필드들:", changedFields);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,8 +186,13 @@ export class EntityJoinService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
// 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성
|
||||||
const aliasColumn = `${column.column_name}_name`;
|
// 단일 컬럼: manager + user_name → manager_user_name
|
||||||
|
// 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨)
|
||||||
|
const firstDisplayColumn = displayColumns[0] || "name";
|
||||||
|
const aliasColumn = `${column.column_name}_${firstDisplayColumn}`;
|
||||||
|
|
||||||
|
logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`);
|
||||||
|
|
||||||
const joinConfig: EntityJoinConfig = {
|
const joinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,8 @@ export class MenuCopyService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 수집
|
* 플로우 수집
|
||||||
|
* - 화면 레이아웃에서 참조된 모든 flowId 수집
|
||||||
|
* - dataflowConfig.flowConfig.flowId 및 selectedDiagramId 모두 수집
|
||||||
*/
|
*/
|
||||||
private async collectFlows(
|
private async collectFlows(
|
||||||
screenIds: Set<number>,
|
screenIds: Set<number>,
|
||||||
|
|
@ -340,6 +342,7 @@ export class MenuCopyService {
|
||||||
logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`);
|
logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`);
|
||||||
|
|
||||||
const flowIds = new Set<number>();
|
const flowIds = new Set<number>();
|
||||||
|
const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = [];
|
||||||
|
|
||||||
for (const screenId of screenIds) {
|
for (const screenId of screenIds) {
|
||||||
const layoutsResult = await client.query<ScreenLayout>(
|
const layoutsResult = await client.query<ScreenLayout>(
|
||||||
|
|
@ -352,13 +355,35 @@ export class MenuCopyService {
|
||||||
|
|
||||||
// webTypeConfig.dataflowConfig.flowConfig.flowId
|
// webTypeConfig.dataflowConfig.flowConfig.flowId
|
||||||
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
|
const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId;
|
||||||
if (flowId) {
|
const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown";
|
||||||
|
|
||||||
|
if (flowId && typeof flowId === "number" && flowId > 0) {
|
||||||
|
if (!flowIds.has(flowId)) {
|
||||||
flowIds.add(flowId);
|
flowIds.add(flowId);
|
||||||
|
flowDetails.push({ flowId, flowName, screenId });
|
||||||
|
logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음)
|
||||||
|
const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId;
|
||||||
|
if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) {
|
||||||
|
if (!flowIds.has(selectedDiagramId)) {
|
||||||
|
flowIds.add(selectedDiagramId);
|
||||||
|
flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId });
|
||||||
|
logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (flowIds.size > 0) {
|
||||||
logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`);
|
logger.info(`✅ 플로우 수집 완료: ${flowIds.size}개`);
|
||||||
|
logger.info(` 📋 수집된 flowIds: [${Array.from(flowIds).join(", ")}]`);
|
||||||
|
} else {
|
||||||
|
logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`);
|
||||||
|
}
|
||||||
|
|
||||||
return flowIds;
|
return flowIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -473,16 +498,22 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// flowId 매핑 (숫자 또는 숫자 문자열)
|
// flowId, selectedDiagramId 매핑 (숫자 또는 숫자 문자열)
|
||||||
if (key === "flowId") {
|
// selectedDiagramId는 dataflowConfig에서 flowId와 동일한 값을 참조하므로 함께 변환
|
||||||
|
if (key === "flowId" || key === "selectedDiagramId") {
|
||||||
const numValue = typeof value === "number" ? value : parseInt(value);
|
const numValue = typeof value === "number" ? value : parseInt(value);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue) && numValue > 0) {
|
||||||
const newId = flowIdMap.get(numValue);
|
const newId = flowIdMap.get(numValue);
|
||||||
if (newId) {
|
if (newId) {
|
||||||
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지
|
||||||
logger.debug(
|
logger.info(
|
||||||
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}`
|
` 🔗 플로우 참조 업데이트 (${currentPath}): ${value} → ${newId}`
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// 매핑이 없으면 경고 로그
|
||||||
|
logger.warn(
|
||||||
|
` ⚠️ 플로우 매핑 없음 (${currentPath}): ${value} - 원본 플로우가 복사되지 않았을 수 있음`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -742,6 +773,8 @@ export class MenuCopyService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 복사
|
* 플로우 복사
|
||||||
|
* - 대상 회사에 같은 이름+테이블의 플로우가 있으면 재사용 (ID 매핑만)
|
||||||
|
* - 없으면 새로 복사
|
||||||
*/
|
*/
|
||||||
private async copyFlows(
|
private async copyFlows(
|
||||||
flowIds: Set<number>,
|
flowIds: Set<number>,
|
||||||
|
|
@ -757,10 +790,11 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`);
|
logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`);
|
||||||
|
logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`);
|
||||||
|
|
||||||
for (const originalFlowId of flowIds) {
|
for (const originalFlowId of flowIds) {
|
||||||
try {
|
try {
|
||||||
// 1) flow_definition 조회
|
// 1) 원본 flow_definition 조회
|
||||||
const flowDefResult = await client.query<FlowDefinition>(
|
const flowDefResult = await client.query<FlowDefinition>(
|
||||||
`SELECT * FROM flow_definition WHERE id = $1`,
|
`SELECT * FROM flow_definition WHERE id = $1`,
|
||||||
[originalFlowId]
|
[originalFlowId]
|
||||||
|
|
@ -772,8 +806,29 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowDef = flowDefResult.rows[0];
|
const flowDef = flowDefResult.rows[0];
|
||||||
|
logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`);
|
||||||
|
|
||||||
// 2) flow_definition 복사
|
// 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인
|
||||||
|
const existingFlowResult = await client.query<{ id: number }>(
|
||||||
|
`SELECT id FROM flow_definition
|
||||||
|
WHERE company_code = $1 AND name = $2 AND table_name = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[targetCompanyCode, flowDef.name, flowDef.table_name]
|
||||||
|
);
|
||||||
|
|
||||||
|
let newFlowId: number;
|
||||||
|
|
||||||
|
if (existingFlowResult.rows.length > 0) {
|
||||||
|
// 기존 플로우가 있으면 재사용
|
||||||
|
newFlowId = existingFlowResult.rows[0].id;
|
||||||
|
flowIdMap.set(originalFlowId, newFlowId);
|
||||||
|
logger.info(
|
||||||
|
` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})`
|
||||||
|
);
|
||||||
|
continue; // 스텝/연결 복사 생략 (기존 것 사용)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 새 flow_definition 복사
|
||||||
const newFlowResult = await client.query<{ id: number }>(
|
const newFlowResult = await client.query<{ id: number }>(
|
||||||
`INSERT INTO flow_definition (
|
`INSERT INTO flow_definition (
|
||||||
name, description, table_name, is_active,
|
name, description, table_name, is_active,
|
||||||
|
|
@ -792,11 +847,11 @@ export class MenuCopyService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const newFlowId = newFlowResult.rows[0].id;
|
newFlowId = newFlowResult.rows[0].id;
|
||||||
flowIdMap.set(originalFlowId, newFlowId);
|
flowIdMap.set(originalFlowId, newFlowId);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
` ✅ 플로우 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})`
|
` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3) flow_step 복사
|
// 3) flow_step 복사
|
||||||
|
|
|
||||||
|
|
@ -1751,7 +1751,7 @@ export class ScreenManagementService {
|
||||||
// 기타
|
// 기타
|
||||||
label: "text-display",
|
label: "text-display",
|
||||||
code: "select-basic",
|
code: "select-basic",
|
||||||
entity: "select-basic",
|
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
|
||||||
category: "select-basic",
|
category: "select-basic",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,82 @@ class TableCategoryValueService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용)
|
||||||
|
* 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다.
|
||||||
|
*/
|
||||||
|
async getAllCategoryColumns(
|
||||||
|
companyCode: string
|
||||||
|
): Promise<CategoryColumn[]> {
|
||||||
|
try {
|
||||||
|
logger.info("전체 카테고리 컬럼 목록 조회", { companyCode });
|
||||||
|
|
||||||
|
const pool = getPool();
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (companyCode === "*") {
|
||||||
|
// 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거)
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
tc.table_name AS "tableName",
|
||||||
|
tc.column_name AS "columnName",
|
||||||
|
tc.column_name AS "columnLabel",
|
||||||
|
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE input_type = 'category'
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) tc
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT table_name, column_name, COUNT(*) as cnt
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE is_active = true
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||||
|
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
||||||
|
`;
|
||||||
|
params = [];
|
||||||
|
} else {
|
||||||
|
// 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거)
|
||||||
|
query = `
|
||||||
|
SELECT
|
||||||
|
tc.table_name AS "tableName",
|
||||||
|
tc.column_name AS "columnName",
|
||||||
|
tc.column_name AS "columnLabel",
|
||||||
|
COALESCE(cv_count.cnt, 0) AS "valueCount"
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE input_type = 'category'
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) tc
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT table_name, column_name, COUNT(*) as cnt
|
||||||
|
FROM table_column_category_values
|
||||||
|
WHERE is_active = true AND company_code = $1
|
||||||
|
GROUP BY table_name, column_name
|
||||||
|
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
|
||||||
|
ORDER BY tc.table_name, tc.display_order, tc.column_name
|
||||||
|
`;
|
||||||
|
params = [companyCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1447,7 +1447,8 @@ export class TableManagementService {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
actualValue,
|
actualValue,
|
||||||
paramIndex
|
paramIndex,
|
||||||
|
operator // operator 전달 (equals면 직접 매칭)
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -1676,7 +1677,8 @@ export class TableManagementService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
value: any,
|
value: any,
|
||||||
paramIndex: number
|
paramIndex: number,
|
||||||
|
operator: string = "contains" // 연결 필터에서 "equals"로 전달되면 직접 매칭
|
||||||
): Promise<{
|
): Promise<{
|
||||||
whereClause: string;
|
whereClause: string;
|
||||||
values: any[];
|
values: any[];
|
||||||
|
|
@ -1688,7 +1690,7 @@ export class TableManagementService {
|
||||||
columnName
|
columnName
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 배열 처리: IN 절 사용
|
// 배열 처리: IN 절 사용
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
// 빈 배열이면 항상 false 조건
|
// 빈 배열이면 항상 false 조건
|
||||||
|
|
@ -1720,13 +1722,35 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
const displayColumn = entityTypeInfo.displayColumn || "name";
|
// equals 연산자인 경우: 직접 값 매칭 (연결 필터에서 코드 값으로 필터링 시 사용)
|
||||||
|
if (operator === "equals") {
|
||||||
|
logger.info(
|
||||||
|
`🔍 [buildEntitySearchCondition] equals 연산자 - 직접 매칭: ${columnName} = ${value}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName} = $${paramIndex}`,
|
||||||
|
values: [value],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색
|
||||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||||
|
const referenceTable = entityTypeInfo.referenceTable;
|
||||||
|
|
||||||
|
// displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직)
|
||||||
|
let displayColumn = entityTypeInfo.displayColumn;
|
||||||
|
if (!displayColumn || displayColumn === "none" || displayColumn === "") {
|
||||||
|
displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn);
|
||||||
|
logger.info(
|
||||||
|
`🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 참조 테이블의 표시 컬럼으로 검색
|
// 참조 테이블의 표시 컬럼으로 검색
|
||||||
return {
|
return {
|
||||||
whereClause: `EXISTS (
|
whereClause: `EXISTS (
|
||||||
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
|
SELECT 1 FROM ${referenceTable} ref
|
||||||
WHERE ref.${referenceColumn} = ${columnName}
|
WHERE ref.${referenceColumn} = ${columnName}
|
||||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||||
)`,
|
)`,
|
||||||
|
|
@ -1754,6 +1778,66 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 참조 테이블에서 표시 컬럼 자동 감지 (entityJoinService와 동일한 우선순위)
|
||||||
|
* 우선순위: *_name > name > label/*_label > title > referenceColumn
|
||||||
|
*/
|
||||||
|
private async findDisplayColumnForTable(
|
||||||
|
tableName: string,
|
||||||
|
referenceColumn?: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND table_schema = 'public'
|
||||||
|
ORDER BY ordinal_position`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
const allColumns = result.map((r) => r.column_name);
|
||||||
|
|
||||||
|
// entityJoinService와 동일한 우선순위
|
||||||
|
// 1. *_name 컬럼 (item_name, customer_name, process_name 등) - company_name 제외
|
||||||
|
const nameColumn = allColumns.find(
|
||||||
|
(col) => col.endsWith("_name") && col !== "company_name"
|
||||||
|
);
|
||||||
|
if (nameColumn) {
|
||||||
|
return nameColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. name 컬럼
|
||||||
|
if (allColumns.includes("name")) {
|
||||||
|
return "name";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. label 또는 *_label 컬럼
|
||||||
|
const labelColumn = allColumns.find(
|
||||||
|
(col) => col === "label" || col.endsWith("_label")
|
||||||
|
);
|
||||||
|
if (labelColumn) {
|
||||||
|
return labelColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. title 컬럼
|
||||||
|
if (allColumns.includes("title")) {
|
||||||
|
return "title";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 참조 컬럼 (referenceColumn)
|
||||||
|
if (referenceColumn && allColumns.includes(referenceColumn)) {
|
||||||
|
return referenceColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 기본값: 첫 번째 비-id 컬럼 또는 id
|
||||||
|
return allColumns.find((col) => col !== "id") || "id";
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`표시 컬럼 감지 실패: ${tableName}`, error);
|
||||||
|
return referenceColumn || "id"; // 오류 시 기본값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 불린 검색 조건 구성
|
* 불린 검색 조건 구성
|
||||||
*/
|
*/
|
||||||
|
|
@ -2205,6 +2289,13 @@ export class TableManagementService {
|
||||||
|
|
||||||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||||
|
|
||||||
|
// created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가
|
||||||
|
const hasCreatedDate = columnTypeMap.has("created_date");
|
||||||
|
if (hasCreatedDate && !data.created_date) {
|
||||||
|
data.created_date = new Date().toISOString();
|
||||||
|
logger.info(`created_date 자동 추가: ${data.created_date}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
// 컬럼명과 값을 분리하고 타입에 맞게 변환
|
||||||
const columns = Object.keys(data);
|
const columns = Object.keys(data);
|
||||||
const values = Object.values(data).map((value, index) => {
|
const values = Object.values(data).map((value, index) => {
|
||||||
|
|
@ -2310,6 +2401,13 @@ export class TableManagementService {
|
||||||
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
|
||||||
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
|
||||||
|
|
||||||
|
// updated_date 컬럼이 있으면 자동으로 현재 시간 추가
|
||||||
|
const hasUpdatedDate = columnTypeMap.has("updated_date");
|
||||||
|
if (hasUpdatedDate && !updatedData.updated_date) {
|
||||||
|
updatedData.updated_date = new Date().toISOString();
|
||||||
|
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||||
|
}
|
||||||
|
|
||||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||||
const setConditions: string[] = [];
|
const setConditions: string[] = [];
|
||||||
const setValues: any[] = [];
|
const setValues: any[] = [];
|
||||||
|
|
|
||||||
|
|
@ -583,3 +583,5 @@ const result = await executeNodeFlow(flowId, {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -356,3 +356,5 @@
|
||||||
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
# 즉시 저장(quickInsert) 버튼 액션 구현 계획서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 목적
|
||||||
|
화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현
|
||||||
|
|
||||||
|
### 1.2 사용 사례
|
||||||
|
- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장
|
||||||
|
|
||||||
|
### 1.3 화면 구성 예시
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ [entity 선택박스] [버튼: quickInsert] │
|
||||||
|
│ ┌─────────────────────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │
|
||||||
|
│ └─────────────────────────────┘ └──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 기술 설계
|
||||||
|
|
||||||
|
### 2.1 버튼 액션 타입 추가
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types/screen-management.ts
|
||||||
|
type ButtonActionType =
|
||||||
|
| "save"
|
||||||
|
| "cancel"
|
||||||
|
| "delete"
|
||||||
|
| "edit"
|
||||||
|
| "add"
|
||||||
|
| "search"
|
||||||
|
| "reset"
|
||||||
|
| "submit"
|
||||||
|
| "close"
|
||||||
|
| "popup"
|
||||||
|
| "navigate"
|
||||||
|
| "custom"
|
||||||
|
| "quickInsert" // 🆕 즉시 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 quickInsert 설정 구조
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface QuickInsertColumnMapping {
|
||||||
|
targetColumn: string; // 저장할 테이블의 컬럼명
|
||||||
|
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
|
||||||
|
|
||||||
|
// sourceType별 추가 설정
|
||||||
|
sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID
|
||||||
|
sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명
|
||||||
|
fixedValue?: any; // fixed: 고정값
|
||||||
|
userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickInsertConfig {
|
||||||
|
targetTable: string; // 저장할 테이블명
|
||||||
|
columnMappings: QuickInsertColumnMapping[];
|
||||||
|
|
||||||
|
// 저장 후 동작
|
||||||
|
afterInsert?: {
|
||||||
|
refreshRightPanel?: boolean; // 우측 패널 새로고침
|
||||||
|
clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록
|
||||||
|
showSuccessMessage?: boolean; // 성공 메시지 표시
|
||||||
|
successMessage?: string; // 커스텀 성공 메시지
|
||||||
|
};
|
||||||
|
|
||||||
|
// 중복 체크 (선택사항)
|
||||||
|
duplicateCheck?: {
|
||||||
|
enabled: boolean;
|
||||||
|
columns: string[]; // 중복 체크할 컬럼들
|
||||||
|
errorMessage?: string; // 중복 시 에러 메시지
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ButtonComponentConfig {
|
||||||
|
// 기존 설정들...
|
||||||
|
actionType: ButtonActionType;
|
||||||
|
|
||||||
|
// 🆕 quickInsert 전용 설정
|
||||||
|
quickInsertConfig?: QuickInsertConfig;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사용자가 entity 선택박스에서 설비 선택
|
||||||
|
└─ equipment_code = "EQ-001" (내부값)
|
||||||
|
└─ 표시: "MCT-01 - 머시닝센터 #1"
|
||||||
|
|
||||||
|
2. 사용자가 "설비 추가" 버튼 클릭
|
||||||
|
|
||||||
|
3. quickInsert 핸들러 실행
|
||||||
|
├─ columnMappings 순회
|
||||||
|
│ ├─ equipment_code: component에서 값 가져오기 → "EQ-001"
|
||||||
|
│ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001"
|
||||||
|
│
|
||||||
|
└─ INSERT 데이터 구성
|
||||||
|
{
|
||||||
|
equipment_code: "EQ-001",
|
||||||
|
process_code: "PRC-001",
|
||||||
|
company_code: "COMPANY_7", // 자동 추가
|
||||||
|
writer: "wace" // 자동 추가
|
||||||
|
}
|
||||||
|
|
||||||
|
4. API 호출: POST /api/table-management/tables/process_equipment/add
|
||||||
|
|
||||||
|
5. 성공 시
|
||||||
|
├─ 성공 메시지 표시
|
||||||
|
├─ 우측 패널(카드/테이블) 새로고침
|
||||||
|
└─ 선택박스 초기화
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 계획
|
||||||
|
|
||||||
|
### 3.1 Phase 1: 타입 정의 및 설정 UI
|
||||||
|
|
||||||
|
| 작업 | 파일 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 |
|
||||||
|
| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 |
|
||||||
|
|
||||||
|
### 3.2 Phase 2: 버튼 액션 핸들러 구현
|
||||||
|
|
||||||
|
| 작업 | 파일 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 |
|
||||||
|
| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 |
|
||||||
|
|
||||||
|
### 3.3 Phase 3: 테스트 및 검증
|
||||||
|
|
||||||
|
| 작업 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 3-1 | 공정별 설비 화면에서 테스트 |
|
||||||
|
| 3-2 | 중복 저장 방지 테스트 |
|
||||||
|
| 3-3 | 에러 처리 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 상세 구현
|
||||||
|
|
||||||
|
### 4.1 ButtonConfigPanel 설정 UI
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 버튼 액션 타입 │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 즉시 저장 (quickInsert) ▼ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ─────────────── 즉시 저장 설정 ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ 대상 테이블 * │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ process_equipment ▼ │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 컬럼 매핑 [+ 추가] │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 매핑 #1 [삭제] │ │
|
||||||
|
│ │ 대상 컬럼: equipment_code │ │
|
||||||
|
│ │ 값 소스: 컴포넌트 선택 │ │
|
||||||
|
│ │ 컴포넌트: [equipment-select ▼] │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 매핑 #2 [삭제] │ │
|
||||||
|
│ │ 대상 컬럼: process_code │ │
|
||||||
|
│ │ 값 소스: 좌측 패널 데이터 │ │
|
||||||
|
│ │ 소스 컬럼: process_code │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ─────────────── 저장 후 동작 ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ ☑ 우측 패널 새로고침 │
|
||||||
|
│ ☑ 선택박스 초기화 │
|
||||||
|
│ ☑ 성공 메시지 표시 │
|
||||||
|
│ │
|
||||||
|
│ ─────────────── 중복 체크 (선택) ─────────────── │
|
||||||
|
│ │
|
||||||
|
│ ☐ 중복 체크 활성화 │
|
||||||
|
│ 체크 컬럼: equipment_code, process_code │
|
||||||
|
│ 에러 메시지: 이미 등록된 설비입니다. │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 핸들러 구현 (의사 코드)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleQuickInsert = async (config: QuickInsertConfig) => {
|
||||||
|
// 1. 컬럼 매핑에서 값 수집
|
||||||
|
const insertData: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const mapping of config.columnMappings) {
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
switch (mapping.sourceType) {
|
||||||
|
case "component":
|
||||||
|
// 같은 화면의 컴포넌트에서 값 가져오기
|
||||||
|
value = getComponentValue(mapping.sourceComponentId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "leftPanel":
|
||||||
|
// 분할 패널 좌측 선택 데이터에서 값 가져오기
|
||||||
|
value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fixed":
|
||||||
|
value = mapping.fixedValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "currentUser":
|
||||||
|
value = user?.[mapping.userField];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
insertData[mapping.targetColumn] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 필수값 검증
|
||||||
|
if (Object.keys(insertData).length === 0) {
|
||||||
|
toast.error("저장할 데이터가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 중복 체크 (설정된 경우)
|
||||||
|
if (config.duplicateCheck?.enabled) {
|
||||||
|
const isDuplicate = await checkDuplicate(
|
||||||
|
config.targetTable,
|
||||||
|
config.duplicateCheck.columns,
|
||||||
|
insertData
|
||||||
|
);
|
||||||
|
if (isDuplicate) {
|
||||||
|
toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. API 호출
|
||||||
|
try {
|
||||||
|
await tableTypeApi.addTableData(config.targetTable, insertData);
|
||||||
|
|
||||||
|
// 5. 성공 후 동작
|
||||||
|
if (config.afterInsert?.showSuccessMessage) {
|
||||||
|
toast.success(config.afterInsert.successMessage || "저장되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.afterInsert?.refreshRightPanel) {
|
||||||
|
// 우측 패널 새로고침 트리거
|
||||||
|
onRefresh?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.afterInsert?.clearComponents) {
|
||||||
|
// 지정된 컴포넌트 초기화
|
||||||
|
for (const componentId of config.afterInsert.clearComponents) {
|
||||||
|
clearComponentValue(componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 컴포넌트 간 통신 방안
|
||||||
|
|
||||||
|
### 5.1 문제점
|
||||||
|
- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함
|
||||||
|
- 현재는 각 컴포넌트가 독립적으로 동작
|
||||||
|
|
||||||
|
### 5.2 해결 방안: formData 활용
|
||||||
|
|
||||||
|
현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// InteractiveScreenViewerDynamic.tsx
|
||||||
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// entity 선택박스에서 값 변경 시
|
||||||
|
const handleFormDataChange = (fieldName: string, value: any) => {
|
||||||
|
setLocalFormData(prev => ({ ...prev, [fieldName]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 버튼 클릭 시 formData에서 값 가져오기
|
||||||
|
const getComponentValue = (componentId: string) => {
|
||||||
|
// componentId로 컴포넌트의 columnName 찾기
|
||||||
|
const component = allComponents.find(c => c.id === componentId);
|
||||||
|
if (component?.columnName) {
|
||||||
|
return formData[component.columnName];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 시나리오
|
||||||
|
|
||||||
|
### 6.1 정상 케이스
|
||||||
|
1. 좌측 테이블에서 공정 "PRC-001" 선택
|
||||||
|
2. 우측 설비 선택박스에서 "MCT-01" 선택
|
||||||
|
3. "설비 추가" 버튼 클릭
|
||||||
|
4. `process_equipment` 테이블에 데이터 저장 확인
|
||||||
|
5. 우측 카드/테이블에 새 항목 표시 확인
|
||||||
|
|
||||||
|
### 6.2 에러 케이스
|
||||||
|
1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지
|
||||||
|
2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지
|
||||||
|
3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지
|
||||||
|
|
||||||
|
### 6.3 엣지 케이스
|
||||||
|
1. 동일 설비 연속 추가 시도
|
||||||
|
2. 네트워크 오류 시 재시도
|
||||||
|
3. 권한 없는 사용자의 저장 시도
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 일정
|
||||||
|
|
||||||
|
| Phase | 작업 | 예상 시간 |
|
||||||
|
|-------|------|----------|
|
||||||
|
| Phase 1 | 타입 정의 및 설정 UI | 1시간 |
|
||||||
|
| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 |
|
||||||
|
| Phase 3 | 테스트 및 검증 | 30분 |
|
||||||
|
| **합계** | | **2시간 30분** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 향후 확장 가능성
|
||||||
|
|
||||||
|
1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가
|
||||||
|
2. **수정 모드**: 기존 데이터 수정 기능
|
||||||
|
3. **조건부 저장**: 특정 조건 만족 시에만 저장
|
||||||
|
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -459,11 +459,39 @@ export default function TableManagementPage() {
|
||||||
if (!selectedTable) return;
|
if (!selectedTable) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함
|
||||||
|
let finalDetailSettings = column.detailSettings || "";
|
||||||
|
|
||||||
|
if (column.inputType === "entity" && column.referenceTable) {
|
||||||
|
// 기존 detailSettings를 파싱하거나 새로 생성
|
||||||
|
let existingSettings: Record<string, unknown> = {};
|
||||||
|
if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) {
|
||||||
|
try {
|
||||||
|
existingSettings = JSON.parse(column.detailSettings);
|
||||||
|
} catch {
|
||||||
|
existingSettings = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티 설정 추가
|
||||||
|
const entitySettings = {
|
||||||
|
...existingSettings,
|
||||||
|
entityTable: column.referenceTable,
|
||||||
|
entityCodeColumn: column.referenceColumn || "id",
|
||||||
|
entityLabelColumn: column.displayColumn || "name",
|
||||||
|
placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요",
|
||||||
|
searchable: existingSettings.searchable ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
finalDetailSettings = JSON.stringify(entitySettings);
|
||||||
|
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
|
||||||
|
}
|
||||||
|
|
||||||
const columnSetting = {
|
const columnSetting = {
|
||||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||||
inputType: column.inputType || "text",
|
inputType: column.inputType || "text",
|
||||||
detailSettings: column.detailSettings || "",
|
detailSettings: finalDetailSettings,
|
||||||
codeCategory: column.codeCategory || "",
|
codeCategory: column.codeCategory || "",
|
||||||
codeValue: column.codeValue || "",
|
codeValue: column.codeValue || "",
|
||||||
referenceTable: column.referenceTable || "",
|
referenceTable: column.referenceTable || "",
|
||||||
|
|
@ -547,7 +575,7 @@ export default function TableManagementPage() {
|
||||||
} else if (successCount > 0 && failCount > 0) {
|
} else if (successCount > 0 && failCount > 0) {
|
||||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||||
} else if (failCount > 0) {
|
} else if (failCount > 0) {
|
||||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
|
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
|
||||||
|
|
@ -680,9 +708,7 @@ export default function TableManagementPage() {
|
||||||
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
||||||
|
|
||||||
if (totalSuccessCount > 0) {
|
if (totalSuccessCount > 0) {
|
||||||
toast.success(
|
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
||||||
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
|
|
||||||
);
|
|
||||||
} else if (totalFailCount > 0) {
|
} else if (totalFailCount > 0) {
|
||||||
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
|
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1000,14 +1026,15 @@ export default function TableManagementPage() {
|
||||||
.filter(
|
.filter(
|
||||||
(table) =>
|
(table) =>
|
||||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
(table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
(table.displayName &&
|
||||||
|
table.displayName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||||
)
|
)
|
||||||
.every((table) => selectedTableIds.has(table.tableName))
|
.every((table) => selectedTableIds.has(table.tableName))
|
||||||
}
|
}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
aria-label="전체 선택"
|
aria-label="전체 선택"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-muted-foreground text-sm">
|
||||||
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
|
{selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1048,8 +1075,8 @@ export default function TableManagementPage() {
|
||||||
key={table.tableName}
|
key={table.tableName}
|
||||||
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
|
className={`bg-card rounded-lg p-4 shadow-sm transition-all ${
|
||||||
selectedTable === table.tableName
|
selectedTable === table.tableName
|
||||||
? "shadow-md bg-muted/30"
|
? "bg-muted/30 shadow-md"
|
||||||
: "hover:shadow-lg hover:bg-muted/20"
|
: "hover:bg-muted/20 hover:shadow-lg"
|
||||||
}`}
|
}`}
|
||||||
style={
|
style={
|
||||||
selectedTable === table.tableName
|
selectedTable === table.tableName
|
||||||
|
|
@ -1068,10 +1095,7 @@ export default function TableManagementPage() {
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="flex-1 cursor-pointer" onClick={() => handleTableSelect(table.tableName)}>
|
||||||
className="flex-1 cursor-pointer"
|
|
||||||
onClick={() => handleTableSelect(table.tableName)}
|
|
||||||
>
|
|
||||||
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
<h4 className="text-sm font-semibold">{table.displayName || table.tableName}</h4>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")}
|
||||||
|
|
@ -1147,7 +1171,10 @@ export default function TableManagementPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* 컬럼 헤더 (고정) */}
|
{/* 컬럼 헤더 (고정) */}
|
||||||
<div className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
|
<div
|
||||||
|
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
|
||||||
|
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||||
|
>
|
||||||
<div className="pr-4">컬럼명</div>
|
<div className="pr-4">컬럼명</div>
|
||||||
<div className="px-4">라벨</div>
|
<div className="px-4">라벨</div>
|
||||||
<div className="pr-6">입력 타입</div>
|
<div className="pr-6">입력 타입</div>
|
||||||
|
|
@ -1171,7 +1198,7 @@ export default function TableManagementPage() {
|
||||||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||||
>
|
>
|
||||||
<div className="pr-4 pt-1">
|
<div className="pt-1 pr-4">
|
||||||
<div className="font-mono text-sm">{column.columnName}</div>
|
<div className="font-mono text-sm">{column.columnName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
|
|
@ -1226,9 +1253,9 @@ export default function TableManagementPage() {
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">
|
||||||
적용할 메뉴 (2레벨)
|
적용할 메뉴 (2레벨)
|
||||||
</label>
|
</label>
|
||||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border p-3">
|
||||||
{secondLevelMenus.length === 0 ? (
|
{secondLevelMenus.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1253,15 +1280,15 @@ export default function TableManagementPage() {
|
||||||
prev.map((col) =>
|
prev.map((col) =>
|
||||||
col.columnName === column.columnName
|
col.columnName === column.columnName
|
||||||
? { ...col, categoryMenus: newMenus }
|
? { ...col, categoryMenus: newMenus }
|
||||||
: col
|
: col,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||||
className="text-xs cursor-pointer flex-1"
|
className="flex-1 cursor-pointer text-xs"
|
||||||
>
|
>
|
||||||
{menu.parentMenuName} → {menu.menuName}
|
{menu.parentMenuName} → {menu.menuName}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -1282,9 +1309,7 @@ export default function TableManagementPage() {
|
||||||
<>
|
<>
|
||||||
{/* 참조 테이블 */}
|
{/* 참조 테이블 */}
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
||||||
참조 테이블
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceTable || "none"}
|
value={column.referenceTable || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -1296,15 +1321,10 @@ export default function TableManagementPage() {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceTableOptions.map((option, index) => (
|
{referenceTableOptions.map((option, index) => (
|
||||||
<SelectItem
|
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
||||||
key={`entity-${option.value}-${index}`}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{option.label}</span>
|
<span className="font-medium">{option.label}</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">{option.value}</span>
|
||||||
{option.value}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1315,9 +1335,7 @@ export default function TableManagementPage() {
|
||||||
{/* 조인 컬럼 */}
|
{/* 조인 컬럼 */}
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
||||||
조인 컬럼
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={column.referenceColumn || "none"}
|
value={column.referenceColumn || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -1361,9 +1379,7 @@ export default function TableManagementPage() {
|
||||||
column.referenceColumn &&
|
column.referenceColumn &&
|
||||||
column.referenceColumn !== "none" && (
|
column.referenceColumn !== "none" && (
|
||||||
<div className="w-48">
|
<div className="w-48">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">
|
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||||
표시 컬럼
|
|
||||||
</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={column.displayColumn || "none"}
|
value={column.displayColumn || "none"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -1408,7 +1424,7 @@ export default function TableManagementPage() {
|
||||||
column.referenceColumn !== "none" &&
|
column.referenceColumn !== "none" &&
|
||||||
column.displayColumn &&
|
column.displayColumn &&
|
||||||
column.displayColumn !== "none" && (
|
column.displayColumn !== "none" && (
|
||||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs">
|
||||||
<span>✓</span>
|
<span>✓</span>
|
||||||
<span className="truncate">설정 완료</span>
|
<span className="truncate">설정 완료</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1460,7 +1476,8 @@ export default function TableManagementPage() {
|
||||||
setDuplicateSourceTable(null);
|
setDuplicateSourceTable(null);
|
||||||
}}
|
}}
|
||||||
onSuccess={async (result) => {
|
onSuccess={async (result) => {
|
||||||
const message = duplicateModalMode === "duplicate"
|
const message =
|
||||||
|
duplicateModalMode === "duplicate"
|
||||||
? "테이블이 성공적으로 복제되었습니다!"
|
? "테이블이 성공적으로 복제되었습니다!"
|
||||||
: "테이블이 성공적으로 생성되었습니다!";
|
: "테이블이 성공적으로 생성되었습니다!";
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
|
|
@ -1516,13 +1533,10 @@ export default function TableManagementPage() {
|
||||||
{selectedTableIds.size > 0 ? (
|
{selectedTableIds.size > 0 ? (
|
||||||
<>
|
<>
|
||||||
선택된 <strong>{selectedTableIds.size}개</strong>의 테이블을 삭제하시겠습니까?
|
선택된 <strong>{selectedTableIds.size}개</strong>의 테이블을 삭제하시겠습니까?
|
||||||
<br />
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
이 작업은 되돌릴 수 없습니다.
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</>
|
||||||
정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -1600,4 +1614,3 @@ export default function TableManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
|
||||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
||||||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
|
||||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
|
||||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
|
||||||
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
@ -307,6 +308,7 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
|
||||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||||
|
|
@ -787,6 +789,7 @@ function ScreenViewPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
|
</ActiveTabProvider>
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Save,
|
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||||
|
|
@ -52,12 +51,6 @@ interface ColumnMapping {
|
||||||
systemColumn: string | null;
|
systemColumn: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadConfig {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
mappings: ColumnMapping[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
||||||
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
||||||
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||||
const [configName, setConfigName] = useState<string>("");
|
|
||||||
const [configType, setConfigType] = useState<string>("");
|
|
||||||
|
|
||||||
// 4단계: 확인
|
// 4단계: 확인
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
|
|
||||||
const data = await importFromExcel(selectedFile, sheets[0]);
|
const data = await importFromExcel(selectedFile, sheets[0]);
|
||||||
setAllData(data);
|
setAllData(data);
|
||||||
setDisplayData(data.slice(0, 10));
|
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
const columns = Object.keys(data[0]);
|
const columns = Object.keys(data[0]);
|
||||||
|
|
@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
try {
|
try {
|
||||||
const data = await importFromExcel(file, sheetName);
|
const data = await importFromExcel(file, sheetName);
|
||||||
setAllData(data);
|
setAllData(data);
|
||||||
setDisplayData(data.slice(0, 10));
|
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
|
||||||
|
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
const columns = Object.keys(data[0]);
|
const columns = Object.keys(data[0]);
|
||||||
|
|
@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자동 매핑
|
// 자동 매핑 - 컬럼명과 라벨 모두 비교
|
||||||
const handleAutoMapping = () => {
|
const handleAutoMapping = () => {
|
||||||
const newMappings = excelColumns.map((excelCol) => {
|
const newMappings = excelColumns.map((excelCol) => {
|
||||||
const matchedSystemCol = systemColumns.find(
|
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||||
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
|
|
||||||
|
// 1. 먼저 라벨로 매칭 시도
|
||||||
|
let matchedSystemCol = systemColumns.find(
|
||||||
|
(sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
|
||||||
|
if (!matchedSystemCol) {
|
||||||
|
matchedSystemCol = systemColumns.find(
|
||||||
|
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
excelColumn: excelCol,
|
excelColumn: excelCol,
|
||||||
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
|
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
|
||||||
|
|
@ -265,28 +266,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 설정 저장
|
|
||||||
const handleSaveConfig = () => {
|
|
||||||
if (!configName.trim()) {
|
|
||||||
toast.error("거래처명을 입력해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: UploadConfig = {
|
|
||||||
name: configName,
|
|
||||||
type: configType,
|
|
||||||
mappings: columnMappings,
|
|
||||||
};
|
|
||||||
|
|
||||||
const savedConfigs = JSON.parse(
|
|
||||||
localStorage.getItem("excelUploadConfigs") || "[]"
|
|
||||||
);
|
|
||||||
savedConfigs.push(config);
|
|
||||||
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
|
|
||||||
|
|
||||||
toast.success("설정이 저장되었습니다.");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 다음 단계
|
// 다음 단계
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (currentStep === 1 && !file) {
|
if (currentStep === 1 && !file) {
|
||||||
|
|
@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mappedData = displayData.map((row) => {
|
// allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
|
||||||
|
const mappedData = allData.map((row) => {
|
||||||
const mappedRow: Record<string, any> = {};
|
const mappedRow: Record<string, any> = {};
|
||||||
columnMappings.forEach((mapping) => {
|
columnMappings.forEach((mapping) => {
|
||||||
if (mapping.systemColumn) {
|
if (mapping.systemColumn) {
|
||||||
|
|
@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
setExcelColumns([]);
|
setExcelColumns([]);
|
||||||
setSystemColumns([]);
|
setSystemColumns([]);
|
||||||
setColumnMappings([]);
|
setColumnMappings([]);
|
||||||
setConfigName("");
|
|
||||||
setConfigType("");
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|
@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */}
|
{/* 3단계: 컬럼 매핑 */}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
|
|
||||||
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
{/* 상단: 제목 + 자동 매핑 버튼 */}
|
||||||
<h3 className="mb-3 text-sm font-semibold sm:text-base">컬럼 매핑 설정</h3>
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold sm:text-base">컬럼 매핑 설정</h3>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAutoMapping}
|
onClick={handleAutoMapping}
|
||||||
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||||
>
|
>
|
||||||
<Zap className="mr-2 h-4 w-4" />
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
자동 매핑
|
자동 매핑
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 중앙: 매핑 리스트 */}
|
{/* 매핑 리스트 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||||
<div>엑셀 컬럼</div>
|
<div>엑셀 컬럼</div>
|
||||||
|
|
@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue placeholder="매핑 안함" />
|
<SelectValue placeholder="매핑 안함">
|
||||||
|
{mapping.systemColumn
|
||||||
|
? (() => {
|
||||||
|
const col = systemColumns.find(c => c.name === mapping.systemColumn);
|
||||||
|
return col?.label || mapping.systemColumn;
|
||||||
|
})()
|
||||||
|
: "매핑 안함"}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none" className="text-xs sm:text-sm">
|
<SelectItem value="none" className="text-xs sm:text-sm">
|
||||||
|
|
@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
value={col.name}
|
value={col.name}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{col.name} ({col.type})
|
{col.label || col.name} ({col.type})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽: 현재 설정 저장 */}
|
|
||||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
<h3 className="text-sm font-semibold sm:text-base">현재 설정 저장</h3>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
|
|
||||||
거래처명 *
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="config-name"
|
|
||||||
value={configName}
|
|
||||||
onChange={(e) => setConfigName(e.target.value)}
|
|
||||||
placeholder="거래처 선택"
|
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
|
|
||||||
유형
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="config-type"
|
|
||||||
value={configType}
|
|
||||||
onChange={(e) => setConfigType(e.target.value)}
|
|
||||||
placeholder="유형을 입력하세요 (예: 원자재)"
|
|
||||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSaveConfig}
|
|
||||||
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
|
|
||||||
>
|
|
||||||
<Save className="mr-2 h-3 w-3" />
|
|
||||||
설정 저장
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
<span className="font-medium">시트:</span> {selectedSheet}
|
<span className="font-medium">시트:</span> {selectedSheet}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">데이터 행:</span> {displayData.length}개
|
<span className="font-medium">데이터 행:</span> {allData.length}개
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">테이블:</span> {tableName}
|
<span className="font-medium">테이블:</span> {tableName}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -666,6 +667,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto bg-white"
|
className="relative mx-auto bg-white"
|
||||||
|
|
@ -738,6 +740,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
|
</ActiveTabProvider>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||||
const splitPanelContext = useSplitPanelContext();
|
const splitPanelContext = useSplitPanelContext();
|
||||||
|
|
||||||
|
// 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트)
|
||||||
|
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||||
|
const prevSelectedLeftDataRef = useRef<string>("");
|
||||||
|
|
||||||
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
||||||
const { userId, userName, companyCode } = useAuth();
|
const { userId, userName, companyCode } = useAuth();
|
||||||
|
|
||||||
|
|
@ -72,7 +76,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
|
|
||||||
// 필드 값 변경 핸들러
|
// 필드 값 변경 핸들러
|
||||||
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
||||||
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
|
|
@ -84,18 +87,14 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
loadScreenData();
|
loadScreenData();
|
||||||
}, [embedding.childScreenId]);
|
}, [embedding.childScreenId]);
|
||||||
|
|
||||||
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
|
// initialFormData 변경 시 formData 업데이트 (수정 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
||||||
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
|
|
||||||
setFormData(initialFormData);
|
setFormData(initialFormData);
|
||||||
}
|
}
|
||||||
}, [initialFormData]);
|
}, [initialFormData]);
|
||||||
|
|
||||||
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
|
||||||
// 🆕 좌측 선택 데이터 (분할 패널 컨텍스트에서 직접 참조)
|
|
||||||
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
|
||||||
|
|
||||||
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
|
// 🆕 좌측 선택 데이터가 변경되면 우측 formData를 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 우측 화면인 경우에만 적용
|
// 우측 화면인 경우에만 적용
|
||||||
|
|
@ -108,6 +107,13 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지)
|
||||||
|
const currentDataStr = JSON.stringify(selectedLeftData || {});
|
||||||
|
if (prevSelectedLeftDataRef.current === currentDataStr) {
|
||||||
|
return; // 실제 값이 같으면 스킵
|
||||||
|
}
|
||||||
|
prevSelectedLeftDataRef.current = currentDataStr;
|
||||||
|
|
||||||
// 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집
|
// 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집
|
||||||
const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string);
|
const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string);
|
||||||
|
|
||||||
|
|
@ -127,12 +133,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔗 [EmbeddedScreen] 우측 폼 데이터 교체:", {
|
|
||||||
allColumnNames,
|
|
||||||
selectedLeftDataKeys: selectedLeftData ? Object.keys(selectedLeftData) : [],
|
|
||||||
initializedFormDataKeys: Object.keys(initializedFormData),
|
|
||||||
});
|
|
||||||
|
|
||||||
setFormData(initializedFormData);
|
setFormData(initializedFormData);
|
||||||
setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링
|
setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링
|
||||||
}, [position, splitPanelContext, selectedLeftData, layout]);
|
}, [position, splitPanelContext, selectedLeftData, layout]);
|
||||||
|
|
@ -152,13 +152,6 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
||||||
|
|
||||||
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
|
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
|
||||||
const screenData = await screenApi.getScreen(embedding.childScreenId);
|
const screenData = await screenApi.getScreen(embedding.childScreenId);
|
||||||
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
|
|
||||||
screenId: embedding.childScreenId,
|
|
||||||
hasData: !!screenData,
|
|
||||||
tableName: screenData?.tableName,
|
|
||||||
screenName: screenData?.name || screenData?.screenName,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
if (screenData) {
|
if (screenData) {
|
||||||
setScreenInfo(screenData);
|
setScreenInfo(screenData);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -27,29 +27,12 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp
|
||||||
// config에서 splitRatio 추출 (기본값 50)
|
// config에서 splitRatio 추출 (기본값 50)
|
||||||
const configSplitRatio = config?.splitRatio ?? 50;
|
const configSplitRatio = config?.splitRatio ?? 50;
|
||||||
|
|
||||||
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
|
|
||||||
screenId,
|
|
||||||
config,
|
|
||||||
leftScreenId: config?.leftScreenId,
|
|
||||||
rightScreenId: config?.rightScreenId,
|
|
||||||
configSplitRatio,
|
|
||||||
parentDataMapping: config?.parentDataMapping,
|
|
||||||
configKeys: config ? Object.keys(config) : [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 initialFormData 별도 로그 (명확한 확인)
|
|
||||||
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
|
|
||||||
hasInitialFormData: !!initialFormData,
|
|
||||||
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
|
|
||||||
initialFormData: initialFormData,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 드래그로 조절 가능한 splitRatio 상태
|
// 드래그로 조절 가능한 splitRatio 상태
|
||||||
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
||||||
|
|
||||||
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
|
|
||||||
setSplitRatio(configSplitRatio);
|
setSplitRatio(configSplitRatio);
|
||||||
}, [configSplitRatio]);
|
}, [configSplitRatio]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
|
@ -184,6 +185,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
||||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||||
|
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
|
||||||
|
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
|
||||||
|
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
@ -308,6 +311,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
};
|
};
|
||||||
}, [currentPage, searchValues, loadData, component.tableName]);
|
}, [currentPage, searchValues, loadData, component.tableName]);
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
||||||
|
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
|
||||||
|
filterColumn: string;
|
||||||
|
filterValue: any;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
||||||
|
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
||||||
|
|
||||||
|
// 이 테이블이 대상 테이블인지 확인
|
||||||
|
if (targetTable === component.tableName) {
|
||||||
|
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
|
||||||
|
tableName: component.tableName,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
});
|
||||||
|
setRelatedButtonFilter({ filterColumn, filterValue });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||||
|
};
|
||||||
|
}, [component.tableName]);
|
||||||
|
|
||||||
|
// relatedButtonFilter 변경 시 데이터 다시 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (relatedButtonFilter) {
|
||||||
|
loadData(1, searchValues);
|
||||||
|
}
|
||||||
|
}, [relatedButtonFilter]);
|
||||||
|
|
||||||
// 카테고리 타입 컬럼의 값 매핑 로드
|
// 카테고리 타입 컬럼의 값 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
|
|
@ -702,10 +740,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 필터 적용
|
||||||
|
let relatedButtonFilterValues: Record<string, any> = {};
|
||||||
|
if (relatedButtonFilter) {
|
||||||
|
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
||||||
|
}
|
||||||
|
|
||||||
// 검색 파라미터와 연결 필터 병합
|
// 검색 파라미터와 연결 필터 병합
|
||||||
const mergedSearchParams = {
|
const mergedSearchParams = {
|
||||||
...searchParams,
|
...searchParams,
|
||||||
...linkedFilterValues,
|
...linkedFilterValues,
|
||||||
|
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🔍 데이터 조회 시작:", {
|
console.log("🔍 데이터 조회 시작:", {
|
||||||
|
|
@ -713,6 +758,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
linkedFilterValues,
|
linkedFilterValues,
|
||||||
|
relatedButtonFilterValues,
|
||||||
mergedSearchParams,
|
mergedSearchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -819,7 +865,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
|
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 사용자 정보 로드
|
// 현재 사용자 정보 로드
|
||||||
|
|
@ -947,7 +993,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}
|
}
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
|
||||||
|
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||||
|
if (isSelected && data[rowIndex]) {
|
||||||
|
splitPanelContext.setSelectedLeftData(data[rowIndex]);
|
||||||
|
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
|
||||||
|
} else if (!isSelected) {
|
||||||
|
splitPanelContext.setSelectedLeftData(null);
|
||||||
|
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, splitPanelContext, splitPanelPosition]);
|
||||||
|
|
||||||
// 전체 선택/해제 핸들러
|
// 전체 선택/해제 핸들러
|
||||||
const handleSelectAll = useCallback(
|
const handleSelectAll = useCallback(
|
||||||
|
|
@ -2144,12 +2201,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const mapping = categoryMappings[column.columnName];
|
const mapping = categoryMappings[column.columnName];
|
||||||
const categoryData = mapping?.[String(value)];
|
const categoryData = mapping?.[String(value)];
|
||||||
|
|
||||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
|
||||||
const displayLabel = categoryData?.label || String(value);
|
const displayLabel = categoryData?.label || String(value);
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
if (displayColor === "none") {
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||||
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
* 🔗 연쇄 드롭다운 래퍼 컴포넌트
|
||||||
|
|
@ -2103,6 +2104,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitPanelProvider>
|
<SplitPanelProvider>
|
||||||
|
<ActiveTabProvider>
|
||||||
<TableOptionsProvider>
|
<TableOptionsProvider>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 테이블 옵션 툴바 */}
|
{/* 테이블 옵션 툴바 */}
|
||||||
|
|
@ -2211,6 +2213,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
|
</ActiveTabProvider>
|
||||||
</SplitPanelProvider>
|
</SplitPanelProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps {
|
||||||
id: number;
|
id: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
};
|
};
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
|
||||||
onSave?: () => Promise<void>;
|
onSave?: () => Promise<void>;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onFlowRefresh?: () => void;
|
onFlowRefresh?: () => void;
|
||||||
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
// 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
// 🆕 그룹 데이터 (EditModal에서 전달)
|
// 그룹 데이터 (EditModal에서 전달)
|
||||||
groupedData?: Record<string, any>[];
|
groupedData?: Record<string, any>[];
|
||||||
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
|
// 비활성화할 필드 목록 (EditModal에서 전달)
|
||||||
disabledFields?: string[];
|
disabledFields?: string[];
|
||||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
// EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||||
isInModal?: boolean;
|
isInModal?: boolean;
|
||||||
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||||
originalData?: Record<string, any> | null;
|
originalData?: Record<string, any> | null;
|
||||||
|
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||||
|
parentTabId?: string; // 부모 탭 ID
|
||||||
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||||
|
|
@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
groupedData,
|
groupedData,
|
||||||
disabledFields = [],
|
disabledFields = [],
|
||||||
isInModal = false,
|
isInModal = false,
|
||||||
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
originalData,
|
||||||
|
parentTabId,
|
||||||
|
parentTabsComponentId,
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { userName: authUserName, user: authUser } = useAuth();
|
const { userName: authUserName, user: authUser } = useAuth();
|
||||||
|
|
@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
component={comp}
|
component={comp}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
originalData={originalData || undefined}
|
||||||
onFormDataChange={handleFormDataChange}
|
onFormDataChange={handleFormDataChange}
|
||||||
screenId={screenInfo?.id}
|
screenId={screenInfo?.id}
|
||||||
tableName={screenInfo?.tableName}
|
tableName={screenInfo?.tableName}
|
||||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
menuObjid={menuObjid}
|
||||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
userId={user?.userId}
|
||||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
userName={user?.userName}
|
||||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
companyCode={user?.companyCode}
|
||||||
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달
|
onSave={onSave}
|
||||||
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
|
allComponents={allComponents}
|
||||||
selectedRowsData={selectedRowsData}
|
selectedRowsData={selectedRowsData}
|
||||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
console.log("테이블에서 선택된 행 데이터:", selectedData);
|
||||||
setSelectedRowsData(selectedData);
|
setSelectedRowsData(selectedData);
|
||||||
}}
|
}}
|
||||||
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
|
|
||||||
groupedData={groupedData}
|
groupedData={groupedData}
|
||||||
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
|
|
||||||
disabledFields={disabledFields}
|
disabledFields={disabledFields}
|
||||||
flowSelectedData={flowSelectedData}
|
flowSelectedData={flowSelectedData}
|
||||||
flowSelectedStepId={flowSelectedStepId}
|
flowSelectedStepId={flowSelectedStepId}
|
||||||
onFlowSelectedDataChange={(selectedData, stepId) => {
|
onFlowSelectedDataChange={(selectedData, stepId) => {
|
||||||
console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId });
|
console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
|
||||||
setFlowSelectedData(selectedData);
|
setFlowSelectedData(selectedData);
|
||||||
setFlowSelectedStepId(stepId);
|
setFlowSelectedStepId(stepId);
|
||||||
}}
|
}}
|
||||||
onRefresh={
|
onRefresh={
|
||||||
onRefresh ||
|
onRefresh ||
|
||||||
(() => {
|
(() => {
|
||||||
// 부모로부터 전달받은 onRefresh 또는 기본 동작
|
console.log("InteractiveScreenViewerDynamic onRefresh 호출");
|
||||||
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onFlowRefresh={onFlowRefresh}
|
onFlowRefresh={onFlowRefresh}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
// buttonActions.ts가 이미 처리함
|
// buttonActions.ts가 이미 처리함
|
||||||
}}
|
}}
|
||||||
|
// 탭 관련 정보 전달
|
||||||
|
parentTabId={parentTabId}
|
||||||
|
parentTabsComponentId={parentTabsComponentId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -584,6 +589,219 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 즉시 저장(quickInsert) 액션 핸들러
|
||||||
|
const handleQuickInsertAction = async () => {
|
||||||
|
// componentConfig에서 quickInsertConfig 가져오기
|
||||||
|
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
|
||||||
|
|
||||||
|
if (!quickInsertConfig?.targetTable) {
|
||||||
|
toast.error("대상 테이블이 설정되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용)
|
||||||
|
let targetTableColumns: string[] = [];
|
||||||
|
try {
|
||||||
|
const { default: apiClient } = await import("@/lib/api/client");
|
||||||
|
const columnsResponse = await apiClient.get(
|
||||||
|
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
|
||||||
|
);
|
||||||
|
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||||
|
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
||||||
|
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
|
||||||
|
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("대상 테이블 컬럼 조회 실패:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 컬럼 매핑에서 값 수집
|
||||||
|
const insertData: Record<string, any> = {};
|
||||||
|
const columnMappings = quickInsertConfig.columnMappings || [];
|
||||||
|
|
||||||
|
for (const mapping of columnMappings) {
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
switch (mapping.sourceType) {
|
||||||
|
case "component":
|
||||||
|
// 같은 화면의 컴포넌트에서 값 가져오기
|
||||||
|
// 방법1: sourceColumnName 사용
|
||||||
|
if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) {
|
||||||
|
value = formData[mapping.sourceColumnName];
|
||||||
|
console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
|
||||||
|
}
|
||||||
|
// 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용
|
||||||
|
else if (mapping.sourceComponentId) {
|
||||||
|
const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||||||
|
if (sourceComp) {
|
||||||
|
const fieldName = (sourceComp as any).columnName || sourceComp.id;
|
||||||
|
value = formData[fieldName];
|
||||||
|
console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "leftPanel":
|
||||||
|
// 분할 패널 좌측 선택 데이터에서 값 가져오기
|
||||||
|
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
|
||||||
|
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fixed":
|
||||||
|
value = mapping.fixedValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "currentUser":
|
||||||
|
if (mapping.userField) {
|
||||||
|
switch (mapping.userField) {
|
||||||
|
case "userId":
|
||||||
|
value = user?.userId;
|
||||||
|
break;
|
||||||
|
case "userName":
|
||||||
|
value = userName;
|
||||||
|
break;
|
||||||
|
case "companyCode":
|
||||||
|
value = user?.companyCode;
|
||||||
|
break;
|
||||||
|
case "deptCode":
|
||||||
|
value = authUser?.deptCode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
insertData[mapping.targetColumn] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우)
|
||||||
|
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
|
||||||
|
const leftData = splitPanelContext.selectedLeftData;
|
||||||
|
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(leftData)) {
|
||||||
|
// 이미 매핑된 컬럼은 스킵
|
||||||
|
if (insertData[key] !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
||||||
|
if (!targetTableColumns.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시스템 컬럼 제외
|
||||||
|
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
|
||||||
|
if (systemColumns.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
||||||
|
if (key.endsWith('_label') || key.endsWith('_name')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값이 있으면 자동 추가
|
||||||
|
if (val !== undefined && val !== null && val !== '') {
|
||||||
|
insertData[key] = val;
|
||||||
|
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🚀 quickInsert 최종 데이터:", insertData);
|
||||||
|
|
||||||
|
// 4. 필수값 검증
|
||||||
|
if (Object.keys(insertData).length === 0) {
|
||||||
|
toast.error("저장할 데이터가 없습니다. 값을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 중복 체크 (설정된 경우)
|
||||||
|
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
||||||
|
try {
|
||||||
|
const { default: apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
// 중복 체크를 위한 검색 조건 구성
|
||||||
|
const searchConditions: Record<string, any> = {};
|
||||||
|
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
||||||
|
if (insertData[col] !== undefined) {
|
||||||
|
searchConditions[col] = { value: insertData[col], operator: "equals" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📍 중복 체크 조건:", searchConditions);
|
||||||
|
|
||||||
|
// 기존 데이터 조회
|
||||||
|
const checkResponse = await apiClient.post(
|
||||||
|
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
search: searchConditions,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📍 중복 체크 응답:", checkResponse.data);
|
||||||
|
|
||||||
|
// data 배열이 있고 길이가 0보다 크면 중복
|
||||||
|
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
|
||||||
|
if (Array.isArray(existingData) && existingData.length > 0) {
|
||||||
|
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("중복 체크 오류:", error);
|
||||||
|
// 중복 체크 실패 시 계속 진행
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. API 호출
|
||||||
|
try {
|
||||||
|
const { default: apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
||||||
|
insertData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
// 7. 성공 후 동작
|
||||||
|
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
|
||||||
|
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 새로고침 (테이블리스트, 카드 디스플레이)
|
||||||
|
if (quickInsertConfig.afterInsert?.refreshData !== false) {
|
||||||
|
console.log("📍 데이터 새로고침 이벤트 발송");
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 지정된 컴포넌트 초기화
|
||||||
|
if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) {
|
||||||
|
for (const componentId of quickInsertConfig.afterInsert.clearComponents) {
|
||||||
|
const targetComp = allComponents.find((c: any) => c.id === componentId);
|
||||||
|
if (targetComp) {
|
||||||
|
const fieldName = (targetComp as any).columnName || targetComp.id;
|
||||||
|
onFormDataChange?.(fieldName, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(response.data?.message || "저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("quickInsert 오류:", error);
|
||||||
|
toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
try {
|
try {
|
||||||
const actionType = config?.actionType || "save";
|
const actionType = config?.actionType || "save";
|
||||||
|
|
@ -604,6 +822,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
case "custom":
|
case "custom":
|
||||||
await handleCustomAction();
|
await handleCustomAction();
|
||||||
break;
|
break;
|
||||||
|
case "quickInsert":
|
||||||
|
await handleQuickInsertAction();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// console.log("🔘 기본 버튼 클릭");
|
// console.log("🔘 기본 버튼 클릭");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,46 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
// 필수 항목 검증
|
||||||
|
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
|
||||||
|
const missingFields: string[] = [];
|
||||||
|
|
||||||
|
components.forEach((component) => {
|
||||||
|
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
|
||||||
|
const isRequired =
|
||||||
|
component.required === true ||
|
||||||
|
component.style?.required === true ||
|
||||||
|
component.componentConfig?.required === true;
|
||||||
|
|
||||||
|
const columnName = component.columnName || component.style?.columnName;
|
||||||
|
const label = component.label || component.style?.label || columnName;
|
||||||
|
|
||||||
|
console.log("🔍 필수 항목 검증:", {
|
||||||
|
componentId: component.id,
|
||||||
|
columnName,
|
||||||
|
label,
|
||||||
|
isRequired,
|
||||||
|
"component.required": component.required,
|
||||||
|
"style.required": component.style?.required,
|
||||||
|
"componentConfig.required": component.componentConfig?.required,
|
||||||
|
value: formData[columnName || ""],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isRequired && columnName) {
|
||||||
|
const value = formData[columnName];
|
||||||
|
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
|
||||||
|
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
|
||||||
|
missingFields.push(label || columnName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: missingFields.length === 0,
|
||||||
|
missingFields,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 저장 핸들러
|
// 저장 핸들러
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!screenData || !screenId) return;
|
if (!screenData || !screenId) return;
|
||||||
|
|
@ -111,6 +151,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 필수 항목 검증
|
||||||
|
const validation = validateRequiredFields();
|
||||||
|
if (!validation.isValid) {
|
||||||
|
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
|
||||||
codeCategory: col.codeCategory || col.code_category,
|
codeCategory: col.codeCategory || col.code_category,
|
||||||
codeValue: col.codeValue || col.code_value,
|
codeValue: col.codeValue || col.code_value,
|
||||||
|
// 엔티티 타입용 참조 테이블 정보
|
||||||
|
referenceTable: col.referenceTable || col.reference_table,
|
||||||
|
referenceColumn: col.referenceColumn || col.reference_column,
|
||||||
|
displayColumn: col.displayColumn || col.display_column,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
||||||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||||
|
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
|
||||||
|
|
||||||
// 🆕 제목 블록 타입
|
// 🆕 제목 블록 타입
|
||||||
interface TitleBlock {
|
interface TitleBlock {
|
||||||
|
|
@ -642,9 +643,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="edit">편집</SelectItem>
|
<SelectItem value="edit">편집</SelectItem>
|
||||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||||
<SelectItem value="transferData">📦 데이터 전달</SelectItem>
|
<SelectItem value="transferData">데이터 전달</SelectItem>
|
||||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기 🆕</SelectItem>
|
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기</SelectItem>
|
||||||
|
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
|
||||||
<SelectItem value="modal">모달 열기</SelectItem>
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
|
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||||
|
|
@ -3068,6 +3071,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
|
||||||
|
{component.componentConfig?.action?.type === "quickInsert" && (
|
||||||
|
<QuickInsertConfigSection
|
||||||
|
component={component}
|
||||||
|
onUpdateProperty={onUpdateProperty}
|
||||||
|
allComponents={allComponents}
|
||||||
|
currentTableName={currentTableName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 제어 기능 섹션 */}
|
{/* 제어 기능 섹션 */}
|
||||||
<div className="mt-8 border-t border-border pt-6">
|
<div className="mt-8 border-t border-border pt-6">
|
||||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Database, Search, Info } from "lucide-react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Database, Search, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
|
||||||
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
|
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
interface EntityField {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type: string;
|
|
||||||
visible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
component,
|
component,
|
||||||
|
|
@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
const widget = component as WidgetComponent;
|
const widget = component as WidgetComponent;
|
||||||
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
|
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||||
|
|
||||||
// 로컬 상태
|
// 테이블 타입 관리에서 설정된 참조 테이블 정보
|
||||||
|
const [referenceInfo, setReferenceInfo] = useState<{
|
||||||
|
referenceTable: string;
|
||||||
|
referenceColumn: string;
|
||||||
|
displayColumn: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}>({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로컬 상태 (UI 관련 설정만)
|
||||||
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
|
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
|
||||||
entityType: config.entityType || "",
|
entityType: config.entityType || "",
|
||||||
displayFields: config.displayFields || [],
|
displayFields: config.displayFields || [],
|
||||||
searchFields: config.searchFields || [],
|
searchFields: config.searchFields || [],
|
||||||
valueField: config.valueField || "id",
|
valueField: config.valueField || "",
|
||||||
labelField: config.labelField || "name",
|
labelField: config.labelField || "",
|
||||||
multiple: config.multiple || false,
|
multiple: config.multiple || false,
|
||||||
searchable: config.searchable !== false, // 기본값 true
|
searchable: config.searchable !== false,
|
||||||
placeholder: config.placeholder || "엔티티를 선택하세요",
|
placeholder: config.placeholder || "항목을 선택하세요",
|
||||||
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
|
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
|
||||||
pageSize: config.pageSize || 20,
|
pageSize: config.pageSize || 20,
|
||||||
minSearchLength: config.minSearchLength || 1,
|
minSearchLength: config.minSearchLength || 1,
|
||||||
|
|
@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
filters: config.filters || {},
|
filters: config.filters || {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 새 필드 추가용 상태
|
// 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
|
||||||
const [newFieldName, setNewFieldName] = useState("");
|
useEffect(() => {
|
||||||
const [newFieldLabel, setNewFieldLabel] = useState("");
|
const loadReferenceInfo = async () => {
|
||||||
const [newFieldType, setNewFieldType] = useState("string");
|
// 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
|
||||||
|
const tableName = widget.tableName;
|
||||||
|
const columnName = widget.columnName;
|
||||||
|
|
||||||
|
if (!tableName || !columnName) {
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: false,
|
||||||
|
error: "테이블 또는 컬럼 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테이블 타입 관리에서 컬럼 정보 조회
|
||||||
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
|
const columnInfo = columns.find((col: any) =>
|
||||||
|
(col.columnName || col.column_name) === columnName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnInfo) {
|
||||||
|
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
|
||||||
|
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
|
||||||
|
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
|
||||||
|
|
||||||
|
// detailSettings에서도 정보 확인 (JSON 파싱)
|
||||||
|
let detailSettings: any = {};
|
||||||
|
if (columnInfo.detailSettings) {
|
||||||
|
try {
|
||||||
|
if (typeof columnInfo.detailSettings === 'string') {
|
||||||
|
detailSettings = JSON.parse(columnInfo.detailSettings);
|
||||||
|
} else {
|
||||||
|
detailSettings = columnInfo.detailSettings;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON 파싱 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalRefTable = refTable || detailSettings.referenceTable || "";
|
||||||
|
const finalRefColumn = refColumn || detailSettings.referenceColumn || "";
|
||||||
|
const finalDispColumn = dispColumn || detailSettings.displayColumn || "";
|
||||||
|
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: finalRefTable,
|
||||||
|
referenceColumn: finalRefColumn,
|
||||||
|
displayColumn: finalDispColumn,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// webTypeConfig에 참조 테이블 정보 자동 설정
|
||||||
|
if (finalRefTable) {
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
valueField: finalRefColumn || "id",
|
||||||
|
labelField: finalDispColumn || "name",
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onUpdateProperty("webTypeConfig", newConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: false,
|
||||||
|
error: "컬럼 정보를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("참조 테이블 정보 로드 실패:", error);
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: false,
|
||||||
|
error: "참조 테이블 정보 로드 실패",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadReferenceInfo();
|
||||||
|
}, [widget.tableName, widget.columnName]);
|
||||||
|
|
||||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
entityType: currentConfig.entityType || "",
|
entityType: currentConfig.entityType || "",
|
||||||
displayFields: currentConfig.displayFields || [],
|
displayFields: currentConfig.displayFields || [],
|
||||||
searchFields: currentConfig.searchFields || [],
|
searchFields: currentConfig.searchFields || [],
|
||||||
valueField: currentConfig.valueField || "id",
|
valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
|
||||||
labelField: currentConfig.labelField || "name",
|
labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
|
||||||
multiple: currentConfig.multiple || false,
|
multiple: currentConfig.multiple || false,
|
||||||
searchable: currentConfig.searchable !== false,
|
searchable: currentConfig.searchable !== false,
|
||||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
placeholder: currentConfig.placeholder || "항목을 선택하세요",
|
||||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||||
pageSize: currentConfig.pageSize || 20,
|
pageSize: currentConfig.pageSize || 20,
|
||||||
minSearchLength: currentConfig.minSearchLength || 1,
|
minSearchLength: currentConfig.minSearchLength || 1,
|
||||||
|
|
@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
apiEndpoint: currentConfig.apiEndpoint || "",
|
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||||
filters: currentConfig.filters || {},
|
filters: currentConfig.filters || {},
|
||||||
});
|
});
|
||||||
}, [widget.webTypeConfig]);
|
}, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
|
||||||
|
|
||||||
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
|
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
|
||||||
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
|
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
|
||||||
|
|
@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
onUpdateProperty("webTypeConfig", localConfig);
|
onUpdateProperty("webTypeConfig", localConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 추가
|
|
||||||
const addDisplayField = () => {
|
|
||||||
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
|
|
||||||
|
|
||||||
const newField: EntityField = {
|
|
||||||
name: newFieldName.trim(),
|
|
||||||
label: newFieldLabel.trim(),
|
|
||||||
type: newFieldType,
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newFields = [...localConfig.displayFields, newField];
|
|
||||||
updateConfig("displayFields", newFields);
|
|
||||||
setNewFieldName("");
|
|
||||||
setNewFieldLabel("");
|
|
||||||
setNewFieldType("string");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 제거
|
|
||||||
const removeDisplayField = (index: number) => {
|
|
||||||
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
|
|
||||||
updateConfig("displayFields", newFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
|
|
||||||
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
|
|
||||||
const newFields = [...localConfig.displayFields];
|
|
||||||
newFields[index] = { ...newFields[index], [field]: value };
|
|
||||||
setLocalConfig({ ...localConfig, displayFields: newFields });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
|
|
||||||
const handleFieldBlur = () => {
|
|
||||||
onUpdateProperty("webTypeConfig", localConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 검색 필드 토글
|
|
||||||
const toggleSearchField = (fieldName: string) => {
|
|
||||||
const currentSearchFields = localConfig.searchFields || [];
|
|
||||||
const newSearchFields = currentSearchFields.includes(fieldName)
|
|
||||||
? currentSearchFields.filter((f) => f !== fieldName)
|
|
||||||
: [...currentSearchFields, fieldName];
|
|
||||||
updateConfig("searchFields", newSearchFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 기본 엔티티 타입들
|
|
||||||
const commonEntityTypes = [
|
|
||||||
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
|
|
||||||
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
|
|
||||||
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
|
|
||||||
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
|
|
||||||
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 기본 엔티티 타입 적용
|
|
||||||
const applyEntityType = (entityType: string) => {
|
|
||||||
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
|
|
||||||
if (!entityConfig) return;
|
|
||||||
|
|
||||||
updateConfig("entityType", entityType);
|
|
||||||
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
|
|
||||||
|
|
||||||
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
|
|
||||||
name: field,
|
|
||||||
label: field.charAt(0).toUpperCase() + field.slice(1),
|
|
||||||
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
|
|
||||||
visible: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
updateConfig("displayFields", defaultFields);
|
|
||||||
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필드 타입 옵션
|
|
||||||
const fieldTypes = [
|
|
||||||
{ value: "string", label: "문자열" },
|
|
||||||
{ value: "number", label: "숫자" },
|
|
||||||
{ value: "date", label: "날짜" },
|
|
||||||
{ value: "boolean", label: "불린" },
|
|
||||||
{ value: "email", label: "이메일" },
|
|
||||||
{ value: "url", label: "URL" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -182,214 +191,97 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
엔티티 설정
|
엔티티 설정
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs">데이터베이스 엔티티 선택 필드의 설정을 관리합니다.</CardDescription>
|
<CardDescription className="text-xs">
|
||||||
|
데이터베이스 엔티티 선택 필드의 설정을 관리합니다.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* 기본 설정 */}
|
{/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">기본 설정</h4>
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
참조 테이블 정보
|
||||||
|
<span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-[10px]">
|
||||||
|
테이블 타입 관리에서 설정
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{referenceInfo.isLoading ? (
|
||||||
<Label htmlFor="entityType" className="text-xs">
|
<div className="bg-muted/50 rounded-md border p-3">
|
||||||
엔티티 타입
|
<p className="text-xs text-muted-foreground">참조 테이블 정보 로딩 중...</p>
|
||||||
</Label>
|
</div>
|
||||||
<Input
|
) : referenceInfo.error ? (
|
||||||
id="entityType"
|
<div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
|
||||||
value={localConfig.entityType || ""}
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
|
<Info className="h-3 w-3" />
|
||||||
onBlur={handleInputBlur}
|
{referenceInfo.error}
|
||||||
placeholder="user, product, department..."
|
</p>
|
||||||
className="text-xs"
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
/>
|
테이블 타입 관리에서 이 컬럼의 참조 테이블을 먼저 설정해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !referenceInfo.referenceTable ? (
|
||||||
|
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
|
||||||
|
<p className="text-xs text-amber-700 flex items-center gap-1">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
참조 테이블이 설정되지 않았습니다.
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
테이블 타입 관리에서 이 컬럼의 참조 테이블을 먼저 설정해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-muted/50 rounded-md border p-3 space-y-2">
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">참조 테이블:</span>
|
||||||
|
<div className="font-medium">{referenceInfo.referenceTable}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">참조 컬럼:</span>
|
||||||
|
<div className="font-medium">{referenceInfo.referenceColumn || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">표시 컬럼:</span>
|
||||||
|
<div className="font-medium">{referenceInfo.displayColumn || "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
이 정보는 테이블 타입 관리에서 변경할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* UI 모드 설정 */}
|
||||||
<Label className="text-xs">기본 엔티티 타입</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{commonEntityTypes.map((entity) => (
|
|
||||||
<Button
|
|
||||||
key={entity.value}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => applyEntityType(entity.value)}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{entity.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="apiEndpoint" className="text-xs">
|
|
||||||
API 엔드포인트
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="apiEndpoint"
|
|
||||||
value={localConfig.apiEndpoint || ""}
|
|
||||||
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
placeholder="/api/entities/user"
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필드 매핑 */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">필드 매핑</h4>
|
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{/* UI 모드 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="valueField" className="text-xs">
|
<Label htmlFor="uiMode" className="text-xs">
|
||||||
값 필드
|
UI 모드
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
|
||||||
id="valueField"
|
|
||||||
value={localConfig.valueField || ""}
|
|
||||||
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
placeholder="id"
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="labelField" className="text-xs">
|
|
||||||
라벨 필드
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="labelField"
|
|
||||||
value={localConfig.labelField || ""}
|
|
||||||
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
|
|
||||||
onBlur={handleInputBlur}
|
|
||||||
placeholder="name"
|
|
||||||
className="text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 표시 필드 관리 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium">표시 필드</h4>
|
|
||||||
|
|
||||||
{/* 새 필드 추가 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">필드 추가</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={newFieldName}
|
|
||||||
onChange={(e) => setNewFieldName(e.target.value)}
|
|
||||||
placeholder="필드명"
|
|
||||||
className="flex-1 text-xs"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={newFieldLabel}
|
|
||||||
onChange={(e) => setNewFieldLabel(e.target.value)}
|
|
||||||
placeholder="라벨"
|
|
||||||
className="flex-1 text-xs"
|
|
||||||
/>
|
|
||||||
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
|
||||||
<SelectTrigger className="w-24 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{fieldTypes.map((type) => (
|
|
||||||
<SelectItem key={type.value} value={type.value}>
|
|
||||||
{type.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={addDisplayField}
|
|
||||||
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 현재 필드 목록 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">표시 필드 ({localConfig.displayFields.length}개)</Label>
|
|
||||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
|
||||||
{localConfig.displayFields.map((field, index) => (
|
|
||||||
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
|
|
||||||
<Switch
|
|
||||||
checked={field.visible}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
const newFields = [...localConfig.displayFields];
|
|
||||||
newFields[index] = { ...newFields[index], visible: checked };
|
|
||||||
const newConfig = { ...localConfig, displayFields: newFields };
|
|
||||||
setLocalConfig(newConfig);
|
|
||||||
onUpdateProperty("webTypeConfig", newConfig);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={field.name}
|
|
||||||
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
|
|
||||||
onBlur={handleFieldBlur}
|
|
||||||
placeholder="필드명"
|
|
||||||
className="flex-1 text-xs"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={field.label}
|
|
||||||
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
|
|
||||||
onBlur={handleFieldBlur}
|
|
||||||
placeholder="라벨"
|
|
||||||
className="flex-1 text-xs"
|
|
||||||
/>
|
|
||||||
<Select
|
<Select
|
||||||
value={field.type}
|
value={(localConfig as any).uiMode || "combo"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => updateConfig("uiMode" as any, value)}
|
||||||
const newFields = [...localConfig.displayFields];
|
|
||||||
newFields[index] = { ...newFields[index], type: value };
|
|
||||||
const newConfig = { ...localConfig, displayFields: newFields };
|
|
||||||
setLocalConfig(newConfig);
|
|
||||||
onUpdateProperty("webTypeConfig", newConfig);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-24 text-xs">
|
<SelectTrigger className="text-xs">
|
||||||
<SelectValue />
|
<SelectValue placeholder="모드 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fieldTypes.map((type) => (
|
<SelectItem value="select">드롭다운 (Select)</SelectItem>
|
||||||
<SelectItem key={type.value} value={type.value}>
|
<SelectItem value="modal">모달 팝업 (Modal)</SelectItem>
|
||||||
{type.label}
|
<SelectItem value="combo">입력 + 모달 버튼 (Combo)</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="autocomplete">자동완성 (Autocomplete)</SelectItem>
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<p className="text-[10px] text-muted-foreground">
|
||||||
size="sm"
|
{(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
|
||||||
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
|
{(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
|
||||||
onClick={() => toggleSearchField(field.name)}
|
{((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
|
||||||
className="p-1 text-xs"
|
{(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
|
||||||
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
|
</p>
|
||||||
>
|
|
||||||
<Search className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => removeDisplayField(index)}
|
|
||||||
className="p-1 text-xs"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 설정 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium">검색 설정</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="placeholder" className="text-xs">
|
<Label htmlFor="placeholder" className="text-xs">
|
||||||
|
|
@ -400,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
value={localConfig.placeholder || ""}
|
value={localConfig.placeholder || ""}
|
||||||
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
|
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
placeholder="엔티티를 선택하세요"
|
placeholder="항목을 선택하세요"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -418,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">검색 설정</h4>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -456,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
<Label htmlFor="searchable" className="text-xs">
|
<Label htmlFor="searchable" className="text-xs">
|
||||||
검색 가능
|
검색 가능
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-muted-foreground text-xs">엔티티를 검색할 수 있습니다.</p>
|
<p className="text-muted-foreground text-xs">항목을 검색할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="searchable"
|
id="searchable"
|
||||||
|
|
@ -470,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
<Label htmlFor="multiple" className="text-xs">
|
<Label htmlFor="multiple" className="text-xs">
|
||||||
다중 선택
|
다중 선택
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-muted-foreground text-xs">여러 엔티티를 선택할 수 있습니다.</p>
|
<p className="text-muted-foreground text-xs">여러 항목을 선택할 수 있습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="multiple"
|
id="multiple"
|
||||||
|
|
@ -480,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 설정 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-medium">추가 필터</h4>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="filters" className="text-xs">
|
|
||||||
JSON 필터
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="filters"
|
|
||||||
value={JSON.stringify(localConfig.filters || {}, null, 2)}
|
|
||||||
onChange={(e) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(e.target.value);
|
|
||||||
updateConfig("filters", parsed);
|
|
||||||
} catch {
|
|
||||||
// 유효하지 않은 JSON은 무시
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder='{"status": "active", "department": "IT"}'
|
|
||||||
className="font-mono text-xs"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-xs">API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 상태 설정 */}
|
{/* 상태 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium">상태 설정</h4>
|
<h4 className="text-sm font-medium">상태 설정</h4>
|
||||||
|
|
@ -516,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
<Label htmlFor="required" className="text-xs">
|
<Label htmlFor="required" className="text-xs">
|
||||||
필수 선택
|
필수 선택
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-muted-foreground text-xs">엔티티가 반드시 선택되어야 합니다.</p>
|
<p className="text-muted-foreground text-xs">반드시 항목을 선택해야 합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="required"
|
id="required"
|
||||||
|
|
@ -530,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
<Label htmlFor="readonly" className="text-xs">
|
<Label htmlFor="readonly" className="text-xs">
|
||||||
읽기 전용
|
읽기 전용
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-muted-foreground text-xs">엔티티를 변경할 수 없습니다.</p>
|
<p className="text-muted-foreground text-xs">값을 변경할 수 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="readonly"
|
id="readonly"
|
||||||
|
|
@ -547,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 rounded border bg-white p-2">
|
<div className="flex items-center gap-2 rounded border bg-white p-2">
|
||||||
<Database className="h-4 w-4 text-gray-400" />
|
<Database className="h-4 w-4 text-gray-400" />
|
||||||
<span className="flex-1 text-xs text-muted-foreground">{localConfig.placeholder || "엔티티를 선택하세요"}</span>
|
<span className="flex-1 text-xs text-muted-foreground">
|
||||||
|
{localConfig.placeholder || "항목을 선택하세요"}
|
||||||
|
</span>
|
||||||
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
|
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{localConfig.displayFields.length > 0 && (
|
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
<div className="font-medium">표시 필드:</div>
|
<div>테이블: {referenceInfo.referenceTable || "미설정"}</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div>값 필드: {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
|
||||||
{localConfig.displayFields
|
<div>표시 필드: {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
|
||||||
.filter((f) => f.visible)
|
{localConfig.multiple && <span> / 다중선택</span>}
|
||||||
.map((field, index) => (
|
{localConfig.required && <span> / 필수</span>}
|
||||||
<span key={index} className="rounded bg-gray-100 px-2 py-1">
|
|
||||||
{field.label}
|
|
||||||
{localConfig.searchFields.includes(field.name) && " 🔍"}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
타입: {localConfig.entityType || "미정"}• 값 필드: {localConfig.valueField}• 라벨 필드:{" "}
|
|
||||||
{localConfig.labelField}
|
|
||||||
{localConfig.multiple && " • 다중선택"}
|
|
||||||
{localConfig.required && " • 필수"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -582,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
EntityConfigPanel.displayName = "EntityConfigPanel";
|
EntityConfigPanel.displayName = "EntityConfigPanel";
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,658 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Check, ChevronsUpDown, Plus, X, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { QuickInsertConfig, QuickInsertColumnMapping } from "@/types/screen-management";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface QuickInsertConfigSectionProps {
|
||||||
|
component: ComponentData;
|
||||||
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
|
allComponents?: ComponentData[];
|
||||||
|
currentTableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableOption {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnOption {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuickInsertConfigSection: React.FC<QuickInsertConfigSectionProps> = ({
|
||||||
|
component,
|
||||||
|
onUpdateProperty,
|
||||||
|
allComponents = [],
|
||||||
|
currentTableName,
|
||||||
|
}) => {
|
||||||
|
// 현재 설정 가져오기
|
||||||
|
const config: QuickInsertConfig = component.componentConfig?.action?.quickInsertConfig || {
|
||||||
|
targetTable: "",
|
||||||
|
columnMappings: [],
|
||||||
|
afterInsert: {
|
||||||
|
refreshData: true,
|
||||||
|
clearComponents: [],
|
||||||
|
showSuccessMessage: true,
|
||||||
|
successMessage: "저장되었습니다.",
|
||||||
|
},
|
||||||
|
duplicateCheck: {
|
||||||
|
enabled: false,
|
||||||
|
columns: [],
|
||||||
|
errorMessage: "이미 존재하는 데이터입니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 목록 상태
|
||||||
|
const [tables, setTables] = useState<TableOption[]>([]);
|
||||||
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
const [tablePopoverOpen, setTablePopoverOpen] = useState(false);
|
||||||
|
const [tableSearch, setTableSearch] = useState("");
|
||||||
|
|
||||||
|
// 대상 테이블 컬럼 목록 상태
|
||||||
|
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
|
||||||
|
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 매핑별 Popover 상태
|
||||||
|
const [targetColumnPopoverOpen, setTargetColumnPopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
const [targetColumnSearch, setTargetColumnSearch] = useState<Record<number, string>>({});
|
||||||
|
const [sourceComponentPopoverOpen, setSourceComponentPopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
const [sourceComponentSearch, setSourceComponentSearch] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
setTablesLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/table-management/tables");
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
setTables(
|
||||||
|
response.data.data.map((t: any) => ({
|
||||||
|
name: t.tableName,
|
||||||
|
label: t.displayName || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setTablesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 대상 테이블 선택 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTargetColumns = async () => {
|
||||||
|
if (!config.targetTable) {
|
||||||
|
setTargetColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTargetColumnsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${config.targetTable}/columns`);
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// columns가 배열인지 확인 (data.columns 또는 data 직접)
|
||||||
|
const columns = response.data.data.columns || response.data.data;
|
||||||
|
setTargetColumns(
|
||||||
|
(Array.isArray(columns) ? columns : []).map((col: any) => ({
|
||||||
|
name: col.columnName || col.column_name,
|
||||||
|
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 목록 로드 실패:", error);
|
||||||
|
setTargetColumns([]);
|
||||||
|
} finally {
|
||||||
|
setTargetColumnsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTargetColumns();
|
||||||
|
}, [config.targetTable]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(updates: Partial<QuickInsertConfig>) => {
|
||||||
|
const newConfig = { ...config, ...updates };
|
||||||
|
onUpdateProperty("componentConfig.action.quickInsertConfig", newConfig);
|
||||||
|
},
|
||||||
|
[config, onUpdateProperty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼 매핑 추가
|
||||||
|
const addMapping = () => {
|
||||||
|
const newMapping: QuickInsertColumnMapping = {
|
||||||
|
targetColumn: "",
|
||||||
|
sourceType: "component",
|
||||||
|
sourceComponentId: "",
|
||||||
|
};
|
||||||
|
updateConfig({
|
||||||
|
columnMappings: [...(config.columnMappings || []), newMapping],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 매핑 삭제
|
||||||
|
const removeMapping = (index: number) => {
|
||||||
|
const newMappings = [...(config.columnMappings || [])];
|
||||||
|
newMappings.splice(index, 1);
|
||||||
|
updateConfig({ columnMappings: newMappings });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 매핑 업데이트
|
||||||
|
const updateMapping = (index: number, updates: Partial<QuickInsertColumnMapping>) => {
|
||||||
|
const newMappings = [...(config.columnMappings || [])];
|
||||||
|
newMappings[index] = { ...newMappings[index], ...updates };
|
||||||
|
updateConfig({ columnMappings: newMappings });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터링된 테이블 목록
|
||||||
|
const filteredTables = tables.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(tableSearch.toLowerCase()) ||
|
||||||
|
t.label.toLowerCase().includes(tableSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컴포넌트 목록 (entity 타입 우선)
|
||||||
|
const availableComponents = allComponents.filter((comp: any) => {
|
||||||
|
// entity 타입 또는 select 타입 컴포넌트 필터링
|
||||||
|
const widgetType = comp.widgetType || comp.componentType || "";
|
||||||
|
return widgetType === "entity" || widgetType === "select" || widgetType === "text";
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4 dark:bg-green-950/20">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">즉시 저장 설정</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
화면에서 선택한 데이터를 버튼 클릭 시 특정 테이블에 즉시 저장합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 대상 테이블 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>대상 테이블 *</Label>
|
||||||
|
<Popover open={tablePopoverOpen} onOpenChange={setTablePopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tablePopoverOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={tablesLoading}
|
||||||
|
>
|
||||||
|
{config.targetTable
|
||||||
|
? tables.find((t) => t.name === config.targetTable)?.label || config.targetTable
|
||||||
|
: "테이블을 선택하세요..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="테이블 검색..."
|
||||||
|
value={tableSearch}
|
||||||
|
onValueChange={setTableSearch}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{filteredTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={`${table.label} ${table.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({ targetTable: table.name, columnMappings: [] });
|
||||||
|
setTablePopoverOpen(false);
|
||||||
|
setTableSearch("");
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", config.targetTable === table.name ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{table.name}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 매핑 */}
|
||||||
|
{config.targetTable && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>컬럼 매핑</Label>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addMapping} className="h-6 text-xs">
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
매핑 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(config.columnMappings || []).length === 0 ? (
|
||||||
|
<div className="rounded border-2 border-dashed py-4 text-center text-xs text-muted-foreground">
|
||||||
|
컬럼 매핑을 추가하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(config.columnMappings || []).map((mapping, index) => (
|
||||||
|
<Card key={index} className="p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">매핑 #{index + 1}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMapping(index)}
|
||||||
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 컬럼 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">대상 컬럼 (저장할 컬럼)</Label>
|
||||||
|
<Popover
|
||||||
|
open={targetColumnPopoverOpen[index] || false}
|
||||||
|
onOpenChange={(open) => setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
disabled={targetColumnsLoading}
|
||||||
|
>
|
||||||
|
{mapping.targetColumn
|
||||||
|
? targetColumns.find((c) => c.name === mapping.targetColumn)?.label || mapping.targetColumn
|
||||||
|
: "컬럼 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="컬럼 검색..."
|
||||||
|
value={targetColumnSearch[index] || ""}
|
||||||
|
onValueChange={(v) => setTargetColumnSearch((prev) => ({ ...prev, [index]: v }))}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{targetColumns
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) ||
|
||||||
|
c.label.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase())
|
||||||
|
)
|
||||||
|
.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateMapping(index, { targetColumn: col.name });
|
||||||
|
setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||||
|
setTargetColumnSearch((prev) => ({ ...prev, [index]: "" }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{col.label}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{col.name}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 소스 타입 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">값 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceType}
|
||||||
|
onValueChange={(value: "component" | "leftPanel" | "fixed" | "currentUser") => {
|
||||||
|
updateMapping(index, {
|
||||||
|
sourceType: value,
|
||||||
|
sourceComponentId: undefined,
|
||||||
|
sourceColumn: undefined,
|
||||||
|
fixedValue: undefined,
|
||||||
|
userField: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="component" className="text-xs">
|
||||||
|
컴포넌트 선택값
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="leftPanel" className="text-xs">
|
||||||
|
좌측 패널 선택 데이터
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="fixed" className="text-xs">
|
||||||
|
고정값
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="currentUser" className="text-xs">
|
||||||
|
현재 사용자 정보
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 타입별 추가 설정 */}
|
||||||
|
{mapping.sourceType === "component" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">소스 컴포넌트</Label>
|
||||||
|
<Popover
|
||||||
|
open={sourceComponentPopoverOpen[index] || false}
|
||||||
|
onOpenChange={(open) => setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
|
||||||
|
{mapping.sourceComponentId
|
||||||
|
? (() => {
|
||||||
|
const comp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||||||
|
return comp?.label || comp?.columnName || mapping.sourceComponentId;
|
||||||
|
})()
|
||||||
|
: "컴포넌트 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="컴포넌트 검색..."
|
||||||
|
value={sourceComponentSearch[index] || ""}
|
||||||
|
onValueChange={(v) => setSourceComponentSearch((prev) => ({ ...prev, [index]: v }))}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">컴포넌트를 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const search = (sourceComponentSearch[index] || "").toLowerCase();
|
||||||
|
const label = (comp.label || "").toLowerCase();
|
||||||
|
const colName = (comp.columnName || "").toLowerCase();
|
||||||
|
return label.includes(search) || colName.includes(search);
|
||||||
|
})
|
||||||
|
.map((comp: any) => (
|
||||||
|
<CommandItem
|
||||||
|
key={comp.id}
|
||||||
|
value={comp.id}
|
||||||
|
onSelect={() => {
|
||||||
|
// sourceComponentId와 함께 sourceColumnName도 저장 (formData 접근용)
|
||||||
|
updateMapping(index, {
|
||||||
|
sourceComponentId: comp.id,
|
||||||
|
sourceColumnName: comp.columnName || undefined,
|
||||||
|
});
|
||||||
|
setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||||
|
setSourceComponentSearch((prev) => ({ ...prev, [index]: "" }));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
mapping.sourceComponentId === comp.id ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{comp.label || comp.columnName || comp.id}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{comp.widgetType || comp.componentType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mapping.sourceType === "leftPanel" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌측 패널 컬럼명</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="예: process_code"
|
||||||
|
value={mapping.sourceColumn || ""}
|
||||||
|
onChange={(e) => updateMapping(index, { sourceColumn: e.target.value })}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
분할 패널 좌측에서 선택된 데이터의 컬럼명을 입력하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mapping.sourceType === "fixed" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">고정값</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="고정값 입력"
|
||||||
|
value={mapping.fixedValue || ""}
|
||||||
|
onChange={(e) => updateMapping(index, { fixedValue: e.target.value })}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mapping.sourceType === "currentUser" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">사용자 정보 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={mapping.userField || ""}
|
||||||
|
onValueChange={(value: "userId" | "userName" | "companyCode" | "deptCode") => {
|
||||||
|
updateMapping(index, { userField: value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="userId" className="text-xs">
|
||||||
|
사용자 ID
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="userName" className="text-xs">
|
||||||
|
사용자 이름
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="companyCode" className="text-xs">
|
||||||
|
회사 코드
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="deptCode" className="text-xs">
|
||||||
|
부서 코드
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 저장 후 동작 설정 */}
|
||||||
|
{config.targetTable && (
|
||||||
|
<div className="space-y-3 rounded border bg-background p-3">
|
||||||
|
<Label className="text-xs font-medium">저장 후 동작</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-normal">데이터 새로고침</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.afterInsert?.refreshData ?? true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateConfig({
|
||||||
|
afterInsert: { ...config.afterInsert, refreshData: checked },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground -mt-2">
|
||||||
|
테이블리스트, 카드 디스플레이 컴포넌트를 새로고침합니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-normal">성공 메시지 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.afterInsert?.showSuccessMessage ?? true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateConfig({
|
||||||
|
afterInsert: { ...config.afterInsert, showSuccessMessage: checked },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.afterInsert?.showSuccessMessage && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">성공 메시지</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="저장되었습니다."
|
||||||
|
value={config.afterInsert?.successMessage || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateConfig({
|
||||||
|
afterInsert: { ...config.afterInsert, successMessage: e.target.value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 중복 체크 설정 */}
|
||||||
|
{config.targetTable && (
|
||||||
|
<div className="space-y-3 rounded border bg-background p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">중복 체크</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.duplicateCheck?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateConfig({
|
||||||
|
duplicateCheck: { ...config.duplicateCheck, enabled: checked },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.duplicateCheck?.enabled && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">중복 체크 컬럼</Label>
|
||||||
|
<div className="mt-1 max-h-40 overflow-y-auto rounded border bg-background p-2">
|
||||||
|
{targetColumns.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground">컬럼을 불러오는 중...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{targetColumns.map((col) => {
|
||||||
|
const isChecked = (config.duplicateCheck?.columns || []).includes(col.name);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.name}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
|
||||||
|
onClick={() => {
|
||||||
|
const currentColumns = config.duplicateCheck?.columns || [];
|
||||||
|
const newColumns = isChecked
|
||||||
|
? currentColumns.filter((c) => c !== col.name)
|
||||||
|
: [...currentColumns, col.name];
|
||||||
|
updateConfig({
|
||||||
|
duplicateCheck: { ...config.duplicateCheck, columns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="h-3 w-3 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-xs whitespace-nowrap">
|
||||||
|
{col.label}{col.label !== col.name && ` (${col.name})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||||
|
선택한 컬럼들의 조합으로 중복 여부를 체크합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">중복 시 에러 메시지</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="이미 존재하는 데이터입니다."
|
||||||
|
value={config.duplicateCheck?.errorMessage || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateConfig({
|
||||||
|
duplicateCheck: { ...config.duplicateCheck, errorMessage: e.target.value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 사용 안내 */}
|
||||||
|
<div className="rounded-md bg-green-100 p-3 dark:bg-green-900/30">
|
||||||
|
<p className="text-xs text-green-900 dark:text-green-100">
|
||||||
|
<strong>사용 방법:</strong>
|
||||||
|
<br />
|
||||||
|
1. 저장할 대상 테이블을 선택합니다
|
||||||
|
<br />
|
||||||
|
2. 컬럼 매핑을 추가하여 각 컬럼에 어떤 값을 저장할지 설정합니다
|
||||||
|
<br />
|
||||||
|
3. 버튼 클릭 시 설정된 값들이 대상 테이블에 즉시 저장됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickInsertConfigSection;
|
||||||
|
|
||||||
|
|
@ -46,6 +46,7 @@ interface DetailSettingsPanelProps {
|
||||||
currentTableName?: string; // 현재 화면의 테이블명
|
currentTableName?: string; // 현재 화면의 테이블명
|
||||||
tables?: TableInfo[]; // 전체 테이블 목록
|
tables?: TableInfo[]; // 전체 테이블 목록
|
||||||
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
|
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
|
||||||
|
components?: ComponentData[]; // 현재 화면의 모든 컴포넌트 (연쇄관계 부모 필드 선택용)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
@ -55,6 +56,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
currentTableName,
|
currentTableName,
|
||||||
tables = [], // 기본값 빈 배열
|
tables = [], // 기본값 빈 배열
|
||||||
currentScreenCompanyCode,
|
currentScreenCompanyCode,
|
||||||
|
components = [], // 기본값 빈 배열
|
||||||
}) => {
|
}) => {
|
||||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
|
|
|
||||||
|
|
@ -943,6 +943,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<Label className="text-xs">읽기전용</Label>
|
<Label className="text-xs">읽기전용</Label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* 숨김 옵션 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
handleUpdate("hidden", checked);
|
||||||
|
handleUpdate("componentConfig.hidden", checked);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">숨김</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,18 @@ import { X, Loader2 } from "lucide-react";
|
||||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||||
|
|
||||||
interface TabsWidgetProps {
|
interface TabsWidgetProps {
|
||||||
component: TabsComponent;
|
component: TabsComponent;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
|
menuObjid?: number; // 부모 화면의 메뉴 OBJID
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
|
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
|
||||||
|
// ActiveTab context 사용
|
||||||
|
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
||||||
const {
|
const {
|
||||||
tabs = [],
|
tabs = [],
|
||||||
defaultTab,
|
defaultTab,
|
||||||
|
|
@ -25,12 +28,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
persistSelection = false,
|
persistSelection = false,
|
||||||
} = component;
|
} = component;
|
||||||
|
|
||||||
console.log("🎨 TabsWidget 렌더링:", {
|
|
||||||
componentId: component.id,
|
|
||||||
tabs,
|
|
||||||
tabsLength: tabs.length,
|
|
||||||
component,
|
|
||||||
});
|
|
||||||
|
|
||||||
const storageKey = `tabs-${component.id}-selected`;
|
const storageKey = `tabs-${component.id}-selected`;
|
||||||
|
|
||||||
|
|
@ -57,25 +54,35 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
|
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
|
||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|
||||||
// 선택된 탭 변경 시 localStorage에 저장
|
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistSelection && typeof window !== "undefined") {
|
if (persistSelection && typeof window !== "undefined") {
|
||||||
localStorage.setItem(storageKey, selectedTab);
|
localStorage.setItem(storageKey, selectedTab);
|
||||||
}
|
}
|
||||||
}, [selectedTab, persistSelection, storageKey]);
|
|
||||||
|
// ActiveTab Context에 현재 활성 탭 정보 등록
|
||||||
|
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
|
||||||
|
if (currentTabInfo) {
|
||||||
|
setActiveTab(component.id, {
|
||||||
|
tabId: selectedTab,
|
||||||
|
tabsComponentId: component.id,
|
||||||
|
screenId: currentTabInfo.screenId,
|
||||||
|
label: currentTabInfo.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
|
||||||
|
|
||||||
|
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
removeTabsComponent(component.id);
|
||||||
|
};
|
||||||
|
}, [component.id, removeTabsComponent]);
|
||||||
|
|
||||||
// 초기 로드 시 선택된 탭의 화면 불러오기
|
// 초기 로드 시 선택된 탭의 화면 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
|
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
|
||||||
console.log("🔄 초기 탭 로드:", {
|
|
||||||
selectedTab,
|
|
||||||
currentTab,
|
|
||||||
hasScreenId: !!currentTab?.screenId,
|
|
||||||
screenId: currentTab?.screenId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
|
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
|
||||||
console.log("📥 초기 화면 로딩 시작:", currentTab.screenId);
|
|
||||||
loadScreenLayout(currentTab.screenId);
|
loadScreenLayout(currentTab.screenId);
|
||||||
}
|
}
|
||||||
}, [selectedTab, visibleTabs]);
|
}, [selectedTab, visibleTabs]);
|
||||||
|
|
@ -83,26 +90,20 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
// 화면 레이아웃 로드
|
// 화면 레이아웃 로드
|
||||||
const loadScreenLayout = async (screenId: number) => {
|
const loadScreenLayout = async (screenId: number) => {
|
||||||
if (screenLayouts[screenId]) {
|
if (screenLayouts[screenId]) {
|
||||||
console.log("✅ 이미 로드된 화면:", screenId);
|
|
||||||
return; // 이미 로드됨
|
return; // 이미 로드됨
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📥 화면 레이아웃 로딩 시작:", screenId);
|
|
||||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
|
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
||||||
console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data });
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
console.log("✅ 화면 레이아웃 로드 완료:", screenId);
|
|
||||||
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
|
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
|
||||||
} else {
|
|
||||||
console.error("❌ 화면 레이아웃 로드 실패 - success false");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error);
|
console.error(`화면 레이아웃 로드 실패 ${screenId}:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
|
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
|
||||||
}
|
}
|
||||||
|
|
@ -110,10 +111,9 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
|
|
||||||
// 탭 변경 핸들러
|
// 탭 변경 핸들러
|
||||||
const handleTabChange = (tabId: string) => {
|
const handleTabChange = (tabId: string) => {
|
||||||
console.log("🔄 탭 변경:", tabId);
|
|
||||||
setSelectedTab(tabId);
|
setSelectedTab(tabId);
|
||||||
|
|
||||||
// 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
// 마운트된 탭 목록에 추가 (한 번 마운트되면 유지)
|
||||||
setMountedTabs(prev => {
|
setMountedTabs(prev => {
|
||||||
if (prev.has(tabId)) return prev;
|
if (prev.has(tabId)) return prev;
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
|
|
@ -123,10 +123,7 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
|
|
||||||
// 해당 탭의 화면 로드
|
// 해당 탭의 화면 로드
|
||||||
const tab = visibleTabs.find((t) => t.id === tabId);
|
const tab = visibleTabs.find((t) => t.id === tabId);
|
||||||
console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId });
|
|
||||||
|
|
||||||
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
|
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
|
||||||
console.log("📥 탭 변경 시 화면 로딩:", tab.screenId);
|
|
||||||
loadScreenLayout(tab.screenId);
|
loadScreenLayout(tab.screenId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -157,7 +154,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
};
|
};
|
||||||
|
|
||||||
if (visibleTabs.length === 0) {
|
if (visibleTabs.length === 0) {
|
||||||
console.log("⚠️ 보이는 탭이 없음");
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||||
|
|
@ -165,13 +161,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🎨 TabsWidget 최종 렌더링:", {
|
|
||||||
visibleTabsCount: visibleTabs.length,
|
|
||||||
selectedTab,
|
|
||||||
screenLayoutsKeys: Object.keys(screenLayouts),
|
|
||||||
loadingScreensKeys: Object.keys(loadingScreens),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col pt-4" style={style}>
|
<div className="flex h-full w-full flex-col pt-4" style={style}>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|
@ -233,14 +222,6 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
const layoutData = screenLayouts[tab.screenId];
|
const layoutData = screenLayouts[tab.screenId];
|
||||||
const { components = [], screenResolution } = layoutData;
|
const { components = [], screenResolution } = layoutData;
|
||||||
|
|
||||||
// 비활성 탭은 로그 생략
|
|
||||||
if (isActive) {
|
|
||||||
console.log("🎯 렌더링할 화면 데이터:", {
|
|
||||||
screenId: tab.screenId,
|
|
||||||
componentsCount: components.length,
|
|
||||||
screenResolution,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const designWidth = screenResolution?.width || 1920;
|
const designWidth = screenResolution?.width || 1920;
|
||||||
const designHeight = screenResolution?.height || 1080;
|
const designHeight = screenResolution?.height || 1080;
|
||||||
|
|
@ -260,16 +241,18 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{components.map((component: any) => (
|
{components.map((comp: any) => (
|
||||||
<InteractiveScreenViewerDynamic
|
<InteractiveScreenViewerDynamic
|
||||||
key={component.id}
|
key={comp.id}
|
||||||
component={component}
|
component={comp}
|
||||||
allComponents={components}
|
allComponents={components}
|
||||||
screenInfo={{
|
screenInfo={{
|
||||||
id: tab.screenId,
|
id: tab.screenId,
|
||||||
tableName: layoutData.tableName,
|
tableName: layoutData.tableName,
|
||||||
}}
|
}}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
|
parentTabId={tab.id}
|
||||||
|
parentTabsComponentId={component.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 항목 제거
|
// 항목 제거
|
||||||
const handleRemoveItem = (index: number) => {
|
const handleRemoveItem = (index: number) => {
|
||||||
if (items.length <= minItems) {
|
// 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용)
|
||||||
return;
|
// minItems 체크 제거 - 모든 항목 삭제 허용
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||||
const removedItem = items[index];
|
const removedItem = items[index];
|
||||||
|
|
@ -419,7 +418,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const valueStr = String(value); // 값을 문자열로 변환
|
const valueStr = String(value); // 값을 문자열로 변환
|
||||||
const categoryData = mapping?.[valueStr];
|
const categoryData = mapping?.[valueStr];
|
||||||
const displayLabel = categoryData?.label || valueStr;
|
const displayLabel = categoryData?.label || valueStr;
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||||
fieldName: field.name,
|
fieldName: field.name,
|
||||||
|
|
@ -430,8 +429,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
displayColor,
|
displayColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 색상이 "none"이면 일반 텍스트로 표시
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
if (displayColor === "none") {
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -518,19 +517,28 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
case "date": {
|
case "date": {
|
||||||
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환
|
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결)
|
||||||
let dateValue = value || "";
|
let dateValue = value || "";
|
||||||
if (dateValue && typeof dateValue === "string") {
|
if (dateValue && typeof dateValue === "string") {
|
||||||
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출
|
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출
|
||||||
if (dateValue.includes("T")) {
|
if (dateValue.includes("T")) {
|
||||||
dateValue = dateValue.split("T")[0];
|
const date = new Date(dateValue);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
dateValue = `${year}-${month}-${day}`;
|
||||||
|
} else {
|
||||||
|
dateValue = "";
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// 유효한 날짜인지 확인
|
// 유효한 날짜인지 확인
|
||||||
const parsedDate = new Date(dateValue);
|
const parsedDate = new Date(dateValue);
|
||||||
if (isNaN(parsedDate.getTime())) {
|
if (isNaN(parsedDate.getTime())) {
|
||||||
dateValue = ""; // 유효하지 않은 날짜면 빈 값
|
dateValue = ""; // 유효하지 않은 날짜면 빈 값
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
|
@ -801,7 +809,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
{!readonly && !disabled && items.length > minItems && (
|
{!readonly && !disabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -871,7 +879,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
{!readonly && !disabled && items.length > minItems && (
|
{!readonly && !disabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 탭 정보
|
||||||
|
*/
|
||||||
|
export interface ActiveTabInfo {
|
||||||
|
tabId: string; // 탭 고유 ID
|
||||||
|
tabsComponentId: string; // 부모 탭 컴포넌트 ID
|
||||||
|
screenId?: number; // 탭에 연결된 화면 ID
|
||||||
|
label?: string; // 탭 라벨
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context 값 타입
|
||||||
|
*/
|
||||||
|
interface ActiveTabContextValue {
|
||||||
|
// 현재 활성 탭 정보 (탭 컴포넌트 ID -> 활성 탭 정보)
|
||||||
|
activeTabs: Map<string, ActiveTabInfo>;
|
||||||
|
|
||||||
|
// 활성 탭 설정
|
||||||
|
setActiveTab: (tabsComponentId: string, tabInfo: ActiveTabInfo) => void;
|
||||||
|
|
||||||
|
// 활성 탭 조회
|
||||||
|
getActiveTab: (tabsComponentId: string) => ActiveTabInfo | undefined;
|
||||||
|
|
||||||
|
// 특정 탭 컴포넌트의 활성 탭 ID 조회
|
||||||
|
getActiveTabId: (tabsComponentId: string) => string | undefined;
|
||||||
|
|
||||||
|
// 전체 활성 탭 ID 목록 (모든 탭 컴포넌트에서)
|
||||||
|
getAllActiveTabIds: () => string[];
|
||||||
|
|
||||||
|
// 탭 컴포넌트 제거 시 정리
|
||||||
|
removeTabsComponent: (tabsComponentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveTabContext = createContext<ActiveTabContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ActiveTabProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [activeTabs, setActiveTabs] = useState<Map<string, ActiveTabInfo>>(new Map());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 탭 설정
|
||||||
|
*/
|
||||||
|
const setActiveTab = useCallback((tabsComponentId: string, tabInfo: ActiveTabInfo) => {
|
||||||
|
setActiveTabs((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(tabsComponentId, tabInfo);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성 탭 조회
|
||||||
|
*/
|
||||||
|
const getActiveTab = useCallback(
|
||||||
|
(tabsComponentId: string) => {
|
||||||
|
return activeTabs.get(tabsComponentId);
|
||||||
|
},
|
||||||
|
[activeTabs]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 탭 컴포넌트의 활성 탭 ID 조회
|
||||||
|
*/
|
||||||
|
const getActiveTabId = useCallback(
|
||||||
|
(tabsComponentId: string) => {
|
||||||
|
return activeTabs.get(tabsComponentId)?.tabId;
|
||||||
|
},
|
||||||
|
[activeTabs]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 활성 탭 ID 목록
|
||||||
|
*/
|
||||||
|
const getAllActiveTabIds = useCallback(() => {
|
||||||
|
return Array.from(activeTabs.values()).map((info) => info.tabId);
|
||||||
|
}, [activeTabs]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 탭 컴포넌트 제거 시 정리
|
||||||
|
*/
|
||||||
|
const removeTabsComponent = useCallback((tabsComponentId: string) => {
|
||||||
|
setActiveTabs((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.delete(tabsComponentId);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActiveTabContext.Provider
|
||||||
|
value={{
|
||||||
|
activeTabs,
|
||||||
|
setActiveTab,
|
||||||
|
getActiveTab,
|
||||||
|
getActiveTabId,
|
||||||
|
getAllActiveTabIds,
|
||||||
|
removeTabsComponent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ActiveTabContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context Hook
|
||||||
|
*/
|
||||||
|
export const useActiveTab = () => {
|
||||||
|
const context = useContext(ActiveTabContext);
|
||||||
|
if (!context) {
|
||||||
|
// Context가 없으면 기본값 반환 (탭이 없는 화면에서 사용 시)
|
||||||
|
return {
|
||||||
|
activeTabs: new Map(),
|
||||||
|
setActiveTab: () => {},
|
||||||
|
getActiveTab: () => undefined,
|
||||||
|
getActiveTabId: () => undefined,
|
||||||
|
getAllActiveTabIds: () => [],
|
||||||
|
removeTabsComponent: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional Context Hook (에러 없이 undefined 반환)
|
||||||
|
*/
|
||||||
|
export const useActiveTabOptional = () => {
|
||||||
|
return useContext(ActiveTabContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -282,10 +282,6 @@ export function SplitPanelProvider({
|
||||||
* 🆕 좌측 선택 데이터 설정
|
* 🆕 좌측 선택 데이터 설정
|
||||||
*/
|
*/
|
||||||
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
|
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
|
||||||
logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, {
|
|
||||||
hasData: !!data,
|
|
||||||
dataKeys: data ? Object.keys(data) : [],
|
|
||||||
});
|
|
||||||
setSelectedLeftData(data);
|
setSelectedLeftData(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -323,11 +319,6 @@ export function SplitPanelProvider({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, {
|
|
||||||
autoMappedKeys: Object.keys(selectedLeftData),
|
|
||||||
explicitMappings: parentDataMapping.length,
|
|
||||||
finalKeys: Object.keys(mappedData),
|
|
||||||
});
|
|
||||||
return mappedData;
|
return mappedData;
|
||||||
}, [selectedLeftData, parentDataMapping]);
|
}, [selectedLeftData, parentDataMapping]);
|
||||||
|
|
||||||
|
|
@ -350,7 +341,6 @@ export function SplitPanelProvider({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues);
|
|
||||||
return filterValues;
|
return filterValues;
|
||||||
}, [selectedLeftData, linkedFilters]);
|
}, [selectedLeftData, linkedFilters]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import React, {
|
||||||
useContext,
|
useContext,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
TableRegistration,
|
TableRegistration,
|
||||||
TableOptionsContextValue,
|
TableOptionsContextValue,
|
||||||
} from "@/types/table-options";
|
} from "@/types/table-options";
|
||||||
|
import { useActiveTab } from "./ActiveTabContext";
|
||||||
|
|
||||||
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
|
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -83,18 +85,41 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
const updatedTable = { ...table, dataCount: count };
|
const updatedTable = { ...table, dataCount: count };
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
newMap.set(tableId, updatedTable);
|
newMap.set(tableId, updatedTable);
|
||||||
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
|
|
||||||
tableId,
|
|
||||||
count,
|
|
||||||
updated: true,
|
|
||||||
});
|
|
||||||
return newMap;
|
return newMap;
|
||||||
}
|
}
|
||||||
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
|
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ActiveTab context 사용 (optional - 에러 방지)
|
||||||
|
const activeTabContext = useActiveTab();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 활성 탭의 테이블만 반환
|
||||||
|
*/
|
||||||
|
const getActiveTabTables = useCallback(() => {
|
||||||
|
const allTables = Array.from(registeredTables.values());
|
||||||
|
const activeTabIds = activeTabContext.getAllActiveTabIds();
|
||||||
|
|
||||||
|
// 활성 탭이 없으면 탭에 속하지 않은 테이블만 반환
|
||||||
|
if (activeTabIds.length === 0) {
|
||||||
|
return allTables.filter(table => !table.parentTabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성 탭에 속한 테이블 + 탭에 속하지 않은 테이블
|
||||||
|
return allTables.filter(table =>
|
||||||
|
!table.parentTabId || activeTabIds.includes(table.parentTabId)
|
||||||
|
);
|
||||||
|
}, [registeredTables, activeTabContext]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 탭의 테이블만 반환
|
||||||
|
*/
|
||||||
|
const getTablesForTab = useCallback((tabId: string) => {
|
||||||
|
const allTables = Array.from(registeredTables.values());
|
||||||
|
return allTables.filter(table => table.parentTabId === tabId);
|
||||||
|
}, [registeredTables]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableOptionsContext.Provider
|
<TableOptionsContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
@ -105,6 +130,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
updateTableDataCount,
|
updateTableDataCount,
|
||||||
selectedTableId,
|
selectedTableId,
|
||||||
setSelectedTableId,
|
setSelectedTableId,
|
||||||
|
getActiveTabTables,
|
||||||
|
getTablesForTab,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -193,3 +193,5 @@ export function applyAutoFillToFormData(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,14 @@ export const dataApi = {
|
||||||
size: number;
|
size: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get(`/data/${tableName}`, { params });
|
// filters를 평탄화하여 쿼리 파라미터로 전달 (백엔드 ...filters 형식에 맞춤)
|
||||||
|
const { filters, ...restParams } = params || {};
|
||||||
|
const flattenedParams = {
|
||||||
|
...restParams,
|
||||||
|
...(filters || {}), // filters 객체를 평탄화
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/data/${tableName}`, { params: flattenedParams });
|
||||||
const raw = response.data || {};
|
const raw = response.data || {};
|
||||||
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -426,12 +426,29 @@ export class DynamicFormApi {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
|
autoFilter?: {
|
||||||
|
enabled: boolean;
|
||||||
|
filterColumn?: string;
|
||||||
|
userField?: string;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
): Promise<ApiResponse<any[]>> {
|
): Promise<ApiResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
|
console.log("📊 테이블 데이터 조회 요청:", { tableName, params });
|
||||||
|
|
||||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {});
|
// autoFilter가 없으면 기본값으로 멀티테넌시 필터 적용
|
||||||
|
// pageSize를 size로 변환 (백엔드 파라미터명 호환)
|
||||||
|
const requestParams = {
|
||||||
|
...params,
|
||||||
|
size: params?.pageSize || params?.size || 100, // 기본값 100
|
||||||
|
autoFilter: params?.autoFilter ?? {
|
||||||
|
enabled: true,
|
||||||
|
filterColumn: "company_code",
|
||||||
|
userField: "companyCode",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, requestParams);
|
||||||
|
|
||||||
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
|
console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data);
|
||||||
console.log("🔍 response.data 상세:", {
|
console.log("🔍 response.data 상세:", {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { apiClient } from "./client";
|
||||||
|
|
||||||
export interface TableColumn {
|
export interface TableColumn {
|
||||||
name: string;
|
name: string;
|
||||||
|
label: string; // 컬럼 라벨 (column_labels 테이블에서 가져옴)
|
||||||
type: string;
|
type: string;
|
||||||
nullable: boolean;
|
nullable: boolean;
|
||||||
default: string | null;
|
default: string | null;
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,9 @@ export interface DynamicComponentRendererProps {
|
||||||
mode?: "view" | "edit";
|
mode?: "view" | "edit";
|
||||||
// 모달 내에서 렌더링 여부
|
// 모달 내에서 렌더링 여부
|
||||||
isInModal?: boolean;
|
isInModal?: boolean;
|
||||||
|
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
|
||||||
|
parentTabId?: string; // 부모 탭 ID
|
||||||
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -226,43 +229,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||||
|
|
||||||
// 🔍 디버깅: screen-split-panel 조회 결과 확인
|
|
||||||
if (componentType === "screen-split-panel") {
|
|
||||||
console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", {
|
|
||||||
componentType,
|
|
||||||
found: !!newComponent,
|
|
||||||
componentId: component.id,
|
|
||||||
componentConfig: component.componentConfig,
|
|
||||||
hasFormData: !!props.formData,
|
|
||||||
formDataKeys: props.formData ? Object.keys(props.formData) : [],
|
|
||||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔍 디버깅: select-basic 조회 결과 확인
|
|
||||||
if (componentType === "select-basic") {
|
|
||||||
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
|
|
||||||
componentType,
|
|
||||||
found: !!newComponent,
|
|
||||||
componentId: component.id,
|
|
||||||
componentConfig: component.componentConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔍 디버깅: text-input 컴포넌트 조회 결과 확인
|
|
||||||
if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") {
|
|
||||||
console.log("🔍 [DynamicComponentRenderer] text-input 조회:", {
|
|
||||||
componentType,
|
|
||||||
componentId: component.id,
|
|
||||||
componentLabel: component.label,
|
|
||||||
componentConfig: component.componentConfig,
|
|
||||||
webTypeConfig: (component as any).webTypeConfig,
|
|
||||||
autoGeneration: (component as any).autoGeneration,
|
|
||||||
found: !!newComponent,
|
|
||||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newComponent) {
|
if (newComponent) {
|
||||||
// 새 컴포넌트 시스템으로 렌더링
|
// 새 컴포넌트 시스템으로 렌더링
|
||||||
try {
|
try {
|
||||||
|
|
@ -324,19 +290,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
currentValue = formData?.[fieldName] || "";
|
currentValue = formData?.[fieldName] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 디버깅: text-input 값 추출 확인
|
|
||||||
if (componentType === "text-input" && formData && Object.keys(formData).length > 0) {
|
|
||||||
console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", {
|
|
||||||
componentId: component.id,
|
|
||||||
componentLabel: component.label,
|
|
||||||
columnName: (component as any).columnName,
|
|
||||||
fieldName,
|
|
||||||
currentValue,
|
|
||||||
hasFormData: !!formData,
|
|
||||||
formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||||
const handleChange = (value: any) => {
|
const handleChange = (value: any) => {
|
||||||
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||||
|
|
@ -369,6 +322,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 숨김 값 추출
|
// 숨김 값 추출
|
||||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
||||||
|
|
||||||
|
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
|
||||||
|
if (hiddenValue && isInteractive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// size.width와 size.height를 style.width와 style.height로 변환
|
// size.width와 size.height를 style.width와 style.height로 변환
|
||||||
const finalStyle: React.CSSProperties = {
|
const finalStyle: React.CSSProperties = {
|
||||||
...component.style,
|
...component.style,
|
||||||
|
|
@ -415,7 +373,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
userId, // 🆕 사용자 ID
|
userId, // 🆕 사용자 ID
|
||||||
userName, // 🆕 사용자 이름
|
userName, // 🆕 사용자 이름
|
||||||
companyCode, // 🆕 회사 코드
|
companyCode, // 🆕 회사 코드
|
||||||
mode,
|
// 🆕 화면 모드 (edit/view)와 컴포넌트 UI 모드 구분
|
||||||
|
screenMode: mode,
|
||||||
|
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
|
||||||
|
mode: component.componentConfig?.mode || mode,
|
||||||
isInModal,
|
isInModal,
|
||||||
readonly: component.readonly,
|
readonly: component.readonly,
|
||||||
// 🆕 disabledFields 체크 또는 기존 readonly
|
// 🆕 disabledFields 체크 또는 기존 readonly
|
||||||
|
|
|
||||||
|
|
@ -388,16 +388,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔍 디버깅: processedConfig.action 확인
|
|
||||||
console.log("[ButtonPrimaryComponent] processedConfig.action 생성 완료", {
|
|
||||||
actionType: processedConfig.action?.type,
|
|
||||||
enableDataflowControl: processedConfig.action?.enableDataflowControl,
|
|
||||||
dataflowTiming: processedConfig.action?.dataflowTiming,
|
|
||||||
dataflowConfig: processedConfig.action?.dataflowConfig,
|
|
||||||
webTypeConfigRaw: component.webTypeConfig,
|
|
||||||
componentText: component.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 스타일 계산
|
// 스타일 계산
|
||||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||||
|
|
@ -839,10 +829,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
groupedData.length > 0
|
groupedData.length > 0
|
||||||
) {
|
) {
|
||||||
effectiveSelectedRowsData = groupedData;
|
effectiveSelectedRowsData = groupedData;
|
||||||
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
|
|
||||||
count: groupedData.length,
|
|
||||||
data: groupedData,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
|
||||||
|
|
@ -858,12 +844,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
|
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
|
||||||
return item.originalData || item;
|
return item.originalData || item;
|
||||||
});
|
});
|
||||||
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
|
|
||||||
tableName: effectiveTableName,
|
|
||||||
count: modalData.length,
|
|
||||||
rawData: modalData,
|
|
||||||
extractedData: effectiveSelectedRowsData,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("modalDataStore 접근 실패:", error);
|
console.warn("modalDataStore 접근 실패:", error);
|
||||||
|
|
@ -928,17 +908,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 디버깅: tableName 확인
|
// 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
|
||||||
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
|
|
||||||
propsTableName: tableName,
|
|
||||||
contextTableName: screenContext?.tableName,
|
|
||||||
effectiveTableName,
|
|
||||||
propsScreenId: screenId,
|
|
||||||
contextScreenId: screenContext?.screenId,
|
|
||||||
effectiveScreenId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
|
|
||||||
// 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴
|
// 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴
|
||||||
// (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록)
|
// (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록)
|
||||||
let splitPanelParentData: Record<string, any> | undefined;
|
let splitPanelParentData: Record<string, any> | undefined;
|
||||||
|
|
@ -947,13 +917,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨)
|
// 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨)
|
||||||
if (splitPanelPosition !== "left") {
|
if (splitPanelPosition !== "left") {
|
||||||
splitPanelParentData = splitPanelContext.getMappedParentData();
|
splitPanelParentData = splitPanelContext.getMappedParentData();
|
||||||
if (Object.keys(splitPanelParentData).length > 0) {
|
|
||||||
console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", {
|
|
||||||
splitPanelParentData,
|
|
||||||
splitPanelPosition,
|
|
||||||
isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -966,22 +929,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
|
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
|
||||||
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
||||||
|
|
||||||
// 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
|
// 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
|
||||||
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
|
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
|
||||||
effectiveFormData = { ...splitPanelParentData };
|
effectiveFormData = { ...splitPanelParentData };
|
||||||
console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 [ButtonPrimary] formData 선택:", {
|
|
||||||
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
|
|
||||||
screenContextKeys: Object.keys(screenContextFormData),
|
|
||||||
hasPropsFormData: Object.keys(propsFormData).length > 0,
|
|
||||||
propsFormDataKeys: Object.keys(propsFormData),
|
|
||||||
hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0,
|
|
||||||
splitPanelPosition,
|
|
||||||
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
|
||||||
});
|
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: effectiveFormData,
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
|
|
@ -1012,6 +964,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
componentConfigs,
|
componentConfigs,
|
||||||
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
||||||
splitPanelParentData,
|
splitPanelParentData,
|
||||||
|
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
|
||||||
|
splitPanelContext: splitPanelContext ? {
|
||||||
|
selectedLeftData: splitPanelContext.selectedLeftData,
|
||||||
|
refreshRightPanel: splitPanelContext.refreshRightPanel,
|
||||||
|
} : undefined,
|
||||||
} as ButtonActionContext;
|
} as ButtonActionContext;
|
||||||
|
|
||||||
// 확인이 필요한 액션인지 확인
|
// 확인이 필요한 액션인지 확인
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { CardDisplayConfig } from "./types";
|
import { CardDisplayConfig } from "./types";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { getFullImageUrl, apiClient } from "@/lib/api/client";
|
import { getFullImageUrl, apiClient } from "@/lib/api/client";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
@ -61,20 +62,34 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
// 테이블 데이터 상태 관리
|
// 테이블 데이터 상태 관리
|
||||||
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
|
||||||
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true); // 초기 로딩 상태를 true로 설정
|
||||||
|
const [initialLoadDone, setInitialLoadDone] = useState(false); // 초기 로드 완료 여부
|
||||||
|
const [hasEverSelectedLeftData, setHasEverSelectedLeftData] = useState(false); // 좌측 데이터 선택 이력
|
||||||
|
|
||||||
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
|
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
|
||||||
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
|
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
|
||||||
|
|
||||||
// 필터 상태 변경 래퍼 (로깅용)
|
// 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가)
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// refreshCardDisplay 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefreshCardDisplay = () => {
|
||||||
|
console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침");
|
||||||
|
setRefreshKey((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터 상태 변경 래퍼
|
||||||
const setFilters = useCallback((newFilters: TableFilter[]) => {
|
const setFilters = useCallback((newFilters: TableFilter[]) => {
|
||||||
console.log("🎴 [CardDisplay] setFilters 호출됨:", {
|
|
||||||
componentId: component.id,
|
|
||||||
filtersCount: newFilters.length,
|
|
||||||
filters: newFilters,
|
|
||||||
});
|
|
||||||
setFiltersInternal(newFilters);
|
setFiltersInternal(newFilters);
|
||||||
}, [component.id]);
|
}, []);
|
||||||
|
|
||||||
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
|
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
|
||||||
const [columnMeta, setColumnMeta] = useState<
|
const [columnMeta, setColumnMeta] = useState<
|
||||||
|
|
@ -108,6 +123,58 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 삭제 핸들러
|
||||||
|
const handleCardDelete = async (data: any, index: number) => {
|
||||||
|
// 사용자 확인
|
||||||
|
if (!confirm("정말로 이 항목을 삭제하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tableNameToUse = tableName || component.componentConfig?.tableName;
|
||||||
|
if (!tableNameToUse) {
|
||||||
|
alert("테이블 정보가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함)
|
||||||
|
const deleteData = [data];
|
||||||
|
|
||||||
|
|
||||||
|
// API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정)
|
||||||
|
// 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만
|
||||||
|
// axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용
|
||||||
|
const response = await apiClient.request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/table-management/tables/${tableNameToUse}/delete`,
|
||||||
|
data: deleteData,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
alert("삭제되었습니다.");
|
||||||
|
|
||||||
|
// 로컬 상태에서 삭제된 항목 제거
|
||||||
|
setLoadedTableData(prev => prev.filter((item, idx) => idx !== index));
|
||||||
|
|
||||||
|
// 선택된 항목이면 선택 해제
|
||||||
|
const cardKey = getCardKey(data, index);
|
||||||
|
if (selectedRows.has(cardKey)) {
|
||||||
|
const newSelectedRows = new Set(selectedRows);
|
||||||
|
newSelectedRows.delete(cardKey);
|
||||||
|
setSelectedRows(newSelectedRows);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`삭제 실패: ${response.data.message || response.data.error || "알 수 없는 오류"}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || "알 수 없는 오류";
|
||||||
|
alert(`삭제 중 오류가 발생했습니다: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 편집 폼 데이터 변경 핸들러
|
// 편집 폼 데이터 변경 핸들러
|
||||||
const handleEditFormChange = (key: string, value: string) => {
|
const handleEditFormChange = (key: string, value: string) => {
|
||||||
setEditData((prev: any) => ({
|
setEditData((prev: any) => ({
|
||||||
|
|
@ -135,8 +202,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
// loadTableData();
|
// loadTableData();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 편집 저장 실패:", error);
|
alert("저장에 실패했습니다.");
|
||||||
alert("❌ 저장에 실패했습니다.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -145,6 +211,25 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const loadTableData = async () => {
|
const loadTableData = async () => {
|
||||||
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
|
// 디자인 모드에서는 테이블 데이터를 로드하지 않음
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoadDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지)
|
||||||
|
// splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음
|
||||||
|
const isRightPanelEarly = splitPanelPosition === "right";
|
||||||
|
const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData &&
|
||||||
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||||
|
|
||||||
|
if (isRightPanelEarly && !hasSelectedLeftDataEarly) {
|
||||||
|
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
|
||||||
|
// 초기 로드가 아닌 경우에는 데이터를 지우지 않음
|
||||||
|
if (!initialLoadDone) {
|
||||||
|
setLoadedTableData([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoadDone(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,18 +237,107 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
||||||
|
|
||||||
if (!tableNameToUse) {
|
if (!tableNameToUse) {
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoadDone(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 필터 확인 (분할 패널 내부일 때)
|
||||||
|
let linkedFilterValues: Record<string, any> = {};
|
||||||
|
let hasLinkedFiltersConfigured = false;
|
||||||
|
let hasSelectedLeftData = false;
|
||||||
|
|
||||||
|
if (splitPanelContext) {
|
||||||
|
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||||
|
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||||||
|
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
||||||
|
(filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") ||
|
||||||
|
filter.targetColumn === tableNameToUse
|
||||||
|
);
|
||||||
|
|
||||||
|
// 좌측 데이터 선택 여부 확인
|
||||||
|
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
|
||||||
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||||
|
|
||||||
|
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
||||||
|
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||||||
|
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
|
||||||
|
const tableSpecificFilters: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(linkedFilterValues)) {
|
||||||
|
// key가 "테이블명.컬럼명" 형식인 경우
|
||||||
|
if (key.includes(".")) {
|
||||||
|
const [tblName, columnName] = key.split(".");
|
||||||
|
if (tblName === tableNameToUse) {
|
||||||
|
// 연결 필터는 코드 값이므로 equals 연산자 사용
|
||||||
|
tableSpecificFilters[columnName] = { value, operator: "equals" };
|
||||||
|
hasLinkedFiltersConfigured = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
|
||||||
|
tableSpecificFilters[key] = { value, operator: "equals" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
linkedFilterValues = tableSpecificFilters;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시
|
||||||
|
// 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수
|
||||||
|
// splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인
|
||||||
|
const isRightPanelFromContext = splitPanelPosition === "right";
|
||||||
|
const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId
|
||||||
|
? splitPanelContext.getPositionByScreenId(screenId as number) === "right"
|
||||||
|
: false;
|
||||||
|
const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext;
|
||||||
|
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
|
||||||
|
|
||||||
|
|
||||||
|
if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) {
|
||||||
|
setLoadedTableData([]);
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoadDone(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
|
// API 호출 파라미터에 연결 필터 추가 (search 객체 안에 넣어야 함)
|
||||||
const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([
|
const apiParams: Record<string, any> = {
|
||||||
tableTypeApi.getTableData(tableNameToUse, {
|
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 50, // 카드 표시용으로 적당한 개수
|
size: 50, // 카드 표시용으로 적당한 개수
|
||||||
}),
|
search: Object.keys(linkedFilterValues).length > 0 ? linkedFilterValues : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 조인 컬럼 설정 가져오기 (componentConfig에서)
|
||||||
|
const joinColumnsConfig = component.componentConfig?.joinColumns || [];
|
||||||
|
const entityJoinColumns = joinColumnsConfig
|
||||||
|
.filter((col: any) => col.isJoinColumn)
|
||||||
|
.map((col: any) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
sourceColumn: col.sourceColumn,
|
||||||
|
referenceTable: col.referenceTable,
|
||||||
|
referenceColumn: col.referenceColumn,
|
||||||
|
displayColumn: col.referenceColumn,
|
||||||
|
label: col.label,
|
||||||
|
joinAlias: col.columnName, // 백엔드에서 필요한 joinAlias 추가
|
||||||
|
sourceTable: tableNameToUse, // 기준 테이블
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드
|
||||||
|
// 조인 컬럼이 있으면 entityJoinApi 사용
|
||||||
|
let dataResponse;
|
||||||
|
if (entityJoinColumns.length > 0) {
|
||||||
|
console.log("🔗 [CardDisplay] 엔티티 조인 API 사용:", entityJoinColumns);
|
||||||
|
dataResponse = await entityJoinApi.getTableDataWithJoins(tableNameToUse, {
|
||||||
|
...apiParams,
|
||||||
|
additionalJoinColumns: entityJoinColumns,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dataResponse = await tableTypeApi.getTableData(tableNameToUse, apiParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [columnsResponse, inputTypesResponse] = await Promise.all([
|
||||||
tableTypeApi.getColumns(tableNameToUse),
|
tableTypeApi.getColumns(tableNameToUse),
|
||||||
tableTypeApi.getColumnInputTypes(tableNameToUse),
|
tableTypeApi.getColumnInputTypes(tableNameToUse),
|
||||||
]);
|
]);
|
||||||
|
|
@ -180,7 +354,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
codeCategory: item.codeCategory || item.code_category,
|
codeCategory: item.codeCategory || item.code_category,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta);
|
|
||||||
setColumnMeta(meta);
|
setColumnMeta(meta);
|
||||||
|
|
||||||
// 카테고리 타입 컬럼 찾기 및 매핑 로드
|
// 카테고리 타입 컬럼 찾기 및 매핑 로드
|
||||||
|
|
@ -188,17 +361,14 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
.filter(([_, m]) => m.inputType === "category")
|
.filter(([_, m]) => m.inputType === "category")
|
||||||
.map(([columnName]) => columnName);
|
.map(([columnName]) => columnName);
|
||||||
|
|
||||||
console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns);
|
|
||||||
|
|
||||||
if (categoryColumns.length > 0) {
|
if (categoryColumns.length > 0) {
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
|
|
||||||
for (const columnName of categoryColumns) {
|
for (const columnName of categoryColumns) {
|
||||||
try {
|
try {
|
||||||
console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`);
|
|
||||||
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`);
|
||||||
|
|
||||||
console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data);
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data) {
|
if (response.data.success && response.data.data) {
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
|
@ -210,29 +380,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const rawColor = item.color ?? item.badge_color;
|
const rawColor = item.color ?? item.badge_color;
|
||||||
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
||||||
mapping[code] = { label, color };
|
mapping[code] = { label, color };
|
||||||
console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`);
|
|
||||||
});
|
});
|
||||||
mappings[columnName] = mapping;
|
mappings[columnName] = mapping;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error);
|
// 카테고리 매핑 로드 실패 시 무시
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings);
|
|
||||||
setCategoryMappings(mappings);
|
setCategoryMappings(mappings);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ CardDisplay: 데이터 로딩 실패`, error);
|
|
||||||
setLoadedTableData([]);
|
setLoadedTableData([]);
|
||||||
setLoadedTableColumns([]);
|
setLoadedTableColumns([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setInitialLoadDone(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadTableData();
|
loadTableData();
|
||||||
}, [isDesignMode, tableName, component.componentConfig?.tableName]);
|
}, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]);
|
||||||
|
|
||||||
// 컴포넌트 설정 (기본값 보장)
|
// 컴포넌트 설정 (기본값 보장)
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
|
|
@ -272,8 +440,34 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
componentStyle.borderColor = isSelected ? "hsl(var(--ring))" : "hsl(var(--border))";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산)
|
||||||
|
const isRightPanelForDisplay = splitPanelPosition === "right" ||
|
||||||
|
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
|
||||||
|
const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
|
||||||
|
const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData;
|
||||||
|
const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay &&
|
||||||
|
Object.keys(selectedLeftDataForDisplay).length > 0;
|
||||||
|
|
||||||
|
// 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasSelectedLeftDataForDisplay) {
|
||||||
|
setHasEverSelectedLeftData(true);
|
||||||
|
}
|
||||||
|
}, [hasSelectedLeftDataForDisplay]);
|
||||||
|
|
||||||
|
// 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
|
||||||
|
// 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지
|
||||||
|
const shouldHideDataForRightPanel = isRightPanelForDisplay &&
|
||||||
|
!hasEverSelectedLeftData &&
|
||||||
|
!hasSelectedLeftDataForDisplay;
|
||||||
|
|
||||||
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
||||||
const displayData = useMemo(() => {
|
const displayData = useMemo(() => {
|
||||||
|
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 빈 배열 반환
|
||||||
|
if (shouldHideDataForRightPanel) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
||||||
if (loadedTableData.length > 0) {
|
if (loadedTableData.length > 0) {
|
||||||
return loadedTableData;
|
return loadedTableData;
|
||||||
|
|
@ -290,7 +484,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
|
|
||||||
// 데이터가 없으면 빈 배열 반환
|
// 데이터가 없으면 빈 배열 반환
|
||||||
return [];
|
return [];
|
||||||
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
|
}, [shouldHideDataForRightPanel, loadedTableData, tableData, componentConfig.staticData]);
|
||||||
|
|
||||||
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
|
// 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용)
|
||||||
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
|
const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns;
|
||||||
|
|
@ -335,13 +529,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
additionalData: {},
|
additionalData: {},
|
||||||
}));
|
}));
|
||||||
useModalDataStore.getState().setData(tableNameToUse, modalItems);
|
useModalDataStore.getState().setData(tableNameToUse, modalItems);
|
||||||
console.log("[CardDisplay] modalDataStore에 데이터 저장:", {
|
|
||||||
dataSourceId: tableNameToUse,
|
|
||||||
count: modalItems.length,
|
|
||||||
});
|
|
||||||
} else if (tableNameToUse && selectedRowsData.length === 0) {
|
} else if (tableNameToUse && selectedRowsData.length === 0) {
|
||||||
useModalDataStore.getState().clearData(tableNameToUse);
|
useModalDataStore.getState().clearData(tableNameToUse);
|
||||||
console.log("[CardDisplay] modalDataStore 데이터 제거:", tableNameToUse);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||||
|
|
@ -349,13 +538,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
splitPanelContext.setSelectedLeftData(data);
|
splitPanelContext.setSelectedLeftData(data);
|
||||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 저장:", {
|
|
||||||
data,
|
|
||||||
parentDataMapping: splitPanelContext.parentDataMapping,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
splitPanelContext.setSelectedLeftData(null);
|
splitPanelContext.setSelectedLeftData(null);
|
||||||
console.log("[CardDisplay] 분할 패널 좌측 데이터 초기화");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
|
||||||
|
|
@ -422,21 +606,38 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
}, [categoryMappings]);
|
}, [categoryMappings]);
|
||||||
|
|
||||||
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
|
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
|
||||||
// 초기 로드 여부 추적
|
// 초기 로드 여부 추적 - 마운트 카운터 사용 (Strict Mode 대응)
|
||||||
const isInitialLoadRef = useRef(true);
|
const mountCountRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountCountRef.current += 1;
|
||||||
|
const currentMount = mountCountRef.current;
|
||||||
|
|
||||||
if (!tableNameToUse || isDesignMode) return;
|
if (!tableNameToUse || isDesignMode) return;
|
||||||
|
|
||||||
// 초기 로드는 별도 useEffect에서 처리하므로 스킵
|
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵
|
||||||
if (isInitialLoadRef.current) {
|
const isRightPanel = splitPanelPosition === "right" ||
|
||||||
isInitialLoadRef.current = false;
|
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
|
||||||
|
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
|
||||||
|
const hasSelectedLeftData = splitPanelContext?.selectedLeftData &&
|
||||||
|
Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||||
|
|
||||||
|
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
|
||||||
|
if (isRightPanel && !hasSelectedLeftData) {
|
||||||
|
// 데이터를 지우지 않고 로딩만 false로 설정
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 2번의 마운트는 초기 로드 useEffect에서 처리 (Strict Mode에서 2번 호출됨)
|
||||||
|
// 필터 변경이 아닌 경우 스킵
|
||||||
|
if (currentMount <= 2 && filters.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadFilteredData = async () => {
|
const loadFilteredData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
// 로딩 상태를 true로 설정하지 않음 - 기존 데이터 유지하면서 새 데이터 로드 (깜빡임 방지)
|
||||||
|
|
||||||
// 필터 값을 검색 파라미터로 변환
|
// 필터 값을 검색 파라미터로 변환
|
||||||
const searchParams: Record<string, any> = {};
|
const searchParams: Record<string, any> = {};
|
||||||
|
|
@ -446,12 +647,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", {
|
|
||||||
tableName: tableNameToUse,
|
|
||||||
filtersCount: filters.length,
|
|
||||||
searchParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
|
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
|
||||||
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
|
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
@ -466,16 +661,14 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
|
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ [CardDisplay] 필터 적용 실패:", error);
|
// 필터 적용 실패 시 무시
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
|
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
|
||||||
loadFilteredData();
|
loadFilteredData();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters, tableNameToUse, isDesignMode, tableId]);
|
}, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]);
|
||||||
|
|
||||||
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
|
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
|
||||||
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
|
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
|
||||||
|
|
@ -498,7 +691,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
label: mapping?.[value]?.label || value,
|
label: mapping?.[value]?.label || value,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [tableNameToUse]);
|
}, [tableNameToUse]);
|
||||||
|
|
@ -545,10 +737,6 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
|
|
||||||
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
|
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
|
||||||
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
|
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
|
||||||
console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", {
|
|
||||||
tableId,
|
|
||||||
filtersCount: newFilters.length,
|
|
||||||
});
|
|
||||||
setFiltersRef.current(newFilters);
|
setFiltersRef.current(newFilters);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -568,20 +756,12 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
getColumnUniqueValues: getColumnUniqueValuesWrapper,
|
getColumnUniqueValues: getColumnUniqueValuesWrapper,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📋 [CardDisplay] TableOptionsContext에 등록:", {
|
|
||||||
tableId,
|
|
||||||
tableName: tableNameToUse,
|
|
||||||
columnsCount: columns.length,
|
|
||||||
dataCount: loadedTableData.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
registerTableRef.current(registration);
|
registerTableRef.current(registration);
|
||||||
|
|
||||||
const unregister = unregisterTableRef.current;
|
const unregister = unregisterTableRef.current;
|
||||||
const currentTableId = tableId;
|
const currentTableId = tableId;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId);
|
|
||||||
unregister(currentTableId);
|
unregister(currentTableId);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -593,8 +773,34 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
columnsKey, // 컬럼 변경 시에만 재등록
|
columnsKey, // 컬럼 변경 시에만 재등록
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 로딩 중인 경우 로딩 표시
|
// 우측 패널이고 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
|
||||||
if (loading) {
|
// 한 번이라도 선택된 적이 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지)
|
||||||
|
if (shouldHideDataForRightPanel) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
...componentStyle,
|
||||||
|
...style,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "20px",
|
||||||
|
background: "#f8fafc",
|
||||||
|
borderRadius: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-muted-foreground text-center">
|
||||||
|
<div className="text-lg mb-2">좌측에서 항목을 선택해주세요</div>
|
||||||
|
<div className="text-sm text-gray-400">선택한 항목의 관련 데이터가 여기에 표시됩니다</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 중이고 데이터가 없는 경우에만 로딩 표시
|
||||||
|
// 데이터가 있으면 로딩 중에도 기존 데이터 유지 (깜빡임 방지)
|
||||||
|
if (loading && displayData.length === 0 && !hasEverSelectedLeftData) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
|
|
@ -617,28 +823,29 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
|
||||||
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
|
||||||
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
|
gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격
|
||||||
padding: "32px", // 패딩 대폭 증가
|
padding: "16px", // 패딩
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤)
|
background: "transparent", // 배경색 제거
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
borderRadius: "0", // 라운드 제거
|
||||||
};
|
};
|
||||||
|
|
||||||
// 카드 스타일 - 컴팩트한 디자인
|
// 카드 스타일 - 컴팩트한 디자인
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
backgroundColor: "white",
|
backgroundColor: "hsl(var(--card))",
|
||||||
border: "1px solid #e5e7eb",
|
border: "1px solid hsl(var(--border))",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
padding: "16px",
|
padding: "16px",
|
||||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
|
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
cursor: isDesignMode ? "pointer" : "default",
|
cursor: isDesignMode ? "pointer" : "default",
|
||||||
|
width: "100%", // 전체 너비 차지
|
||||||
};
|
};
|
||||||
|
|
||||||
// 텍스트 자르기 함수
|
// 텍스트 자르기 함수
|
||||||
|
|
@ -957,6 +1164,17 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
편집
|
편집
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(componentConfig.cardStyle?.showDeleteButton ?? false) && (
|
||||||
|
<button
|
||||||
|
className="text-xs text-red-500 hover:text-red-700 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCardDelete(data, index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
interface CardDisplayConfigPanelProps {
|
interface CardDisplayConfigPanelProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -9,9 +24,32 @@ interface CardDisplayConfigPanelProps {
|
||||||
tableColumns?: any[];
|
tableColumns?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EntityJoinColumn {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
joinAlias: string;
|
||||||
|
suggestedLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JoinTable {
|
||||||
|
tableName: string;
|
||||||
|
currentDisplayColumn: string;
|
||||||
|
joinConfig?: {
|
||||||
|
sourceColumn: string;
|
||||||
|
};
|
||||||
|
availableColumns: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CardDisplay 설정 패널
|
* CardDisplay 설정 패널
|
||||||
* 카드 레이아웃과 동일한 설정 UI 제공
|
* 카드 레이아웃과 동일한 설정 UI 제공 + 엔티티 조인 컬럼 지원
|
||||||
*/
|
*/
|
||||||
export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
config,
|
config,
|
||||||
|
|
@ -19,6 +57,40 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
screenTableName,
|
screenTableName,
|
||||||
tableColumns = [],
|
tableColumns = [],
|
||||||
}) => {
|
}) => {
|
||||||
|
// 엔티티 조인 컬럼 상태
|
||||||
|
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||||
|
availableColumns: EntityJoinColumn[];
|
||||||
|
joinTables: JoinTable[];
|
||||||
|
}>({ availableColumns: [], joinTables: [] });
|
||||||
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||||
|
|
||||||
|
// 엔티티 조인 컬럼 정보 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEntityJoinColumns = async () => {
|
||||||
|
const tableName = config.tableName || screenTableName;
|
||||||
|
if (!tableName) {
|
||||||
|
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingEntityJoins(true);
|
||||||
|
try {
|
||||||
|
const result = await entityJoinApi.getEntityJoinColumns(tableName);
|
||||||
|
setEntityJoinColumns({
|
||||||
|
availableColumns: result.availableColumns || [],
|
||||||
|
joinTables: result.joinTables || [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||||
|
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||||
|
} finally {
|
||||||
|
setLoadingEntityJoins(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchEntityJoinColumns();
|
||||||
|
}, [config.tableName, screenTableName]);
|
||||||
|
|
||||||
const handleChange = (key: string, value: any) => {
|
const handleChange = (key: string, value: any) => {
|
||||||
onChange({ ...config, [key]: value });
|
onChange({ ...config, [key]: value });
|
||||||
};
|
};
|
||||||
|
|
@ -28,7 +100,6 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
let newConfig = { ...config };
|
let newConfig = { ...config };
|
||||||
let current = newConfig;
|
let current = newConfig;
|
||||||
|
|
||||||
// 중첩 객체 생성
|
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
if (!current[keys[i]]) {
|
if (!current[keys[i]]) {
|
||||||
current[keys[i]] = {};
|
current[keys[i]] = {};
|
||||||
|
|
@ -40,6 +111,47 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 컬럼 선택 시 조인 컬럼이면 joinColumns 설정도 함께 업데이트
|
||||||
|
const handleColumnSelect = (path: string, columnName: string) => {
|
||||||
|
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||||
|
(col) => col.joinAlias === columnName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (joinColumn) {
|
||||||
|
const joinColumnsConfig = config.joinColumns || [];
|
||||||
|
const existingJoinColumn = joinColumnsConfig.find(
|
||||||
|
(jc: any) => jc.columnName === columnName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingJoinColumn) {
|
||||||
|
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||||
|
(jt) => jt.tableName === joinColumn.tableName
|
||||||
|
);
|
||||||
|
|
||||||
|
const newJoinColumnConfig = {
|
||||||
|
columnName: joinColumn.joinAlias,
|
||||||
|
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||||
|
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||||
|
referenceTable: joinColumn.tableName,
|
||||||
|
referenceColumn: joinColumn.columnName,
|
||||||
|
isJoinColumn: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
columnMapping: {
|
||||||
|
...config.columnMapping,
|
||||||
|
[path.split(".")[1]]: columnName,
|
||||||
|
},
|
||||||
|
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNestedChange(path, columnName);
|
||||||
|
};
|
||||||
|
|
||||||
// 표시 컬럼 추가
|
// 표시 컬럼 추가
|
||||||
const addDisplayColumn = () => {
|
const addDisplayColumn = () => {
|
||||||
const currentColumns = config.columnMapping?.displayColumns || [];
|
const currentColumns = config.columnMapping?.displayColumns || [];
|
||||||
|
|
@ -58,122 +170,198 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
const updateDisplayColumn = (index: number, value: string) => {
|
const updateDisplayColumn = (index: number, value: string) => {
|
||||||
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
const currentColumns = [...(config.columnMapping?.displayColumns || [])];
|
||||||
currentColumns[index] = value;
|
currentColumns[index] = value;
|
||||||
|
|
||||||
|
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||||
|
(col) => col.joinAlias === value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (joinColumn) {
|
||||||
|
const joinColumnsConfig = config.joinColumns || [];
|
||||||
|
const existingJoinColumn = joinColumnsConfig.find(
|
||||||
|
(jc: any) => jc.columnName === value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingJoinColumn) {
|
||||||
|
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||||
|
(jt) => jt.tableName === joinColumn.tableName
|
||||||
|
);
|
||||||
|
|
||||||
|
const newJoinColumnConfig = {
|
||||||
|
columnName: joinColumn.joinAlias,
|
||||||
|
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||||
|
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||||
|
referenceTable: joinColumn.tableName,
|
||||||
|
referenceColumn: joinColumn.columnName,
|
||||||
|
isJoinColumn: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
columnMapping: {
|
||||||
|
...config.columnMapping,
|
||||||
|
displayColumns: currentColumns,
|
||||||
|
},
|
||||||
|
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
handleNestedChange("columnMapping.displayColumns", currentColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 테이블별로 조인 컬럼 그룹화
|
||||||
|
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
|
||||||
|
entityJoinColumns.availableColumns.forEach((col) => {
|
||||||
|
if (!joinColumnsByTable[col.tableName]) {
|
||||||
|
joinColumnsByTable[col.tableName] = [];
|
||||||
|
}
|
||||||
|
joinColumnsByTable[col.tableName].push(col);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
|
||||||
|
const renderColumnSelect = (
|
||||||
|
value: string,
|
||||||
|
onChangeHandler: (value: string) => void,
|
||||||
|
placeholder: string = "컬럼을 선택하세요"
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || "__none__"}
|
||||||
|
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* 선택 안함 옵션 */}
|
||||||
|
<SelectItem value="__none__" className="text-xs text-muted-foreground">
|
||||||
|
선택 안함
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
|
{/* 기본 테이블 컬럼 */}
|
||||||
|
{tableColumns.length > 0 && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel className="text-xs font-semibold text-muted-foreground">
|
||||||
|
기본 컬럼
|
||||||
|
</SelectLabel>
|
||||||
|
{tableColumns.map((column) => (
|
||||||
|
<SelectItem
|
||||||
|
key={column.columnName}
|
||||||
|
value={column.columnName}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 조인 테이블별 컬럼 */}
|
||||||
|
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
|
||||||
|
<SelectGroup key={tableName}>
|
||||||
|
<SelectLabel className="text-xs font-semibold text-blue-600">
|
||||||
|
{tableName} (조인)
|
||||||
|
</SelectLabel>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem
|
||||||
|
key={col.joinAlias}
|
||||||
|
value={col.joinAlias}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{col.suggestedLabel || col.columnLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium text-gray-700">카드 디스플레이 설정</div>
|
<div className="text-sm font-medium">카드 디스플레이 설정</div>
|
||||||
|
|
||||||
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
|
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
|
||||||
{tableColumns && tableColumns.length > 0 && (
|
{tableColumns && tableColumns.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h5 className="text-xs font-medium text-gray-700">컬럼 매핑</h5>
|
<h5 className="text-xs font-medium text-muted-foreground">컬럼 매핑</h5>
|
||||||
|
|
||||||
<div>
|
{loadingEntityJoins && (
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">타이틀 컬럼</label>
|
<div className="text-xs text-muted-foreground">조인 컬럼 로딩 중...</div>
|
||||||
<select
|
)}
|
||||||
value={config.columnMapping?.titleColumn || ""}
|
|
||||||
onChange={(e) => handleNestedChange("columnMapping.titleColumn", e.target.value)}
|
<div className="space-y-1">
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
<Label className="text-xs">타이틀 컬럼</Label>
|
||||||
>
|
{renderColumnSelect(
|
||||||
<option value="">컬럼을 선택하세요</option>
|
config.columnMapping?.titleColumn || "",
|
||||||
{tableColumns.map((column) => (
|
(value) => handleColumnSelect("columnMapping.titleColumn", value)
|
||||||
<option key={column.columnName} value={column.columnName}>
|
)}
|
||||||
{column.columnLabel || column.columnName} ({column.dataType})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">서브타이틀 컬럼</label>
|
<Label className="text-xs">서브타이틀 컬럼</Label>
|
||||||
<select
|
{renderColumnSelect(
|
||||||
value={config.columnMapping?.subtitleColumn || ""}
|
config.columnMapping?.subtitleColumn || "",
|
||||||
onChange={(e) => handleNestedChange("columnMapping.subtitleColumn", e.target.value)}
|
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
)}
|
||||||
>
|
|
||||||
<option value="">컬럼을 선택하세요</option>
|
|
||||||
{tableColumns.map((column) => (
|
|
||||||
<option key={column.columnName} value={column.columnName}>
|
|
||||||
{column.columnLabel || column.columnName} ({column.dataType})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">설명 컬럼</label>
|
<Label className="text-xs">설명 컬럼</Label>
|
||||||
<select
|
{renderColumnSelect(
|
||||||
value={config.columnMapping?.descriptionColumn || ""}
|
config.columnMapping?.descriptionColumn || "",
|
||||||
onChange={(e) => handleNestedChange("columnMapping.descriptionColumn", e.target.value)}
|
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
)}
|
||||||
>
|
|
||||||
<option value="">컬럼을 선택하세요</option>
|
|
||||||
{tableColumns.map((column) => (
|
|
||||||
<option key={column.columnName} value={column.columnName}>
|
|
||||||
{column.columnLabel || column.columnName} ({column.dataType})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">이미지 컬럼</label>
|
<Label className="text-xs">이미지 컬럼</Label>
|
||||||
<select
|
{renderColumnSelect(
|
||||||
value={config.columnMapping?.imageColumn || ""}
|
config.columnMapping?.imageColumn || "",
|
||||||
onChange={(e) => handleNestedChange("columnMapping.imageColumn", e.target.value)}
|
(value) => handleColumnSelect("columnMapping.imageColumn", value)
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
)}
|
||||||
>
|
|
||||||
<option value="">컬럼을 선택하세요</option>
|
|
||||||
{tableColumns.map((column) => (
|
|
||||||
<option key={column.columnName} value={column.columnName}>
|
|
||||||
{column.columnLabel || column.columnName} ({column.dataType})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 동적 표시 컬럼 추가 */}
|
{/* 동적 표시 컬럼 추가 */}
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-medium text-gray-600">표시 컬럼들</label>
|
<Label className="text-xs">표시 컬럼들</Label>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={addDisplayColumn}
|
onClick={addDisplayColumn}
|
||||||
className="rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600"
|
className="h-6 px-2 text-xs"
|
||||||
>
|
>
|
||||||
+ 컬럼 추가
|
+ 컬럼 추가
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => (
|
{(config.columnMapping?.displayColumns || []).map((column: string, index: number) => (
|
||||||
<div key={index} className="flex items-center space-x-2">
|
<div key={index} className="flex items-center gap-2">
|
||||||
<select
|
<div className="flex-1">
|
||||||
value={column}
|
{renderColumnSelect(
|
||||||
onChange={(e) => updateDisplayColumn(index, e.target.value)}
|
column,
|
||||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
(value) => updateDisplayColumn(index, value)
|
||||||
>
|
)}
|
||||||
<option value="">컬럼을 선택하세요</option>
|
</div>
|
||||||
{tableColumns.map((col) => (
|
<Button
|
||||||
<option key={col.columnName} value={col.columnName}>
|
|
||||||
{col.columnLabel || col.columnName} ({col.dataType})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => removeDisplayColumn(index)}
|
onClick={() => removeDisplayColumn(index)}
|
||||||
className="rounded bg-red-500 px-2 py-1 text-xs text-white hover:bg-red-600"
|
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||||
>
|
>
|
||||||
삭제
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && (
|
{(!config.columnMapping?.displayColumns || config.columnMapping.displayColumns.length === 0) && (
|
||||||
<div className="rounded border border-dashed border-gray-300 py-2 text-center text-xs text-gray-500">
|
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
|
||||||
"컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요
|
"컬럼 추가" 버튼을 클릭하여 표시할 컬럼을 추가하세요
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -184,173 +372,166 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
|
||||||
|
|
||||||
{/* 카드 스타일 설정 */}
|
{/* 카드 스타일 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h5 className="text-xs font-medium text-gray-700">카드 스타일</h5>
|
<h5 className="text-xs font-medium text-muted-foreground">카드 스타일</h5>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">한 행당 카드 수</label>
|
<Label className="text-xs">한 행당 카드 수</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="6"
|
max="6"
|
||||||
value={config.cardsPerRow || 3}
|
value={config.cardsPerRow || 3}
|
||||||
onChange={(e) => handleChange("cardsPerRow", parseInt(e.target.value))}
|
onChange={(e) => handleChange("cardsPerRow", parseInt(e.target.value))}
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">카드 간격 (px)</label>
|
<Label className="text-xs">카드 간격 (px)</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="50"
|
max="50"
|
||||||
value={config.cardSpacing || 16}
|
value={config.cardSpacing || 16}
|
||||||
onChange={(e) => handleChange("cardSpacing", parseInt(e.target.value))}
|
onChange={(e) => handleChange("cardSpacing", parseInt(e.target.value))}
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showTitle"
|
id="showTitle"
|
||||||
checked={config.cardStyle?.showTitle ?? true}
|
checked={config.cardStyle?.showTitle ?? true}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showTitle", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showTitle", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showTitle" className="text-xs text-gray-600">
|
<Label htmlFor="showTitle" className="text-xs font-normal">
|
||||||
타이틀 표시
|
타이틀 표시
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showSubtitle"
|
id="showSubtitle"
|
||||||
checked={config.cardStyle?.showSubtitle ?? true}
|
checked={config.cardStyle?.showSubtitle ?? true}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showSubtitle", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showSubtitle", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showSubtitle" className="text-xs text-gray-600">
|
<Label htmlFor="showSubtitle" className="text-xs font-normal">
|
||||||
서브타이틀 표시
|
서브타이틀 표시
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showDescription"
|
id="showDescription"
|
||||||
checked={config.cardStyle?.showDescription ?? true}
|
checked={config.cardStyle?.showDescription ?? true}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showDescription", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDescription", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showDescription" className="text-xs text-gray-600">
|
<Label htmlFor="showDescription" className="text-xs font-normal">
|
||||||
설명 표시
|
설명 표시
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showImage"
|
id="showImage"
|
||||||
checked={config.cardStyle?.showImage ?? false}
|
checked={config.cardStyle?.showImage ?? false}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showImage", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showImage", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showImage" className="text-xs text-gray-600">
|
<Label htmlFor="showImage" className="text-xs font-normal">
|
||||||
이미지 표시
|
이미지 표시
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showActions"
|
id="showActions"
|
||||||
checked={config.cardStyle?.showActions ?? true}
|
checked={config.cardStyle?.showActions ?? true}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showActions", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showActions", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showActions" className="text-xs text-gray-600">
|
<Label htmlFor="showActions" className="text-xs font-normal">
|
||||||
액션 버튼 표시
|
액션 버튼 표시
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 개별 버튼 설정 (액션 버튼이 활성화된 경우에만 표시) */}
|
{/* 개별 버튼 설정 */}
|
||||||
{(config.cardStyle?.showActions ?? true) && (
|
{(config.cardStyle?.showActions ?? true) && (
|
||||||
<div className="ml-4 space-y-2 border-l-2 border-gray-200 pl-3">
|
<div className="ml-5 space-y-2 border-l-2 border-muted pl-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showViewButton"
|
id="showViewButton"
|
||||||
checked={config.cardStyle?.showViewButton ?? true}
|
checked={config.cardStyle?.showViewButton ?? true}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showViewButton", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showViewButton", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showViewButton" className="text-xs text-gray-600">
|
<Label htmlFor="showViewButton" className="text-xs font-normal">
|
||||||
상세보기 버튼
|
상세보기 버튼
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="showEditButton"
|
id="showEditButton"
|
||||||
checked={config.cardStyle?.showEditButton ?? true}
|
checked={config.cardStyle?.showEditButton ?? true}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.showEditButton", e.target.checked)}
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showEditButton", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="showEditButton" className="text-xs text-gray-600">
|
<Label htmlFor="showEditButton" className="text-xs font-normal">
|
||||||
편집 버튼
|
편집 버튼
|
||||||
</label>
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showDeleteButton"
|
||||||
|
checked={config.cardStyle?.showDeleteButton ?? false}
|
||||||
|
onCheckedChange={(checked) => handleNestedChange("cardStyle.showDeleteButton", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showDeleteButton" className="text-xs font-normal">
|
||||||
|
삭제 버튼
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-600">설명 최대 길이</label>
|
<Label className="text-xs">설명 최대 길이</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
min="10"
|
||||||
max="500"
|
max="500"
|
||||||
value={config.cardStyle?.maxDescriptionLength || 100}
|
value={config.cardStyle?.maxDescriptionLength || 100}
|
||||||
onChange={(e) => handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))}
|
onChange={(e) => handleNestedChange("cardStyle.maxDescriptionLength", parseInt(e.target.value))}
|
||||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 공통 설정 */}
|
{/* 공통 설정 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h5 className="text-xs font-medium text-gray-700">공통 설정</h5>
|
<h5 className="text-xs font-medium text-muted-foreground">공통 설정</h5>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="disabled"
|
id="disabled"
|
||||||
checked={config.disabled || false}
|
checked={config.disabled || false}
|
||||||
onChange={(e) => handleChange("disabled", e.target.checked)}
|
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="disabled" className="text-xs text-gray-600">
|
<Label htmlFor="disabled" className="text-xs font-normal">
|
||||||
비활성화
|
비활성화
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
id="readonly"
|
id="readonly"
|
||||||
checked={config.readonly || false}
|
checked={config.readonly || false}
|
||||||
onChange={(e) => handleChange("readonly", e.target.checked)}
|
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
/>
|
||||||
<label htmlFor="readonly" className="text-xs text-gray-600">
|
<Label htmlFor="readonly" className="text-xs font-normal">
|
||||||
읽기 전용
|
읽기 전용
|
||||||
</label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface CardStyleConfig {
|
||||||
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
showActions?: boolean; // 액션 버튼 표시 여부 (전체)
|
||||||
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
showViewButton?: boolean; // 상세보기 버튼 표시 여부
|
||||||
showEditButton?: boolean; // 편집 버튼 표시 여부
|
showEditButton?: boolean; // 편집 버튼 표시 여부
|
||||||
|
showDeleteButton?: boolean; // 삭제 버튼 표시 여부
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -166,8 +166,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
||||||
|
// 🆕 UTC 시간을 로컬 시간으로 변환하여 날짜 추출 (타임존 이슈 해결)
|
||||||
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
||||||
return dateStr.split("T")[0];
|
const date = new Date(dateStr);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다른 형식의 날짜 문자열이나 Date 객체 처리
|
// 다른 형식의 날짜 문자열이나 Date 객체 처리
|
||||||
|
|
@ -276,7 +281,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -299,16 +304,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 구분자 */}
|
{/* 구분자 */}
|
||||||
<span className="text-base font-medium text-muted-foreground">~</span>
|
<span className="text-muted-foreground text-base font-medium">~</span>
|
||||||
|
|
||||||
{/* 종료일 */}
|
{/* 종료일 */}
|
||||||
<input
|
<input
|
||||||
|
|
@ -326,11 +333,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"h-full min-h-full flex-1 rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -344,7 +353,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
<div className={`relative h-full w-full ${className || ""}`} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label className="absolute -top-6 left-0 text-sm font-medium text-muted-foreground">
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && <span className="text-destructive">*</span>}
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -368,11 +377,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -402,11 +413,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
"box-border h-full min-h-full w-full rounded-md border px-3 py-2 text-sm transition-all duration-200 outline-none",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
isSelected ? "border-ring ring-2 ring-ring/50" : "border-input",
|
isSelected ? "border-ring ring-ring/50 ring-2" : "border-input",
|
||||||
componentConfig.disabled ? "bg-muted text-muted-foreground cursor-not-allowed opacity-50" : "bg-background text-foreground",
|
componentConfig.disabled
|
||||||
"disabled:cursor-not-allowed"
|
? "bg-muted text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
|
: "bg-background text-foreground",
|
||||||
|
"disabled:cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { EntitySearchModal } from "./EntitySearchModal";
|
import { EntitySearchModal } from "./EntitySearchModal";
|
||||||
import { EntitySearchInputProps, EntitySearchResult } from "./types";
|
import { EntitySearchInputProps, EntitySearchResult } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||||
|
|
||||||
export function EntitySearchInputComponent({
|
export function EntitySearchInputComponent({
|
||||||
tableName,
|
tableName,
|
||||||
displayField,
|
displayField,
|
||||||
valueField,
|
valueField,
|
||||||
searchFields = [displayField],
|
searchFields = [displayField],
|
||||||
mode = "combo",
|
mode: modeProp,
|
||||||
|
uiMode, // EntityConfigPanel에서 저장되는 값
|
||||||
placeholder = "검색...",
|
placeholder = "검색...",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
filterCondition = {},
|
filterCondition = {},
|
||||||
|
|
@ -24,31 +29,230 @@ export function EntitySearchInputComponent({
|
||||||
showAdditionalInfo = false,
|
showAdditionalInfo = false,
|
||||||
additionalFields = [],
|
additionalFields = [],
|
||||||
className,
|
className,
|
||||||
}: EntitySearchInputProps) {
|
style,
|
||||||
|
// 연쇄관계 props
|
||||||
|
cascadingRelationCode,
|
||||||
|
parentValue: parentValueProp,
|
||||||
|
parentFieldId,
|
||||||
|
formData,
|
||||||
|
// 🆕 추가 props
|
||||||
|
component,
|
||||||
|
isInteractive,
|
||||||
|
onFormDataChange,
|
||||||
|
}: EntitySearchInputProps & {
|
||||||
|
uiMode?: string;
|
||||||
|
component?: any;
|
||||||
|
isInteractive?: boolean;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
|
||||||
|
}) {
|
||||||
|
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||||
|
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||||
|
|
||||||
|
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
|
||||||
|
const config = component?.componentConfig || {};
|
||||||
|
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
||||||
|
const effectiveParentFieldId = parentFieldId || config.parentFieldId;
|
||||||
|
const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined
|
||||||
|
|
||||||
|
// 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨)
|
||||||
|
const isChildRole = effectiveCascadingRole === "child";
|
||||||
|
const shouldApplyCascading = effectiveCascadingRelationCode && isChildRole;
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectOpen, setSelectOpen] = useState(false);
|
||||||
const [displayValue, setDisplayValue] = useState("");
|
const [displayValue, setDisplayValue] = useState("");
|
||||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||||
|
const [options, setOptions] = useState<EntitySearchResult[]>([]);
|
||||||
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||||
|
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||||
|
|
||||||
// value가 변경되면 표시값 업데이트
|
// 연쇄관계 상태
|
||||||
|
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
|
||||||
|
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
|
||||||
|
const previousParentValue = useRef<any>(null);
|
||||||
|
|
||||||
|
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
|
||||||
|
const parentValue = isChildRole
|
||||||
|
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
|
||||||
|
const filterConditionKey = JSON.stringify(filterCondition || {});
|
||||||
|
|
||||||
|
// 연쇄관계가 설정된 경우: 부모 값이 변경되면 자식 옵션 로드 (자식 역할일 때만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value && selectedData) {
|
const loadCascadingOptions = async () => {
|
||||||
setDisplayValue(selectedData[displayField] || "");
|
if (!shouldApplyCascading) return;
|
||||||
|
|
||||||
|
// 부모 값이 없으면 옵션 초기화
|
||||||
|
if (!parentValue) {
|
||||||
|
setCascadingOptions([]);
|
||||||
|
// 부모 값이 변경되면 현재 값도 초기화
|
||||||
|
if (previousParentValue.current !== null && previousParentValue.current !== parentValue) {
|
||||||
|
handleClear();
|
||||||
|
}
|
||||||
|
previousParentValue.current = parentValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부모 값이 동일하면 스킵
|
||||||
|
if (previousParentValue.current === parentValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousParentValue.current = parentValue;
|
||||||
|
setIsCascadingLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🔗 연쇄관계 옵션 로드:", { effectiveCascadingRelationCode, parentValue });
|
||||||
|
const response = await cascadingRelationApi.getOptions(effectiveCascadingRelationCode, String(parentValue));
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 옵션을 EntitySearchResult 형태로 변환
|
||||||
|
const formattedOptions = response.data.map((opt: any) => ({
|
||||||
|
[valueField]: opt.value,
|
||||||
|
[displayField]: opt.label,
|
||||||
|
...opt, // 추가 필드도 포함
|
||||||
|
}));
|
||||||
|
setCascadingOptions(formattedOptions);
|
||||||
|
console.log("✅ 연쇄관계 옵션 로드 완료:", formattedOptions.length, "개");
|
||||||
|
|
||||||
|
// 현재 선택된 값이 새 옵션에 없으면 초기화
|
||||||
|
if (value && !formattedOptions.find((opt: any) => opt[valueField] === value)) {
|
||||||
|
handleClear();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
setCascadingOptions([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 연쇄관계 옵션 로드 실패:", error);
|
||||||
|
setCascadingOptions([]);
|
||||||
|
} finally {
|
||||||
|
setIsCascadingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCascadingOptions();
|
||||||
|
}, [shouldApplyCascading, effectiveCascadingRelationCode, parentValue, valueField, displayField]);
|
||||||
|
|
||||||
|
// select 모드일 때 옵션 로드 (연쇄관계가 없거나 부모 역할인 경우)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "select" && tableName && !optionsLoaded && !shouldApplyCascading) {
|
||||||
|
loadOptions();
|
||||||
|
setOptionsLoaded(true);
|
||||||
|
}
|
||||||
|
}, [mode, tableName, filterConditionKey, optionsLoaded, shouldApplyCascading]);
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
if (!tableName) return;
|
||||||
|
|
||||||
|
setIsLoadingOptions(true);
|
||||||
|
try {
|
||||||
|
const response = await dynamicFormApi.getTableData(tableName, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 100, // 최대 100개까지 로드
|
||||||
|
filters: filterCondition,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setOptions(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("옵션 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOptions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 실제 사용할 옵션 목록 (자식 역할이고 연쇄관계가 있으면 연쇄 옵션 사용)
|
||||||
|
const effectiveOptions = shouldApplyCascading ? cascadingOptions : options;
|
||||||
|
const isLoading = shouldApplyCascading ? isCascadingLoading : isLoadingOptions;
|
||||||
|
|
||||||
|
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDisplayValue = async () => {
|
||||||
|
if (value && selectedData) {
|
||||||
|
// 이미 selectedData가 있으면 표시값만 업데이트
|
||||||
|
setDisplayValue(selectedData[displayField] || "");
|
||||||
|
} else if (value && mode === "select" && effectiveOptions.length > 0) {
|
||||||
|
// select 모드에서 value가 있고 options가 로드된 경우
|
||||||
|
const found = effectiveOptions.find((opt) => opt[valueField] === value);
|
||||||
|
if (found) {
|
||||||
|
setSelectedData(found);
|
||||||
|
setDisplayValue(found[displayField] || "");
|
||||||
|
}
|
||||||
|
} else if (value && !selectedData && tableName) {
|
||||||
|
// value는 있지만 selectedData가 없는 경우 (초기 로드 시)
|
||||||
|
// API로 해당 데이터 조회
|
||||||
|
try {
|
||||||
|
console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField });
|
||||||
|
const response = await dynamicFormApi.getTableData(tableName, {
|
||||||
|
filters: { [valueField]: value },
|
||||||
|
pageSize: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 데이터 추출 (중첩 구조 처리)
|
||||||
|
const responseData = response.data as any;
|
||||||
|
const dataArray = Array.isArray(responseData)
|
||||||
|
? responseData
|
||||||
|
: responseData?.data
|
||||||
|
? Array.isArray(responseData.data)
|
||||||
|
? responseData.data
|
||||||
|
: [responseData.data]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
const foundData = dataArray[0];
|
||||||
|
setSelectedData(foundData);
|
||||||
|
setDisplayValue(foundData[displayField] || "");
|
||||||
|
console.log("✅ [EntitySearchInput] 초기값 로드 완료:", foundData);
|
||||||
|
} else {
|
||||||
|
// 데이터를 찾지 못한 경우 value 자체를 표시
|
||||||
|
console.log("⚠️ [EntitySearchInput] 초기값 데이터 없음, value 표시:", value);
|
||||||
|
setDisplayValue(String(value));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ [EntitySearchInput] API 응답 실패, value 표시:", value);
|
||||||
|
setDisplayValue(String(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [EntitySearchInput] 초기값 조회 실패:", error);
|
||||||
|
// 에러 시 value 자체를 표시
|
||||||
|
setDisplayValue(String(value));
|
||||||
|
}
|
||||||
|
} else if (!value) {
|
||||||
setDisplayValue("");
|
setDisplayValue("");
|
||||||
setSelectedData(null);
|
setSelectedData(null);
|
||||||
}
|
}
|
||||||
}, [value, displayField]);
|
};
|
||||||
|
|
||||||
|
loadDisplayValue();
|
||||||
|
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
|
||||||
|
|
||||||
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
||||||
setSelectedData(fullData);
|
setSelectedData(fullData);
|
||||||
setDisplayValue(fullData[displayField] || "");
|
setDisplayValue(fullData[displayField] || "");
|
||||||
onChange?.(newValue, fullData);
|
onChange?.(newValue, fullData);
|
||||||
|
|
||||||
|
// 🆕 onFormDataChange 호출 (formData에 값 저장)
|
||||||
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setDisplayValue("");
|
setDisplayValue("");
|
||||||
setSelectedData(null);
|
setSelectedData(null);
|
||||||
onChange?.(null, null);
|
onChange?.(null, null);
|
||||||
|
|
||||||
|
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
|
||||||
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||||
|
onFormDataChange(component.columnName, null);
|
||||||
|
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenModal = () => {
|
const handleOpenModal = () => {
|
||||||
|
|
@ -57,10 +261,105 @@ export function EntitySearchInputComponent({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectOption = (option: EntitySearchResult) => {
|
||||||
|
handleSelect(option[valueField], option);
|
||||||
|
setSelectOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
|
||||||
|
const componentHeight = style?.height;
|
||||||
|
const inputStyle: React.CSSProperties = componentHeight ? { height: componentHeight } : {};
|
||||||
|
|
||||||
|
// select 모드: 검색 가능한 드롭다운
|
||||||
|
if (mode === "select") {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||||
|
{/* 라벨 렌더링 */}
|
||||||
|
{component?.label && component?.style?.labelDisplay !== false && (
|
||||||
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
|
{component.label}
|
||||||
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Popover open={selectOpen} onOpenChange={setSelectOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={selectOpen}
|
||||||
|
disabled={disabled || isLoading || Boolean(shouldApplyCascading && !parentValue)}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between font-normal",
|
||||||
|
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
|
||||||
|
!value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "로딩 중..."
|
||||||
|
: shouldApplyCascading && !parentValue
|
||||||
|
? "상위 항목을 먼저 선택하세요"
|
||||||
|
: displayValue || placeholder}
|
||||||
|
<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={`${displayField} 검색...`} className="text-xs sm:text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs sm:text-sm">항목을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{effectiveOptions.map((option, index) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option[valueField] || index}
|
||||||
|
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
||||||
|
onSelect={() => handleSelectOption(option)}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", value === option[valueField] ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{option[displayField]}</span>
|
||||||
|
{valueField !== displayField && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* 추가 정보 표시 */}
|
||||||
|
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||||
|
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
|
||||||
|
{additionalFields.map((field) => (
|
||||||
|
<div key={field} className="flex gap-2">
|
||||||
|
<span className="font-medium">{field}:</span>
|
||||||
|
<span>{selectedData[field] || "-"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// modal, combo, autocomplete 모드
|
||||||
|
return (
|
||||||
|
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||||
|
{/* 라벨 렌더링 */}
|
||||||
|
{component?.label && component?.style?.labelDisplay !== false && (
|
||||||
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
|
{component.label}
|
||||||
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
{/* 입력 필드 */}
|
{/* 입력 필드 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex h-full gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
|
|
@ -68,7 +367,8 @@ export function EntitySearchInputComponent({
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={mode === "modal" || mode === "combo"}
|
readOnly={mode === "modal" || mode === "combo"}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
|
className={cn("w-full pr-8", !componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
||||||
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
{displayValue && !disabled && (
|
{displayValue && !disabled && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -76,19 +376,21 @@ export function EntitySearchInputComponent({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
|
||||||
{(mode === "modal" || mode === "combo") && (
|
{(mode === "modal" || mode === "combo") && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenModal}
|
onClick={handleOpenModal}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
||||||
|
style={inputStyle}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -97,7 +399,7 @@ export function EntitySearchInputComponent({
|
||||||
|
|
||||||
{/* 추가 정보 표시 */}
|
{/* 추가 정보 표시 */}
|
||||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||||
<div className="text-xs text-muted-foreground space-y-1 px-2">
|
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
|
||||||
{additionalFields.map((field) => (
|
{additionalFields.map((field) => (
|
||||||
<div key={field} className="flex gap-2">
|
<div key={field} className="flex gap-2">
|
||||||
<span className="font-medium">{field}:</span>
|
<span className="font-medium">{field}:</span>
|
||||||
|
|
@ -107,7 +409,8 @@ export function EntitySearchInputComponent({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 검색 모달 */}
|
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
|
||||||
|
{(mode === "modal" || mode === "combo") && (
|
||||||
<EntitySearchModal
|
<EntitySearchModal
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onOpenChange={setModalOpen}
|
onOpenChange={setModalOpen}
|
||||||
|
|
@ -120,7 +423,7 @@ export function EntitySearchInputComponent({
|
||||||
modalColumns={modalColumns}
|
modalColumns={modalColumns}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -8,19 +8,27 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
|
||||||
|
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
|
||||||
import { EntitySearchInputConfig } from "./config";
|
import { EntitySearchInputConfig } from "./config";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface EntitySearchInputConfigPanelProps {
|
interface EntitySearchInputConfigPanelProps {
|
||||||
config: EntitySearchInputConfig;
|
config: EntitySearchInputConfig;
|
||||||
onConfigChange: (config: EntitySearchInputConfig) => void;
|
onConfigChange: (config: EntitySearchInputConfig) => void;
|
||||||
|
currentComponent?: any; // 테이블 패널에서 드래그한 컴포넌트 정보
|
||||||
|
allComponents?: any[]; // 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntitySearchInputConfigPanel({
|
export function EntitySearchInputConfigPanel({
|
||||||
config,
|
config,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
|
currentComponent,
|
||||||
|
allComponents = [],
|
||||||
}: EntitySearchInputConfigPanelProps) {
|
}: EntitySearchInputConfigPanelProps) {
|
||||||
const [localConfig, setLocalConfig] = useState(config);
|
const [localConfig, setLocalConfig] = useState(config);
|
||||||
const [allTables, setAllTables] = useState<any[]>([]);
|
const [allTables, setAllTables] = useState<any[]>([]);
|
||||||
|
|
@ -31,7 +39,151 @@ export function EntitySearchInputConfigPanel({
|
||||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||||
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
||||||
|
|
||||||
// 전체 테이블 목록 로드
|
// 연쇄 드롭다운 설정 상태 (SelectBasicConfigPanel과 동일)
|
||||||
|
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||||
|
|
||||||
|
// 연쇄관계 목록
|
||||||
|
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||||
|
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 타입 관리에서 설정된 참조 테이블 정보
|
||||||
|
const [referenceInfo, setReferenceInfo] = useState<{
|
||||||
|
referenceTable: string;
|
||||||
|
referenceColumn: string;
|
||||||
|
displayColumn: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAutoLoaded: boolean; // 자동 로드되었는지 여부
|
||||||
|
error: string | null;
|
||||||
|
}>({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: false,
|
||||||
|
isAutoLoaded: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 설정 완료 여부 (중복 방지)
|
||||||
|
const autoConfigApplied = useRef(false);
|
||||||
|
|
||||||
|
// 테이블 패널에서 드래그한 컴포넌트인 경우, 참조 테이블 정보 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadReferenceInfo = async () => {
|
||||||
|
// currentComponent에서 소스 테이블/컬럼 정보 추출
|
||||||
|
const sourceTableName = currentComponent?.tableName || currentComponent?.sourceTableName;
|
||||||
|
const sourceColumnName = currentComponent?.columnName || currentComponent?.sourceColumnName;
|
||||||
|
|
||||||
|
if (!sourceTableName || !sourceColumnName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 config에 테이블명이 설정되어 있고, 자동 로드가 완료되었다면 스킵
|
||||||
|
if (config.tableName && autoConfigApplied.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setReferenceInfo(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 테이블 타입 관리에서 컬럼 정보 조회
|
||||||
|
const columns = await tableTypeApi.getColumns(sourceTableName);
|
||||||
|
const columnInfo = columns.find((col: any) =>
|
||||||
|
(col.columnName || col.column_name) === sourceColumnName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (columnInfo) {
|
||||||
|
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
|
||||||
|
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
|
||||||
|
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
|
||||||
|
|
||||||
|
// detailSettings에서도 정보 확인 (JSON 파싱)
|
||||||
|
let detailSettings: any = {};
|
||||||
|
if (columnInfo.detailSettings) {
|
||||||
|
try {
|
||||||
|
if (typeof columnInfo.detailSettings === 'string') {
|
||||||
|
detailSettings = JSON.parse(columnInfo.detailSettings);
|
||||||
|
} else {
|
||||||
|
detailSettings = columnInfo.detailSettings;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON 파싱 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalRefTable = refTable || detailSettings.referenceTable || "";
|
||||||
|
const finalRefColumn = refColumn || detailSettings.referenceColumn || "id";
|
||||||
|
const finalDispColumn = dispColumn || detailSettings.displayColumn || "name";
|
||||||
|
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: finalRefTable,
|
||||||
|
referenceColumn: finalRefColumn,
|
||||||
|
displayColumn: finalDispColumn,
|
||||||
|
isLoading: false,
|
||||||
|
isAutoLoaded: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 참조 테이블 정보로 config 자동 설정 (config에 아직 설정이 없는 경우만)
|
||||||
|
if (finalRefTable && !config.tableName) {
|
||||||
|
autoConfigApplied.current = true;
|
||||||
|
const newConfig: EntitySearchInputConfig = {
|
||||||
|
...localConfig,
|
||||||
|
tableName: finalRefTable,
|
||||||
|
valueField: finalRefColumn,
|
||||||
|
displayField: finalDispColumn,
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: false,
|
||||||
|
isAutoLoaded: false,
|
||||||
|
error: "컬럼 정보를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("참조 테이블 정보 로드 실패:", error);
|
||||||
|
setReferenceInfo({
|
||||||
|
referenceTable: "",
|
||||||
|
referenceColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
isLoading: false,
|
||||||
|
isAutoLoaded: false,
|
||||||
|
error: "참조 테이블 정보 로드 실패",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadReferenceInfo();
|
||||||
|
}, [currentComponent?.tableName, currentComponent?.columnName, currentComponent?.sourceTableName, currentComponent?.sourceColumnName]);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (cascadingEnabled && relationList.length === 0) {
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
}, [cascadingEnabled]);
|
||||||
|
|
||||||
|
// 연쇄 관계 목록 로드 함수
|
||||||
|
const loadRelationList = async () => {
|
||||||
|
setLoadingRelations(true);
|
||||||
|
try {
|
||||||
|
const response = await cascadingRelationApi.getList("Y");
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setRelationList(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingRelations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 테이블 목록 로드 (수동 선택을 위해)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
setIsLoadingTables(true);
|
setIsLoadingTables(true);
|
||||||
|
|
@ -73,8 +225,11 @@ export function EntitySearchInputConfigPanel({
|
||||||
loadColumns();
|
loadColumns();
|
||||||
}, [localConfig.tableName]);
|
}, [localConfig.tableName]);
|
||||||
|
|
||||||
|
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalConfig(config);
|
setLocalConfig(config);
|
||||||
|
// 연쇄 드롭다운 설정 동기화
|
||||||
|
setCascadingEnabled(!!config.cascadingRelationCode);
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
|
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
|
||||||
|
|
@ -83,6 +238,71 @@ export function EntitySearchInputConfigPanel({
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연쇄 드롭다운 활성화/비활성화
|
||||||
|
const handleCascadingToggle = (enabled: boolean) => {
|
||||||
|
setCascadingEnabled(enabled);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// 비활성화 시 관계 설정 제거
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
cascadingRelationCode: undefined,
|
||||||
|
cascadingRole: undefined,
|
||||||
|
cascadingParentField: undefined,
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
} else {
|
||||||
|
// 활성화 시 관계 목록 로드
|
||||||
|
loadRelationList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연쇄 관계 선택 (역할은 별도 선택)
|
||||||
|
const handleRelationSelect = (code: string) => {
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
cascadingRelationCode: code || undefined,
|
||||||
|
cascadingRole: undefined, // 역할은 별도로 선택
|
||||||
|
cascadingParentField: undefined,
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 역할 변경 핸들러
|
||||||
|
const handleRoleChange = (role: "parent" | "child") => {
|
||||||
|
const selectedRel = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
|
||||||
|
|
||||||
|
if (role === "parent" && selectedRel) {
|
||||||
|
// 부모 역할: 부모 테이블 정보로 설정
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
cascadingRole: role,
|
||||||
|
tableName: selectedRel.parent_table,
|
||||||
|
valueField: selectedRel.parent_value_column,
|
||||||
|
displayField: selectedRel.parent_label_column || selectedRel.parent_value_column,
|
||||||
|
cascadingParentField: undefined, // 부모 역할이면 부모 필드 필요 없음
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
} else if (role === "child" && selectedRel) {
|
||||||
|
// 자식 역할: 자식 테이블 정보로 설정
|
||||||
|
const newConfig = {
|
||||||
|
...localConfig,
|
||||||
|
cascadingRole: role,
|
||||||
|
tableName: selectedRel.child_table,
|
||||||
|
valueField: selectedRel.child_value_column,
|
||||||
|
displayField: selectedRel.child_label_column || selectedRel.child_value_column,
|
||||||
|
};
|
||||||
|
setLocalConfig(newConfig);
|
||||||
|
onConfigChange(newConfig);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 관계 정보
|
||||||
|
const selectedRelation = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
|
||||||
|
|
||||||
const addSearchField = () => {
|
const addSearchField = () => {
|
||||||
const fields = localConfig.searchFields || [];
|
const fields = localConfig.searchFields || [];
|
||||||
updateConfig({ searchFields: [...fields, ""] });
|
updateConfig({ searchFields: [...fields, ""] });
|
||||||
|
|
@ -134,10 +354,213 @@ export function EntitySearchInputConfigPanel({
|
||||||
updateConfig({ additionalFields: fields });
|
updateConfig({ additionalFields: fields });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 자동 로드된 참조 테이블 정보가 있는지 확인
|
||||||
|
const hasAutoReference = referenceInfo.isAutoLoaded && referenceInfo.referenceTable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 연쇄 드롭다운 설정 - SelectConfigPanel과 동일한 패턴 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
<h4 className="text-sm font-medium">연쇄 드롭다운</h4>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={cascadingEnabled}
|
||||||
|
onCheckedChange={handleCascadingToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
다른 필드의 값에 따라 옵션이 동적으로 변경됩니다. (예: 창고 선택 → 해당 창고의 위치만 표시)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{cascadingEnabled && (
|
||||||
|
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
|
||||||
|
{/* 관계 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
<Label className="text-xs">연쇄 관계 선택</Label>
|
||||||
|
<Select
|
||||||
|
value={localConfig.cascadingRelationCode || ""}
|
||||||
|
onValueChange={handleRelationSelect}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="text-xs">
|
||||||
|
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{relationList.map((relation) => (
|
||||||
|
<SelectItem key={relation.relation_code} value={relation.relation_code}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{relation.relation_name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{relation.parent_table} → {relation.child_table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 역할 선택 */}
|
||||||
|
{localConfig.cascadingRelationCode && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">역할 선택</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={localConfig.cascadingRole === "parent" ? "default" : "outline"}
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => handleRoleChange("parent")}
|
||||||
|
>
|
||||||
|
부모 (상위 선택)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={localConfig.cascadingRole === "child" ? "default" : "outline"}
|
||||||
|
className="flex-1 text-xs"
|
||||||
|
onClick={() => handleRoleChange("child")}
|
||||||
|
>
|
||||||
|
자식 (하위 선택)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{localConfig.cascadingRole === "parent"
|
||||||
|
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
|
||||||
|
: localConfig.cascadingRole === "child"
|
||||||
|
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
|
||||||
|
: "이 필드의 역할을 선택하세요."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 부모 필드 설정 (자식 역할일 때만) */}
|
||||||
|
{localConfig.cascadingRelationCode && localConfig.cascadingRole === "child" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">부모 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={localConfig.cascadingParentField || ""}
|
||||||
|
onChange={(e) => updateConfig({ cascadingParentField: e.target.value || undefined })}
|
||||||
|
placeholder="예: warehouse_code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
이 드롭다운의 옵션을 결정할 부모 필드의 컬럼명을 입력하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 관계 정보 표시 */}
|
||||||
|
{selectedRelation && localConfig.cascadingRole && (
|
||||||
|
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
|
||||||
|
{localConfig.cascadingRole === "parent" ? (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-blue-600">부모 역할 (상위 선택)</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_table}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.parent_value_column}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-medium text-green-600">자식 역할 (하위 선택)</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">데이터 소스:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_table}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">저장 값:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_value_column}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">필터 컬럼:</span>{" "}
|
||||||
|
<span className="font-medium">{selectedRelation.child_filter_column}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 관계 관리 페이지 링크 */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/admin/cascading-relations" target="_blank">
|
||||||
|
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
|
||||||
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
|
관계 관리
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 - 연쇄 드롭다운 비활성화 시에만 표시 */}
|
||||||
|
{!cascadingEnabled && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-4">
|
||||||
|
아래에서 직접 테이블/필드를 설정하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 참조 테이블 자동 로드 정보 표시 */}
|
||||||
|
{referenceInfo.isLoading && (
|
||||||
|
<div className="bg-muted/50 rounded-md border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">참조 테이블 정보 로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasAutoReference && !cascadingEnabled && (
|
||||||
|
<div className="bg-primary/5 rounded-md border border-primary/20 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-xs font-medium text-primary">테이블 타입에서 자동 설정됨</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">참조 테이블:</span>
|
||||||
|
<div className="font-medium">{referenceInfo.referenceTable}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">값 필드:</span>
|
||||||
|
<div className="font-medium">{referenceInfo.referenceColumn || "id"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">표시 필드:</span>
|
||||||
|
<div className="font-medium">{referenceInfo.displayColumn || "name"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
소스: {currentComponent?.tableName}.{currentComponent?.columnName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{referenceInfo.error && !hasAutoReference && !cascadingEnabled && (
|
||||||
|
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
|
||||||
|
<p className="text-xs text-amber-700 flex items-center gap-1">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
{referenceInfo.error}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
테이블을 수동으로 선택하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
테이블명 *
|
||||||
|
{hasAutoReference && (
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-2">(자동 설정됨)</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -302,7 +725,7 @@ export function EntitySearchInputConfigPanel({
|
||||||
<Label className="text-xs sm:text-sm">UI 모드</Label>
|
<Label className="text-xs sm:text-sm">UI 모드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={localConfig.mode || "combo"}
|
value={localConfig.mode || "combo"}
|
||||||
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
|
onValueChange={(value: "select" | "autocomplete" | "modal" | "combo") =>
|
||||||
updateConfig({ mode: value })
|
updateConfig({ mode: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -310,11 +733,18 @@ export function EntitySearchInputConfigPanel({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="select">드롭다운 (검색 가능)</SelectItem>
|
||||||
<SelectItem value="combo">콤보 (입력 + 모달)</SelectItem>
|
<SelectItem value="combo">콤보 (입력 + 모달)</SelectItem>
|
||||||
<SelectItem value="modal">모달만</SelectItem>
|
<SelectItem value="modal">모달만</SelectItem>
|
||||||
<SelectItem value="autocomplete">자동완성만</SelectItem>
|
<SelectItem value="autocomplete">자동완성만</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{localConfig.mode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
|
||||||
|
{localConfig.mode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
|
||||||
|
{(localConfig.mode === "combo" || !localConfig.mode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
|
||||||
|
{localConfig.mode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@ export interface EntitySearchInputConfig {
|
||||||
valueField: string;
|
valueField: string;
|
||||||
searchFields?: string[];
|
searchFields?: string[];
|
||||||
filterCondition?: Record<string, any>;
|
filterCondition?: Record<string, any>;
|
||||||
mode?: "autocomplete" | "modal" | "combo";
|
mode?: "select" | "autocomplete" | "modal" | "combo";
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
modalTitle?: string;
|
modalTitle?: string;
|
||||||
modalColumns?: string[];
|
modalColumns?: string[];
|
||||||
showAdditionalInfo?: boolean;
|
showAdditionalInfo?: boolean;
|
||||||
additionalFields?: string[];
|
additionalFields?: string[];
|
||||||
|
|
||||||
|
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
|
||||||
|
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
||||||
|
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||||
|
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ export interface EntitySearchInputProps {
|
||||||
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
|
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
|
||||||
|
|
||||||
// UI 모드
|
// UI 모드
|
||||||
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
|
// - select: 드롭다운 선택 (검색 가능한 콤보박스)
|
||||||
|
// - modal: 모달 팝업에서 선택
|
||||||
|
// - combo: 입력 + 모달 버튼 (기본)
|
||||||
|
// - autocomplete: 입력하면서 자동완성
|
||||||
|
mode?: "select" | "autocomplete" | "modal" | "combo"; // 기본: "combo"
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
||||||
|
|
@ -19,6 +23,13 @@ export interface EntitySearchInputProps {
|
||||||
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
||||||
companyCode?: string; // 멀티테넌시
|
companyCode?: string; // 멀티테넌시
|
||||||
|
|
||||||
|
// 연쇄관계 설정
|
||||||
|
cascadingRelationCode?: string; // 연쇄관계 코드 (cascading_relation 테이블)
|
||||||
|
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||||
|
parentFieldId?: string; // 부모 필드의 컬럼명 (자식 역할일 때, formData에서 값 추출용)
|
||||||
|
parentValue?: any; // 부모 필드의 현재 값 (직접 전달)
|
||||||
|
formData?: Record<string, any>; // 전체 폼 데이터 (부모 값 추출용)
|
||||||
|
|
||||||
// 선택된 값
|
// 선택된 값
|
||||||
value?: any;
|
value?: any;
|
||||||
onChange?: (value: any, fullData?: any) => void;
|
onChange?: (value: any, fullData?: any) => void;
|
||||||
|
|
@ -33,6 +44,7 @@ export interface EntitySearchInputProps {
|
||||||
|
|
||||||
// 스타일
|
// 스타일
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntitySearchResult {
|
export interface EntitySearchResult {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,9 @@ import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록,
|
||||||
// 🆕 메일 수신자 선택 컴포넌트
|
// 🆕 메일 수신자 선택 컴포넌트
|
||||||
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
|
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
|
||||||
|
|
||||||
|
// 🆕 연관 데이터 버튼 컴포넌트
|
||||||
|
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Columns } from "lucide-react";
|
||||||
import { ItemSelectionModal } from "./ItemSelectionModal";
|
import { ItemSelectionModal } from "./ItemSelectionModal";
|
||||||
import { RepeaterTable } from "./RepeaterTable";
|
import { RepeaterTable } from "./RepeaterTable";
|
||||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
|
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
|
||||||
|
|
@ -328,6 +328,12 @@ export function ModalRepeaterTableComponent({
|
||||||
const companyCode = componentConfig?.companyCode || propCompanyCode;
|
const companyCode = componentConfig?.companyCode || propCompanyCode;
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 체크박스 선택 상태
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// 균등 분배 트리거 (값이 변경되면 RepeaterTable에서 균등 분배 실행)
|
||||||
|
const [equalizeWidthsTrigger, setEqualizeWidthsTrigger] = useState(0);
|
||||||
|
|
||||||
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -794,6 +800,18 @@ export function ModalRepeaterTableComponent({
|
||||||
handleChange(newData);
|
handleChange(newData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 선택된 항목 일괄 삭제 핸들러
|
||||||
|
const handleBulkDelete = () => {
|
||||||
|
if (selectedRows.size === 0) return;
|
||||||
|
|
||||||
|
// 선택되지 않은 항목만 남김
|
||||||
|
const newData = localValue.filter((_, index) => !selectedRows.has(index));
|
||||||
|
|
||||||
|
// 데이터 업데이트 및 선택 상태 초기화
|
||||||
|
handleChange(newData);
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
|
// 컬럼명 -> 라벨명 매핑 생성 (sourceColumnLabels 우선, 없으면 columns에서 가져옴)
|
||||||
const columnLabels = columns.reduce((acc, col) => {
|
const columnLabels = columns.reduce((acc, col) => {
|
||||||
// sourceColumnLabels에 정의된 라벨 우선 사용
|
// sourceColumnLabels에 정의된 라벨 우선 사용
|
||||||
|
|
@ -805,9 +823,34 @@ export function ModalRepeaterTableComponent({
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
{localValue.length > 0 && `${localValue.length}개 항목`}
|
{localValue.length > 0 && `${localValue.length}개 항목`}
|
||||||
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
||||||
|
</span>
|
||||||
|
{columns.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEqualizeWidthsTrigger((prev) => prev + 1)}
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
title="컬럼 너비 균등 분배"
|
||||||
|
>
|
||||||
|
<Columns className="h-3.5 w-3.5 mr-1" />
|
||||||
|
균등 분배
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
선택 삭제 ({selectedRows.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={() => setModalOpen(true)}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
|
@ -816,6 +859,7 @@ export function ModalRepeaterTableComponent({
|
||||||
{modalButtonText}
|
{modalButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Repeater 테이블 */}
|
{/* Repeater 테이블 */}
|
||||||
<RepeaterTable
|
<RepeaterTable
|
||||||
|
|
@ -826,6 +870,9 @@ export function ModalRepeaterTableComponent({
|
||||||
onRowDelete={handleRowDelete}
|
onRowDelete={handleRowDelete}
|
||||||
activeDataSources={activeDataSources}
|
activeDataSources={activeDataSources}
|
||||||
onDataSourceChange={handleDataSourceChange}
|
onDataSourceChange={handleDataSourceChange}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onSelectionChange={setSelectedRows}
|
||||||
|
equalizeWidthsTrigger={equalizeWidthsTrigger}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 선택 모달 */}
|
{/* 항목 선택 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,68 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Trash2, ChevronDown, Check } from "lucide-react";
|
import { ChevronDown, Check, GripVertical } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RepeaterColumnConfig } from "./types";
|
import { RepeaterColumnConfig } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// @dnd-kit imports
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
arrayMove,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
|
||||||
|
interface SortableRowProps {
|
||||||
|
id: string;
|
||||||
|
children: (props: {
|
||||||
|
attributes: React.HTMLAttributes<HTMLElement>;
|
||||||
|
listeners: React.HTMLAttributes<HTMLElement> | undefined;
|
||||||
|
isDragging: boolean;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRow({ id, children, className }: SortableRowProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
backgroundColor: isDragging ? "#f0f9ff" : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr ref={setNodeRef} style={style} className={className}>
|
||||||
|
{children({ attributes, listeners, isDragging })}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface RepeaterTableProps {
|
interface RepeaterTableProps {
|
||||||
columns: RepeaterColumnConfig[];
|
columns: RepeaterColumnConfig[];
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -18,6 +72,11 @@ interface RepeaterTableProps {
|
||||||
// 동적 데이터 소스 관련
|
// 동적 데이터 소스 관련
|
||||||
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
|
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
|
||||||
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
|
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
|
||||||
|
// 체크박스 선택 관련
|
||||||
|
selectedRows: Set<number>; // 선택된 행 인덱스
|
||||||
|
onSelectionChange: (selectedRows: Set<number>) => void; // 선택 변경 콜백
|
||||||
|
// 균등 분배 트리거
|
||||||
|
equalizeWidthsTrigger?: number; // 값이 변경되면 균등 분배 실행
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterTable({
|
export function RepeaterTable({
|
||||||
|
|
@ -28,7 +87,60 @@ export function RepeaterTable({
|
||||||
onRowDelete,
|
onRowDelete,
|
||||||
activeDataSources = {},
|
activeDataSources = {},
|
||||||
onDataSourceChange,
|
onDataSourceChange,
|
||||||
|
selectedRows,
|
||||||
|
onSelectionChange,
|
||||||
|
equalizeWidthsTrigger,
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
|
// 컨테이너 ref - 실제 너비 측정용
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
|
||||||
|
const [isEqualizedMode, setIsEqualizedMode] = useState(false);
|
||||||
|
|
||||||
|
// DnD 센서 설정
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8, // 8px 이동해야 드래그 시작 (클릭과 구분)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 드래그 종료 핸들러
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = data.findIndex((_, idx) => `row-${idx}` === active.id);
|
||||||
|
const newIndex = data.findIndex((_, idx) => `row-${idx}` === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const newData = arrayMove(data, oldIndex, newIndex);
|
||||||
|
onDataChange(newData);
|
||||||
|
|
||||||
|
// 선택된 행 인덱스도 업데이트
|
||||||
|
if (selectedRows.size > 0) {
|
||||||
|
const newSelectedRows = new Set<number>();
|
||||||
|
selectedRows.forEach((oldIdx) => {
|
||||||
|
if (oldIdx === oldIndex) {
|
||||||
|
newSelectedRows.add(newIndex);
|
||||||
|
} else if (oldIdx > oldIndex && oldIdx <= newIndex) {
|
||||||
|
newSelectedRows.add(oldIdx - 1);
|
||||||
|
} else if (oldIdx < oldIndex && oldIdx >= newIndex) {
|
||||||
|
newSelectedRows.add(oldIdx + 1);
|
||||||
|
} else {
|
||||||
|
newSelectedRows.add(oldIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onSelectionChange(newSelectedRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useState<{
|
const [editingCell, setEditingCell] = useState<{
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
field: string;
|
field: string;
|
||||||
|
|
@ -66,16 +178,101 @@ export function RepeaterTable({
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startWidth: columnWidths[field] || 120,
|
startWidth: columnWidths[field] || 120,
|
||||||
});
|
});
|
||||||
|
// 수동 조정 시 균등 분배 모드 해제
|
||||||
|
setIsEqualizedMode(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 더블클릭으로 기본 너비로 리셋
|
// 컬럼 확장 상태 추적 (토글용)
|
||||||
|
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 데이터 기준 최적 너비 계산
|
||||||
|
const calculateAutoFitWidth = (field: string): number => {
|
||||||
|
const column = columns.find(col => col.field === field);
|
||||||
|
if (!column) return 120;
|
||||||
|
|
||||||
|
// 헤더 텍스트 길이 (대략 8px per character + padding)
|
||||||
|
const headerWidth = (column.label?.length || field.length) * 8 + 40;
|
||||||
|
|
||||||
|
// 데이터 중 가장 긴 텍스트 찾기
|
||||||
|
let maxDataWidth = 0;
|
||||||
|
data.forEach(row => {
|
||||||
|
const value = row[field];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
let displayText = String(value);
|
||||||
|
|
||||||
|
// 숫자는 천단위 구분자 포함
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
displayText = value.toLocaleString();
|
||||||
|
}
|
||||||
|
// 날짜는 yyyy-mm-dd 형식
|
||||||
|
if (column.type === 'date' && displayText.includes('T')) {
|
||||||
|
displayText = displayText.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대략적인 너비 계산 (8px per character + padding)
|
||||||
|
const textWidth = displayText.length * 8 + 32;
|
||||||
|
maxDataWidth = Math.max(maxDataWidth, textWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
|
||||||
|
const optimalWidth = Math.max(headerWidth, maxDataWidth);
|
||||||
|
return Math.min(Math.max(optimalWidth, 60), 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 더블클릭으로 auto-fit / 기본 너비 토글
|
||||||
const handleDoubleClick = (field: string) => {
|
const handleDoubleClick = (field: string) => {
|
||||||
setColumnWidths((prev) => ({
|
// 개별 컬럼 조정 시 균등 분배 모드 해제
|
||||||
...prev,
|
setIsEqualizedMode(false);
|
||||||
|
|
||||||
|
setExpandedColumns(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(field)) {
|
||||||
|
// 확장 상태 → 기본 너비로 복구
|
||||||
|
newSet.delete(field);
|
||||||
|
setColumnWidths(prevWidths => ({
|
||||||
|
...prevWidths,
|
||||||
[field]: defaultWidths[field] || 120,
|
[field]: defaultWidths[field] || 120,
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
// 기본 상태 → 데이터 기준 auto-fit
|
||||||
|
newSet.add(field);
|
||||||
|
const autoWidth = calculateAutoFitWidth(field);
|
||||||
|
setColumnWidths(prevWidths => ({
|
||||||
|
...prevWidths,
|
||||||
|
[field]: autoWidth,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 균등 분배 트리거 감지
|
||||||
|
useEffect(() => {
|
||||||
|
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
// 실제 컨테이너 너비 측정
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
|
||||||
|
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
|
||||||
|
const checkboxColumnWidth = 40;
|
||||||
|
const borderWidth = 2;
|
||||||
|
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
|
||||||
|
|
||||||
|
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
|
||||||
|
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
||||||
|
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
columns.forEach((col) => {
|
||||||
|
newWidths[col.field] = equalWidth;
|
||||||
|
});
|
||||||
|
|
||||||
|
setColumnWidths(newWidths);
|
||||||
|
setExpandedColumns(new Set()); // 확장 상태 초기화
|
||||||
|
setIsEqualizedMode(true); // 균등 분배 모드 활성화
|
||||||
|
}, [equalizeWidthsTrigger, columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
|
|
||||||
|
|
@ -112,6 +309,33 @@ export function RepeaterTable({
|
||||||
onRowChange(rowIndex, newRow);
|
onRowChange(rowIndex, newRow);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 전체 선택 체크박스 핸들러
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
// 모든 행 선택
|
||||||
|
const allIndices = new Set(data.map((_, index) => index));
|
||||||
|
onSelectionChange(allIndices);
|
||||||
|
} else {
|
||||||
|
// 전체 해제
|
||||||
|
onSelectionChange(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개별 행 선택 핸들러
|
||||||
|
const handleRowSelect = (rowIndex: number, checked: boolean) => {
|
||||||
|
const newSelection = new Set(selectedRows);
|
||||||
|
if (checked) {
|
||||||
|
newSelection.add(rowIndex);
|
||||||
|
} else {
|
||||||
|
newSelection.delete(rowIndex);
|
||||||
|
}
|
||||||
|
onSelectionChange(newSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택 상태 계산
|
||||||
|
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
|
||||||
|
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
|
||||||
|
|
||||||
const renderCell = (
|
const renderCell = (
|
||||||
row: any,
|
row: any,
|
||||||
column: RepeaterColumnConfig,
|
column: RepeaterColumnConfig,
|
||||||
|
|
@ -123,12 +347,23 @@ export function RepeaterTable({
|
||||||
|
|
||||||
// 계산 필드는 편집 불가
|
// 계산 필드는 편집 불가
|
||||||
if (column.calculated || !column.editable) {
|
if (column.calculated || !column.editable) {
|
||||||
|
// 숫자 포맷팅 함수: 정수/소수점 자동 구분
|
||||||
|
const formatNumber = (val: any): string => {
|
||||||
|
if (val === undefined || val === null || val === "") return "0";
|
||||||
|
const num = typeof val === "number" ? val : parseFloat(val);
|
||||||
|
if (isNaN(num)) return "0";
|
||||||
|
// 정수면 소수점 없이, 소수면 소수점 유지
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
return num.toLocaleString("ko-KR");
|
||||||
|
} else {
|
||||||
|
return num.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-2 py-1">
|
<div className="px-2 py-1">
|
||||||
{column.type === "number"
|
{column.type === "number"
|
||||||
? typeof value === "number"
|
? formatNumber(value)
|
||||||
? value.toLocaleString()
|
|
||||||
: value || "0"
|
|
||||||
: value || "-"}
|
: value || "-"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -137,14 +372,27 @@ export function RepeaterTable({
|
||||||
// 편집 가능한 필드
|
// 편집 가능한 필드
|
||||||
switch (column.type) {
|
switch (column.type) {
|
||||||
case "number":
|
case "number":
|
||||||
|
// 숫자 표시: 정수/소수점 자동 구분
|
||||||
|
const displayValue = (() => {
|
||||||
|
if (value === undefined || value === null || value === "") return "";
|
||||||
|
const num = typeof value === "number" ? value : parseFloat(value);
|
||||||
|
if (isNaN(num)) return "";
|
||||||
|
// 정수면 소수점 없이, 소수면 소수점 유지
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
return num.toString();
|
||||||
|
} else {
|
||||||
|
return num.toString();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={value || ""}
|
value={displayValue}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||||
}
|
}
|
||||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -172,7 +420,7 @@ export function RepeaterTable({
|
||||||
type="date"
|
type="date"
|
||||||
value={formatDateValue(value)}
|
value={formatDateValue(value)}
|
||||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -184,7 +432,7 @@ export function RepeaterTable({
|
||||||
handleCellEdit(rowIndex, column.field, newValue)
|
handleCellEdit(rowIndex, column.field, newValue)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
|
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -203,20 +451,48 @@ export function RepeaterTable({
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
|
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 아이템 ID 목록
|
||||||
|
const sortableItems = data.map((_, idx) => `row-${idx}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-gray-200 bg-white">
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div ref={containerRef} className="border border-gray-200 bg-white">
|
||||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table
|
||||||
|
className={cn(
|
||||||
|
"text-xs border-collapse",
|
||||||
|
isEqualizedMode && "w-full"
|
||||||
|
)}
|
||||||
|
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
|
||||||
|
>
|
||||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
<thead className="bg-gray-50 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12">
|
{/* 드래그 핸들 헤더 */}
|
||||||
#
|
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
|
||||||
|
<span className="sr-only">순서</span>
|
||||||
|
</th>
|
||||||
|
{/* 체크박스 헤더 */}
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
// @ts-ignore - indeterminate는 HTML 속성
|
||||||
|
data-indeterminate={isIndeterminate}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
className={cn(
|
||||||
|
"border-gray-400",
|
||||||
|
isIndeterminate && "data-[state=checked]:bg-primary"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</th>
|
</th>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||||
|
|
@ -225,13 +501,15 @@ export function RepeaterTable({
|
||||||
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const isExpanded = expandedColumns.has(col.field);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.field}
|
key={col.field}
|
||||||
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
|
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
|
||||||
style={{ width: `${columnWidths[col.field]}px` }}
|
style={{ width: `${columnWidths[col.field]}px` }}
|
||||||
onDoubleClick={() => handleDoubleClick(col.field)}
|
onDoubleClick={() => handleDoubleClick(col.field)}
|
||||||
title="더블클릭하여 기본 너비로 되돌리기"
|
title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between pointer-events-none">
|
<div className="flex items-center justify-between pointer-events-none">
|
||||||
<div className="flex items-center gap-1 pointer-events-auto">
|
<div className="flex items-center gap-1 pointer-events-auto">
|
||||||
|
|
@ -303,11 +581,9 @@ export function RepeaterTable({
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
|
|
||||||
삭제
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
||||||
<tbody className="bg-white">
|
<tbody className="bg-white">
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -320,32 +596,59 @@ export function RepeaterTable({
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
data.map((row, rowIndex) => (
|
data.map((row, rowIndex) => (
|
||||||
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors">
|
<SortableRow
|
||||||
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200">
|
key={`row-${rowIndex}`}
|
||||||
{rowIndex + 1}
|
id={`row-${rowIndex}`}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-blue-50/50 transition-colors",
|
||||||
|
selectedRows.has(rowIndex) && "bg-blue-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ attributes, listeners, isDragging }) => (
|
||||||
|
<>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
|
||||||
|
isDragging && "cursor-grabbing"
|
||||||
|
)}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
{/* 체크박스 */}
|
||||||
|
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(rowIndex)}
|
||||||
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
||||||
|
className="border-gray-400"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
{/* 데이터 컬럼들 */}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
|
<td
|
||||||
|
key={col.field}
|
||||||
|
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden"
|
||||||
|
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }}
|
||||||
|
>
|
||||||
{renderCell(row, col, rowIndex)}
|
{renderCell(row, col, rowIndex)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
</>
|
||||||
<Button
|
)}
|
||||||
variant="ghost"
|
</SortableRow>
|
||||||
size="sm"
|
|
||||||
onClick={() => onRowDelete(rowIndex)}
|
|
||||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
# RelatedDataButtons 컴포넌트
|
||||||
|
|
||||||
|
좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시하는 컴포넌트
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
- **ID**: `related-data-buttons`
|
||||||
|
- **카테고리**: data
|
||||||
|
- **웹타입**: container
|
||||||
|
- **버전**: 1.0.0
|
||||||
|
|
||||||
|
## 사용 사례
|
||||||
|
|
||||||
|
### 품목별 라우팅 버전 관리
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 알루미늄 프레임 [+ 라우팅 버전 추가] │
|
||||||
|
│ ITEM001 │
|
||||||
|
│ ┌──────────────┐ ┌─────────┐ │
|
||||||
|
│ │ 기본 라우팅 ★ │ │ 개선버전 │ │
|
||||||
|
│ └──────────────┘ └─────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 좌측 패널: item_info 선택
|
||||||
|
↓ SplitPanelContext.selectedLeftData
|
||||||
|
2. RelatedDataButtons: item_code로 item_routing_version 조회
|
||||||
|
↓ 버튼 클릭 시 이벤트 발생
|
||||||
|
3. 하위 테이블: routing_version_id로 item_routing_detail 필터링
|
||||||
|
```
|
||||||
|
|
||||||
|
## 설정 옵션
|
||||||
|
|
||||||
|
### 소스 매핑 (sourceMapping)
|
||||||
|
|
||||||
|
| 속성 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| sourceTable | string | 좌측 패널 테이블명 (예: item_info) |
|
||||||
|
| sourceColumn | string | 필터에 사용할 컬럼 (예: item_code) |
|
||||||
|
|
||||||
|
### 헤더 표시 (headerDisplay)
|
||||||
|
|
||||||
|
| 속성 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| show | boolean | 헤더 표시 여부 |
|
||||||
|
| titleColumn | string | 제목으로 표시할 컬럼 (예: item_name) |
|
||||||
|
| subtitleColumn | string | 부제목으로 표시할 컬럼 (예: item_code) |
|
||||||
|
|
||||||
|
### 버튼 데이터 소스 (buttonDataSource)
|
||||||
|
|
||||||
|
| 속성 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| tableName | string | 조회할 테이블명 (예: item_routing_version) |
|
||||||
|
| filterColumn | string | 필터링할 컬럼명 (예: item_code) |
|
||||||
|
| displayColumn | string | 버튼에 표시할 컬럼명 (예: version_name) |
|
||||||
|
| valueColumn | string | 선택 시 전달할 값 컬럼 (기본: id) |
|
||||||
|
| orderColumn | string | 정렬 컬럼 |
|
||||||
|
| orderDirection | "ASC" \| "DESC" | 정렬 방향 |
|
||||||
|
|
||||||
|
### 버튼 스타일 (buttonStyle)
|
||||||
|
|
||||||
|
| 속성 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| variant | string | 기본 버튼 스타일 (default, outline, secondary, ghost) |
|
||||||
|
| activeVariant | string | 선택 시 버튼 스타일 |
|
||||||
|
| size | string | 버튼 크기 (sm, default, lg) |
|
||||||
|
| defaultIndicator.column | string | 기본 버전 판단 컬럼 |
|
||||||
|
| defaultIndicator.showStar | boolean | 별표 아이콘 표시 여부 |
|
||||||
|
|
||||||
|
### 추가 버튼 (addButton)
|
||||||
|
|
||||||
|
| 속성 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| show | boolean | 추가 버튼 표시 여부 |
|
||||||
|
| label | string | 버튼 라벨 |
|
||||||
|
| position | "header" \| "inline" | 버튼 위치 |
|
||||||
|
| modalScreenId | number | 연결할 모달 화면 ID |
|
||||||
|
|
||||||
|
### 이벤트 설정 (events)
|
||||||
|
|
||||||
|
| 속성 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| targetTable | string | 필터링할 하위 테이블명 |
|
||||||
|
| targetFilterColumn | string | 하위 테이블의 필터 컬럼명 |
|
||||||
|
|
||||||
|
## 이벤트
|
||||||
|
|
||||||
|
### related-button-select
|
||||||
|
|
||||||
|
버튼 선택 시 발생하는 커스텀 이벤트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
window.addEventListener("related-button-select", (e: CustomEvent) => {
|
||||||
|
const { targetTable, filterColumn, filterValue, selectedData } = e.detail;
|
||||||
|
// 하위 테이블 필터링 처리
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사용 예시
|
||||||
|
|
||||||
|
### 품목별 라우팅 버전 화면
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config: RelatedDataButtonsConfig = {
|
||||||
|
sourceMapping: {
|
||||||
|
sourceTable: "item_info",
|
||||||
|
sourceColumn: "item_code",
|
||||||
|
},
|
||||||
|
headerDisplay: {
|
||||||
|
show: true,
|
||||||
|
titleColumn: "item_name",
|
||||||
|
subtitleColumn: "item_code",
|
||||||
|
},
|
||||||
|
buttonDataSource: {
|
||||||
|
tableName: "item_routing_version",
|
||||||
|
filterColumn: "item_code",
|
||||||
|
displayColumn: "version_name",
|
||||||
|
valueColumn: "id",
|
||||||
|
},
|
||||||
|
buttonStyle: {
|
||||||
|
variant: "outline",
|
||||||
|
activeVariant: "default",
|
||||||
|
defaultIndicator: {
|
||||||
|
column: "is_default",
|
||||||
|
showStar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
targetTable: "item_routing_detail",
|
||||||
|
targetFilterColumn: "routing_version_id",
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
show: true,
|
||||||
|
label: "+ 라우팅 버전 추가",
|
||||||
|
position: "header",
|
||||||
|
},
|
||||||
|
autoSelectFirst: true,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 분할 패널과 함께 사용
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┬──────────────────────────────────────────────┐
|
||||||
|
│ │ [RelatedDataButtons 컴포넌트] │
|
||||||
|
│ 품목 목록 │ 품목명 표시 + 버전 버튼들 │
|
||||||
|
│ (좌측 패널) ├──────────────────────────────────────────────┤
|
||||||
|
│ │ [DataTable 컴포넌트] │
|
||||||
|
│ item_info │ 공정 순서 테이블 (item_routing_detail) │
|
||||||
|
│ │ related-button-select 이벤트로 필터링 │
|
||||||
|
└─────────────────┴──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 개발자 정보
|
||||||
|
|
||||||
|
- **생성일**: 2024-12
|
||||||
|
- **경로**: `lib/registry/components/related-data-buttons/`
|
||||||
|
|
||||||
|
|
@ -0,0 +1,462 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Star, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import type { RelatedDataButtonsConfig, ButtonItem } from "./types";
|
||||||
|
|
||||||
|
// 전역 상태: 현재 선택된 버튼 데이터를 외부에서 접근 가능하게
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__relatedButtonsSelectedData?: {
|
||||||
|
selectedItem: ButtonItem | null;
|
||||||
|
masterData: Record<string, any> | null;
|
||||||
|
config: RelatedDataButtonsConfig | null;
|
||||||
|
};
|
||||||
|
// 🆕 RelatedDataButtons가 대상으로 하는 테이블 목록 (전역 레지스트리)
|
||||||
|
__relatedButtonsTargetTables?: Set<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 레지스트리 초기화
|
||||||
|
if (typeof window !== "undefined" && !window.__relatedButtonsTargetTables) {
|
||||||
|
window.__relatedButtonsTargetTables = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelatedDataButtonsComponentProps {
|
||||||
|
config: RelatedDataButtonsConfig;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentProps> = ({
|
||||||
|
config,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
const [buttons, setButtons] = useState<ButtonItem[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<ButtonItem | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
|
// SplitPanel Context 연결
|
||||||
|
const splitPanelContext = useSplitPanelContext();
|
||||||
|
|
||||||
|
// 선택된 데이터를 전역 상태에 저장 (외부 버튼에서 접근용)
|
||||||
|
useEffect(() => {
|
||||||
|
window.__relatedButtonsSelectedData = {
|
||||||
|
selectedItem,
|
||||||
|
masterData,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
console.log("🔄 [RelatedDataButtons] 전역 상태 업데이트:", {
|
||||||
|
selectedItem,
|
||||||
|
hasConfig: !!config,
|
||||||
|
modalLink: config?.modalLink,
|
||||||
|
});
|
||||||
|
}, [selectedItem, masterData, config]);
|
||||||
|
|
||||||
|
// 좌측 패널에서 선택된 데이터 감지
|
||||||
|
useEffect(() => {
|
||||||
|
if (!splitPanelContext?.selectedLeftData) {
|
||||||
|
setMasterData(null);
|
||||||
|
setButtons([]);
|
||||||
|
setSelectedId(null);
|
||||||
|
setSelectedItem(null);
|
||||||
|
|
||||||
|
// 🆕 좌측 데이터가 없을 때 대상 테이블에 빈 상태 알림
|
||||||
|
if (config.events?.targetTable) {
|
||||||
|
window.dispatchEvent(new CustomEvent("related-button-select", {
|
||||||
|
detail: {
|
||||||
|
targetTable: config.events.targetTable,
|
||||||
|
filterColumn: config.events.targetFilterColumn,
|
||||||
|
filterValue: null, // null로 설정하여 빈 상태 표시
|
||||||
|
selectedData: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMasterData(splitPanelContext.selectedLeftData);
|
||||||
|
}, [splitPanelContext?.selectedLeftData, config.events]);
|
||||||
|
|
||||||
|
// 🆕 컴포넌트 마운트 시 대상 테이블에 필터 필요 알림
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.events?.targetTable) {
|
||||||
|
// 전역 레지스트리에 등록
|
||||||
|
window.__relatedButtonsTargetTables?.add(config.events.targetTable);
|
||||||
|
|
||||||
|
// 이벤트도 발생 (이미 마운트된 테이블 컴포넌트를 위해)
|
||||||
|
window.dispatchEvent(new CustomEvent("related-button-register", {
|
||||||
|
detail: {
|
||||||
|
targetTable: config.events.targetTable,
|
||||||
|
filterColumn: config.events.targetFilterColumn,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
console.log("📝 [RelatedDataButtons] 대상 테이블에 필터 등록:", config.events.targetTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 컴포넌트 언마운트 시 등록 해제
|
||||||
|
if (config.events?.targetTable) {
|
||||||
|
window.__relatedButtonsTargetTables?.delete(config.events.targetTable);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("related-button-unregister", {
|
||||||
|
detail: {
|
||||||
|
targetTable: config.events.targetTable,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [config.events?.targetTable, config.events?.targetFilterColumn]);
|
||||||
|
|
||||||
|
// 버튼 데이터 로드
|
||||||
|
const loadButtons = useCallback(async () => {
|
||||||
|
if (!masterData || !config.buttonDataSource?.tableName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterValue = masterData[config.sourceMapping.sourceColumn];
|
||||||
|
if (!filterValue) {
|
||||||
|
setButtons([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableName, filterColumn, displayColumn, valueColumn, orderColumn, orderDirection } = config.buttonDataSource;
|
||||||
|
|
||||||
|
const response = await dataApi.getTableData(tableName, {
|
||||||
|
filters: { [filterColumn]: filterValue },
|
||||||
|
sortBy: orderColumn || "created_date",
|
||||||
|
sortOrder: (orderDirection?.toLowerCase() || "asc") as "asc" | "desc",
|
||||||
|
size: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.length > 0) {
|
||||||
|
const defaultConfig = config.buttonStyle?.defaultIndicator;
|
||||||
|
|
||||||
|
const items: ButtonItem[] = response.data.map((row: Record<string, any>) => {
|
||||||
|
let isDefault = false;
|
||||||
|
if (defaultConfig?.column) {
|
||||||
|
const val = row[defaultConfig.column];
|
||||||
|
const checkValue = defaultConfig.value || "Y";
|
||||||
|
isDefault = val === checkValue || val === true || val === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id || row[valueColumn || "id"],
|
||||||
|
displayText: row[displayColumn] || row.id,
|
||||||
|
value: row[valueColumn || "id"],
|
||||||
|
isDefault,
|
||||||
|
rawData: row,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setButtons(items);
|
||||||
|
|
||||||
|
// 자동 선택: 기본 항목 또는 첫 번째 항목
|
||||||
|
if (config.autoSelectFirst && items.length > 0) {
|
||||||
|
const defaultItem = items.find(item => item.isDefault);
|
||||||
|
const targetItem = defaultItem || items[0];
|
||||||
|
setSelectedId(targetItem.id);
|
||||||
|
setSelectedItem(targetItem);
|
||||||
|
emitSelection(targetItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("RelatedDataButtons 데이터 로드 실패:", error);
|
||||||
|
setButtons([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [masterData, config.buttonDataSource, config.sourceMapping, config.buttonStyle, config.autoSelectFirst]);
|
||||||
|
|
||||||
|
// masterData 변경 시 버튼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (masterData) {
|
||||||
|
setSelectedId(null); // 마스터 변경 시 선택 초기화
|
||||||
|
setSelectedItem(null);
|
||||||
|
loadButtons();
|
||||||
|
}
|
||||||
|
}, [masterData, loadButtons]);
|
||||||
|
|
||||||
|
// 선택 이벤트 발생
|
||||||
|
const emitSelection = useCallback((item: ButtonItem) => {
|
||||||
|
if (!config.events?.targetTable || !config.events?.targetFilterColumn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커스텀 이벤트 발생 (하위 테이블 필터링용)
|
||||||
|
window.dispatchEvent(new CustomEvent("related-button-select", {
|
||||||
|
detail: {
|
||||||
|
targetTable: config.events.targetTable,
|
||||||
|
filterColumn: config.events.targetFilterColumn,
|
||||||
|
filterValue: item.value,
|
||||||
|
selectedData: item.rawData,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("📌 RelatedDataButtons 선택 이벤트:", {
|
||||||
|
targetTable: config.events.targetTable,
|
||||||
|
filterColumn: config.events.targetFilterColumn,
|
||||||
|
filterValue: item.value,
|
||||||
|
});
|
||||||
|
}, [config.events]);
|
||||||
|
|
||||||
|
// 버튼 클릭 핸들러
|
||||||
|
const handleButtonClick = useCallback((item: ButtonItem) => {
|
||||||
|
setSelectedId(item.id);
|
||||||
|
setSelectedItem(item);
|
||||||
|
emitSelection(item);
|
||||||
|
}, [emitSelection]);
|
||||||
|
|
||||||
|
// 모달 열기 (선택된 버튼 데이터 전달)
|
||||||
|
const openModalWithSelectedData = useCallback((targetScreenId: number) => {
|
||||||
|
if (!selectedItem) {
|
||||||
|
console.warn("선택된 버튼이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 매핑 적용
|
||||||
|
const initialData: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (config.modalLink?.dataMapping) {
|
||||||
|
config.modalLink.dataMapping.forEach(mapping => {
|
||||||
|
if (mapping.sourceField === "value") {
|
||||||
|
initialData[mapping.targetField] = selectedItem.value;
|
||||||
|
} else if (mapping.sourceField === "id") {
|
||||||
|
initialData[mapping.targetField] = selectedItem.id;
|
||||||
|
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
|
||||||
|
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 기본 매핑: id를 routing_version_id로 전달
|
||||||
|
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📤 RelatedDataButtons 모달 열기:", {
|
||||||
|
targetScreenId,
|
||||||
|
selectedItem,
|
||||||
|
initialData,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("open-screen-modal", {
|
||||||
|
detail: {
|
||||||
|
screenId: targetScreenId,
|
||||||
|
initialData,
|
||||||
|
onSuccess: () => {
|
||||||
|
loadButtons(); // 모달 성공 후 새로고침
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [selectedItem, config.modalLink, loadButtons]);
|
||||||
|
|
||||||
|
// 외부 버튼에서 모달 열기 요청 수신
|
||||||
|
useEffect(() => {
|
||||||
|
const handleExternalModalOpen = (event: CustomEvent) => {
|
||||||
|
const { targetScreenId, componentId } = event.detail || {};
|
||||||
|
|
||||||
|
// componentId가 지정되어 있고 현재 컴포넌트가 아니면 무시
|
||||||
|
if (componentId && componentId !== config.sourceMapping?.sourceTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetScreenId && selectedItem) {
|
||||||
|
openModalWithSelectedData(targetScreenId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("related-buttons-open-modal" as any, handleExternalModalOpen);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("related-buttons-open-modal" as any, handleExternalModalOpen);
|
||||||
|
};
|
||||||
|
}, [selectedItem, config.sourceMapping, openModalWithSelectedData]);
|
||||||
|
|
||||||
|
// 내부 모달 링크 버튼 클릭
|
||||||
|
const handleModalLinkClick = useCallback(() => {
|
||||||
|
if (!config.modalLink?.targetScreenId) {
|
||||||
|
console.warn("모달 링크 설정이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openModalWithSelectedData(config.modalLink.targetScreenId);
|
||||||
|
}, [config.modalLink, openModalWithSelectedData]);
|
||||||
|
|
||||||
|
// 추가 버튼 클릭
|
||||||
|
const handleAddClick = useCallback(() => {
|
||||||
|
if (!config.addButton?.modalScreenId) return;
|
||||||
|
|
||||||
|
const filterValue = masterData?.[config.sourceMapping.sourceColumn];
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("open-screen-modal", {
|
||||||
|
detail: {
|
||||||
|
screenId: config.addButton.modalScreenId,
|
||||||
|
initialData: {
|
||||||
|
[config.buttonDataSource.filterColumn]: filterValue,
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
loadButtons(); // 모달 성공 후 새로고침
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [config.addButton, config.buttonDataSource.filterColumn, config.sourceMapping.sourceColumn, masterData, loadButtons]);
|
||||||
|
|
||||||
|
// 버튼 variant 계산
|
||||||
|
const getButtonVariant = useCallback((item: ButtonItem): "default" | "outline" | "secondary" | "ghost" => {
|
||||||
|
if (selectedId === item.id) {
|
||||||
|
return config.buttonStyle?.activeVariant || "default";
|
||||||
|
}
|
||||||
|
return config.buttonStyle?.variant || "outline";
|
||||||
|
}, [selectedId, config.buttonStyle]);
|
||||||
|
|
||||||
|
// 마스터 데이터 없음
|
||||||
|
if (!masterData) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-lg border bg-card p-4", className)} style={style}>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
좌측에서 항목을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerConfig = config.headerDisplay;
|
||||||
|
const addButtonConfig = config.addButton;
|
||||||
|
const modalLinkConfig = config.modalLink;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-lg border bg-card", className)} style={style}>
|
||||||
|
{/* 헤더 영역 */}
|
||||||
|
{headerConfig?.show !== false && (
|
||||||
|
<div className="flex items-start justify-between p-4 pb-3">
|
||||||
|
<div>
|
||||||
|
{/* 제목 (품목명 등) */}
|
||||||
|
{headerConfig?.titleColumn && masterData[headerConfig.titleColumn] && (
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{masterData[headerConfig.titleColumn]}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{/* 부제목 (품목코드 등) */}
|
||||||
|
{headerConfig?.subtitleColumn && masterData[headerConfig.subtitleColumn] && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{masterData[headerConfig.subtitleColumn]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 모달 링크 버튼 (헤더 위치) */}
|
||||||
|
{modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition === "header" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleModalLinkClick}
|
||||||
|
disabled={!selectedItem}
|
||||||
|
title={!selectedItem ? "버튼을 먼저 선택하세요" : ""}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-1 h-4 w-4" />
|
||||||
|
{modalLinkConfig.buttonLabel || "상세 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 헤더 위치 추가 버튼 */}
|
||||||
|
{addButtonConfig?.show && addButtonConfig?.position === "header" && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
{addButtonConfig.label || "버전 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 영역 */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : buttons.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{config.emptyMessage || "데이터가 없습니다"}
|
||||||
|
</p>
|
||||||
|
{/* 인라인 추가 버튼 (데이터 없을 때) */}
|
||||||
|
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="border-dashed"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
{addButtonConfig.label || "추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{buttons.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.id}
|
||||||
|
variant={getButtonVariant(item)}
|
||||||
|
size={config.buttonStyle?.size || "default"}
|
||||||
|
onClick={() => handleButtonClick(item)}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
selectedId === item.id && "ring-2 ring-primary ring-offset-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 기본 버전 별표 */}
|
||||||
|
{item.isDefault && config.buttonStyle?.defaultIndicator?.showStar && (
|
||||||
|
<Star className="mr-1.5 h-3.5 w-3.5 fill-yellow-400 text-yellow-400" />
|
||||||
|
)}
|
||||||
|
{item.displayText}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 모달 링크 버튼 (인라인 위치) */}
|
||||||
|
{modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition !== "header" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size={config.buttonStyle?.size || "default"}
|
||||||
|
onClick={handleModalLinkClick}
|
||||||
|
disabled={!selectedItem}
|
||||||
|
title={!selectedItem ? "버튼을 먼저 선택하세요" : ""}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-1 h-4 w-4" />
|
||||||
|
{modalLinkConfig.buttonLabel || "상세 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인라인 추가 버튼 */}
|
||||||
|
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size={config.buttonStyle?.size || "default"}
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="border-dashed"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
{addButtonConfig.label || "추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RelatedDataButtonsComponent;
|
||||||
|
|
@ -0,0 +1,874 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import type { RelatedDataButtonsConfig } from "./types";
|
||||||
|
|
||||||
|
// 화면 정보 타입
|
||||||
|
interface ScreenInfo {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 선택 컴포넌트
|
||||||
|
interface ScreenSelectorProps {
|
||||||
|
value?: number;
|
||||||
|
onChange: (screenId: number | undefined, tableName?: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScreenSelector: React.FC<ScreenSelectorProps> = ({ value, onChange, placeholder = "화면 선택" }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScreens = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await screenApi.getScreens({ size: 500 });
|
||||||
|
if (response.data) {
|
||||||
|
setScreens(response.data.map((s: any) => ({
|
||||||
|
screenId: s.screenId,
|
||||||
|
screenName: s.screenName || s.name || `화면 ${s.screenId}`,
|
||||||
|
tableName: s.tableName || s.table_name,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadScreens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedScreen = screens.find(s => s.screenId === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between text-xs h-9">
|
||||||
|
{loading ? "로딩중..." : selectedScreen ? `${selectedScreen.screenName} (${selectedScreen.screenId})` : placeholder}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-2 text-center">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{screens.map((screen) => (
|
||||||
|
<CommandItem
|
||||||
|
key={screen.screenId}
|
||||||
|
value={`${screen.screenName} ${screen.screenId}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(screen.screenId, screen.tableName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="truncate">{screen.screenName}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground">({screen.screenId})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RelatedDataButtonsConfigPanelProps {
|
||||||
|
config: RelatedDataButtonsConfig;
|
||||||
|
onChange: (config: RelatedDataButtonsConfig) => void;
|
||||||
|
tables?: TableInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
tables: propTables = [],
|
||||||
|
}) => {
|
||||||
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
|
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [buttonTableColumns, setButtonTableColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [targetModalTableColumns, setTargetModalTableColumns] = useState<ColumnInfo[]>([]); // 대상 모달 테이블 컬럼
|
||||||
|
const [targetModalTableName, setTargetModalTableName] = useState<string>(""); // 대상 모달 테이블명
|
||||||
|
const [eventTargetTableColumns, setEventTargetTableColumns] = useState<ColumnInfo[]>([]); // 하위 테이블 연동 대상 테이블 컬럼
|
||||||
|
|
||||||
|
// Popover 상태
|
||||||
|
const [sourceTableOpen, setSourceTableOpen] = useState(false);
|
||||||
|
const [buttonTableOpen, setButtonTableOpen] = useState(false);
|
||||||
|
|
||||||
|
// 전체 테이블 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName || t.table_name,
|
||||||
|
displayName: t.tableLabel || t.table_label || t.displayName,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 소스 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!config.sourceMapping?.sourceTable) {
|
||||||
|
setSourceTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await getTableColumns(config.sourceMapping.sourceTable);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setSourceTableColumns(response.data.columns.map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.column_name,
|
||||||
|
columnLabel: c.columnLabel || c.column_label || c.displayName,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [config.sourceMapping?.sourceTable]);
|
||||||
|
|
||||||
|
// 버튼 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!config.buttonDataSource?.tableName) {
|
||||||
|
setButtonTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await getTableColumns(config.buttonDataSource.tableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setButtonTableColumns(response.data.columns.map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.column_name,
|
||||||
|
columnLabel: c.columnLabel || c.column_label || c.displayName,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("버튼 테이블 컬럼 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [config.buttonDataSource?.tableName]);
|
||||||
|
|
||||||
|
// 대상 모달 화면의 테이블명 로드 (초기 로드 및 screenId 변경 시)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTargetScreenTable = async () => {
|
||||||
|
if (!config.modalLink?.targetScreenId) {
|
||||||
|
setTargetModalTableName("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const screenInfo = await screenApi.getScreen(config.modalLink.targetScreenId);
|
||||||
|
if (screenInfo?.tableName) {
|
||||||
|
setTargetModalTableName(screenInfo.tableName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("대상 모달 화면 정보 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTargetScreenTable();
|
||||||
|
}, [config.modalLink?.targetScreenId]);
|
||||||
|
|
||||||
|
// 대상 모달 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!targetModalTableName) {
|
||||||
|
setTargetModalTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await getTableColumns(targetModalTableName);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setTargetModalTableColumns(response.data.columns.map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.column_name,
|
||||||
|
columnLabel: c.columnLabel || c.column_label || c.displayName,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("대상 모달 테이블 컬럼 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [targetModalTableName]);
|
||||||
|
|
||||||
|
// 하위 테이블 연동 대상 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!config.events?.targetTable) {
|
||||||
|
setEventTargetTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await getTableColumns(config.events.targetTable);
|
||||||
|
if (response.success && response.data?.columns) {
|
||||||
|
setEventTargetTableColumns(response.data.columns.map((c: any) => ({
|
||||||
|
columnName: c.columnName || c.column_name,
|
||||||
|
columnLabel: c.columnLabel || c.column_label || c.displayName,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("하위 테이블 연동 대상 테이블 컬럼 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [config.events?.targetTable]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const updateConfig = useCallback((updates: Partial<RelatedDataButtonsConfig>) => {
|
||||||
|
onChange({ ...config, ...updates });
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateSourceMapping = useCallback((updates: Partial<RelatedDataButtonsConfig["sourceMapping"]>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
sourceMapping: { ...config.sourceMapping, ...updates },
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateHeaderDisplay = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["headerDisplay"]>>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
headerDisplay: { ...config.headerDisplay, ...updates } as any,
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateButtonDataSource = useCallback((updates: Partial<RelatedDataButtonsConfig["buttonDataSource"]>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
buttonDataSource: { ...config.buttonDataSource, ...updates },
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateButtonStyle = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["buttonStyle"]>>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
buttonStyle: { ...config.buttonStyle, ...updates },
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateAddButton = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["addButton"]>>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
addButton: { ...config.addButton, ...updates },
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateEvents = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["events"]>>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
events: { ...config.events, ...updates },
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const updateModalLink = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["modalLink"]>>) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
modalLink: { ...config.modalLink, ...updates },
|
||||||
|
});
|
||||||
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
const tables = allTables.length > 0 ? allTables : propTables;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 소스 매핑 (좌측 패널 연결) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">소스 테이블 (좌측 패널)</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={sourceTableOpen} onOpenChange={setSourceTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between">
|
||||||
|
{config.sourceMapping?.sourceTable || "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateSourceMapping({ sourceTable: table.tableName });
|
||||||
|
setSourceTableOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", config.sourceMapping?.sourceTable === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">필터 컬럼 (버튼 테이블 조회 시 사용)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.sourceMapping?.sourceColumn || ""}
|
||||||
|
onValueChange={(value) => updateSourceMapping({ sourceColumn: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 헤더 표시 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">헤더 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.headerDisplay?.show !== false}
|
||||||
|
onCheckedChange={(checked) => updateHeaderDisplay({ show: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.headerDisplay?.show !== false && (
|
||||||
|
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">제목 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.headerDisplay?.titleColumn || ""}
|
||||||
|
onValueChange={(value) => updateHeaderDisplay({ titleColumn: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="제목 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">부제목 컬럼 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.headerDisplay?.subtitleColumn || "__none__"}
|
||||||
|
onValueChange={(value) => updateHeaderDisplay({ subtitleColumn: value === "__none__" ? "" : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="부제목 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{sourceTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 데이터 소스 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">버튼 데이터 소스</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">테이블</Label>
|
||||||
|
<Popover open={buttonTableOpen} onOpenChange={setButtonTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between">
|
||||||
|
{config.buttonDataSource?.tableName || "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateButtonDataSource({ tableName: table.tableName });
|
||||||
|
setButtonTableOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", config.buttonDataSource?.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">필터 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.buttonDataSource?.filterColumn || ""}
|
||||||
|
onValueChange={(value) => updateButtonDataSource({ filterColumn: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{buttonTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">표시 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.buttonDataSource?.displayColumn || ""}
|
||||||
|
onValueChange={(value) => updateButtonDataSource({ displayColumn: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{buttonTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 스타일 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">버튼 스타일</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">기본 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.buttonStyle?.variant || "outline"}
|
||||||
|
onValueChange={(value: any) => updateButtonStyle({ variant: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="secondary">Secondary</SelectItem>
|
||||||
|
<SelectItem value="ghost">Ghost</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">선택 시 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.buttonStyle?.activeVariant || "default"}
|
||||||
|
onValueChange={(value: any) => updateButtonStyle({ activeVariant: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="secondary">Secondary</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 표시 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">기본 버전 표시 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.buttonStyle?.defaultIndicator?.column || "__none__"}
|
||||||
|
onValueChange={(value) => updateButtonStyle({
|
||||||
|
defaultIndicator: {
|
||||||
|
...config.buttonStyle?.defaultIndicator,
|
||||||
|
column: value === "__none__" ? "" : value,
|
||||||
|
showStar: config.buttonStyle?.defaultIndicator?.showStar ?? true,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{buttonTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.buttonStyle?.defaultIndicator?.column && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.buttonStyle?.defaultIndicator?.showStar ?? true}
|
||||||
|
onCheckedChange={(checked) => updateButtonStyle({
|
||||||
|
defaultIndicator: {
|
||||||
|
...config.buttonStyle?.defaultIndicator,
|
||||||
|
column: config.buttonStyle?.defaultIndicator?.column || "",
|
||||||
|
showStar: checked,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">별표 표시</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이벤트 설정 (하위 테이블 연동) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">하위 테이블 연동</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">대상 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between">
|
||||||
|
{config.events?.targetTable || "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateEvents({ targetTable: table.tableName });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", config.events?.targetTable === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">필터 컬럼 (버튼 값 컬럼 → 대상 테이블 컬럼)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.events?.targetFilterColumn || ""}
|
||||||
|
onValueChange={(value) => updateEvents({ targetFilterColumn: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{eventTargetTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{eventTargetTableColumns.length === 0 && config.events?.targetTable && (
|
||||||
|
<p className="text-xs text-muted-foreground">컬럼을 불러오는 중...</p>
|
||||||
|
)}
|
||||||
|
{!config.events?.targetTable && (
|
||||||
|
<p className="text-xs text-muted-foreground">먼저 대상 테이블을 선택하세요</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 버튼 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">추가 버튼</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.addButton?.show ?? false}
|
||||||
|
onCheckedChange={(checked) => updateAddButton({ show: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.addButton?.show && (
|
||||||
|
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.addButton?.label || ""}
|
||||||
|
onChange={(e) => updateAddButton({ label: e.target.value })}
|
||||||
|
placeholder="+ 버전 추가"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">위치</Label>
|
||||||
|
<Select
|
||||||
|
value={config.addButton?.position || "header"}
|
||||||
|
onValueChange={(value: any) => updateAddButton({ position: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="header">헤더 우측</SelectItem>
|
||||||
|
<SelectItem value="inline">버튼들과 함께</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.addButton?.modalScreenId}
|
||||||
|
onChange={(screenId) => updateAddButton({ modalScreenId: screenId })}
|
||||||
|
placeholder="화면 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-semibold">모달 연동 (공정 추가 등)</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.modalLink?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => updateModalLink({ enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.modalLink?.enabled && (
|
||||||
|
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">트리거 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.modalLink?.triggerType || "external"}
|
||||||
|
onValueChange={(value: any) => updateModalLink({ triggerType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="external">외부 버튼 (별도 버튼에서 호출)</SelectItem>
|
||||||
|
<SelectItem value="button">내부 버튼 (컴포넌트에 버튼 표시)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.modalLink?.triggerType === "button" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.modalLink?.buttonLabel || ""}
|
||||||
|
onChange={(e) => updateModalLink({ buttonLabel: e.target.value })}
|
||||||
|
placeholder="공정 추가"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 위치</Label>
|
||||||
|
<Select
|
||||||
|
value={config.modalLink?.buttonPosition || "header"}
|
||||||
|
onValueChange={(value: any) => updateModalLink({ buttonPosition: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="header">헤더 우측</SelectItem>
|
||||||
|
<SelectItem value="inline">버튼들과 함께</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">대상 모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modalLink?.targetScreenId}
|
||||||
|
onChange={(screenId, tableName) => {
|
||||||
|
updateModalLink({ targetScreenId: screenId });
|
||||||
|
if (tableName) {
|
||||||
|
setTargetModalTableName(tableName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="화면 선택"
|
||||||
|
/>
|
||||||
|
{targetModalTableName && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
테이블: {targetModalTableName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
<Label className="text-xs font-medium">데이터 매핑</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
선택된 버튼 데이터를 모달 초기값으로 전달합니다.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">소스 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.modalLink?.dataMapping?.[0]?.sourceField === "id" ? "__id__" :
|
||||||
|
config.modalLink?.dataMapping?.[0]?.sourceField === "value" ? "__value__" :
|
||||||
|
config.modalLink?.dataMapping?.[0]?.sourceField || "__id__"}
|
||||||
|
onValueChange={(value) => updateModalLink({
|
||||||
|
dataMapping: [{
|
||||||
|
sourceField: value === "__id__" ? "id" : value === "__value__" ? "value" : value,
|
||||||
|
targetField: config.modalLink?.dataMapping?.[0]?.targetField || ""
|
||||||
|
}]
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__id__">ID (버튼 고유값)</SelectItem>
|
||||||
|
<SelectItem value="__value__">값 (valueColumn)</SelectItem>
|
||||||
|
{buttonTableColumns
|
||||||
|
.filter(col => col.columnName !== "id") // id 중복 제거
|
||||||
|
.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">대상 필드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.modalLink?.dataMapping?.[0]?.targetField || "__none__"}
|
||||||
|
onValueChange={(value) => updateModalLink({
|
||||||
|
dataMapping: [{
|
||||||
|
sourceField: config.modalLink?.dataMapping?.[0]?.sourceField || "id",
|
||||||
|
targetField: value === "__none__" ? "" : value
|
||||||
|
}]
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
|
{targetModalTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{targetModalTableColumns.length === 0 && targetModalTableName && (
|
||||||
|
<p className="text-xs text-muted-foreground">컬럼을 불러오는 중...</p>
|
||||||
|
)}
|
||||||
|
{!targetModalTableName && (
|
||||||
|
<p className="text-xs text-muted-foreground">먼저 대상 모달 화면을 선택하세요</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기타 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-semibold">기타 설정</Label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.autoSelectFirst ?? true}
|
||||||
|
onCheckedChange={(checked) => updateConfig({ autoSelectFirst: checked })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">첫 번째 항목 자동 선택</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">빈 상태 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={config.emptyMessage || ""}
|
||||||
|
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
|
||||||
|
placeholder="데이터가 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RelatedDataButtonsConfigPanel;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { RelatedDataButtonsDefinition } from "./index";
|
||||||
|
import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RelatedDataButtons 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class RelatedDataButtonsRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = RelatedDataButtonsDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { component } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RelatedDataButtonsComponent
|
||||||
|
config={component?.config || RelatedDataButtonsDefinition.defaultConfig}
|
||||||
|
className={component?.className}
|
||||||
|
style={component?.style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
RelatedDataButtonsRenderer.registerSelf();
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { ComponentConfig } from "@/lib/registry/types";
|
||||||
|
|
||||||
|
export const relatedDataButtonsConfig: ComponentConfig = {
|
||||||
|
id: "related-data-buttons",
|
||||||
|
name: "연관 데이터 버튼",
|
||||||
|
description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들",
|
||||||
|
category: "data",
|
||||||
|
webType: "container",
|
||||||
|
version: "1.0.0",
|
||||||
|
icon: "LayoutList",
|
||||||
|
defaultConfig: {
|
||||||
|
sourceMapping: {
|
||||||
|
sourceTable: "",
|
||||||
|
sourceColumn: "",
|
||||||
|
},
|
||||||
|
headerDisplay: {
|
||||||
|
show: true,
|
||||||
|
titleColumn: "",
|
||||||
|
subtitleColumn: "",
|
||||||
|
},
|
||||||
|
buttonDataSource: {
|
||||||
|
tableName: "",
|
||||||
|
filterColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
valueColumn: "id",
|
||||||
|
orderColumn: "created_date",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
},
|
||||||
|
buttonStyle: {
|
||||||
|
variant: "outline",
|
||||||
|
activeVariant: "default",
|
||||||
|
size: "default",
|
||||||
|
defaultIndicator: {
|
||||||
|
column: "",
|
||||||
|
showStar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
show: false,
|
||||||
|
label: "+ 버전 추가",
|
||||||
|
position: "header",
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
targetTable: "",
|
||||||
|
targetFilterColumn: "",
|
||||||
|
},
|
||||||
|
autoSelectFirst: true,
|
||||||
|
emptyMessage: "데이터가 없습니다",
|
||||||
|
},
|
||||||
|
configPanelComponent: "RelatedDataButtonsConfigPanel",
|
||||||
|
rendererComponent: "RelatedDataButtonsRenderer",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
|
||||||
|
import { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RelatedDataButtons 컴포넌트 정의
|
||||||
|
* 좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시
|
||||||
|
*/
|
||||||
|
export const RelatedDataButtonsDefinition = createComponentDefinition({
|
||||||
|
id: "related-data-buttons",
|
||||||
|
name: "연관 데이터 버튼",
|
||||||
|
nameEng: "Related Data Buttons",
|
||||||
|
description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들",
|
||||||
|
category: ComponentCategory.DATA,
|
||||||
|
webType: "container",
|
||||||
|
component: RelatedDataButtonsComponent,
|
||||||
|
defaultConfig: {
|
||||||
|
sourceMapping: {
|
||||||
|
sourceTable: "",
|
||||||
|
sourceColumn: "",
|
||||||
|
},
|
||||||
|
headerDisplay: {
|
||||||
|
show: true,
|
||||||
|
titleColumn: "",
|
||||||
|
subtitleColumn: "",
|
||||||
|
},
|
||||||
|
buttonDataSource: {
|
||||||
|
tableName: "",
|
||||||
|
filterColumn: "",
|
||||||
|
displayColumn: "",
|
||||||
|
valueColumn: "id",
|
||||||
|
orderColumn: "created_date",
|
||||||
|
orderDirection: "ASC",
|
||||||
|
},
|
||||||
|
buttonStyle: {
|
||||||
|
variant: "outline",
|
||||||
|
activeVariant: "default",
|
||||||
|
size: "default",
|
||||||
|
defaultIndicator: {
|
||||||
|
column: "",
|
||||||
|
showStar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
show: false,
|
||||||
|
label: "+ 버전 추가",
|
||||||
|
position: "header",
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
targetTable: "",
|
||||||
|
targetFilterColumn: "",
|
||||||
|
},
|
||||||
|
autoSelectFirst: true,
|
||||||
|
emptyMessage: "데이터가 없습니다",
|
||||||
|
},
|
||||||
|
defaultSize: { width: 400, height: 120 },
|
||||||
|
configPanel: RelatedDataButtonsConfigPanel,
|
||||||
|
icon: "LayoutList",
|
||||||
|
tags: ["버튼", "연관데이터", "마스터디테일", "라우팅"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type { RelatedDataButtonsConfig, ButtonItem } from "./types";
|
||||||
|
export { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
|
||||||
|
export { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel";
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* RelatedDataButtons 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 좌측 패널에서 선택한 데이터의 정보를 표시하고,
|
||||||
|
* 연관 테이블의 데이터를 버튼으로 표시하는 컴포넌트
|
||||||
|
*
|
||||||
|
* 예시: 품목 선택 → 품목명/코드 표시 + 라우팅 버전 버튼들
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 헤더 표시 설정 (선택된 마스터 데이터 정보)
|
||||||
|
*/
|
||||||
|
export interface HeaderDisplayConfig {
|
||||||
|
show?: boolean; // 헤더 표시 여부
|
||||||
|
titleColumn: string; // 제목으로 표시할 컬럼 (예: item_name)
|
||||||
|
subtitleColumn?: string; // 부제목으로 표시할 컬럼 (예: item_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버튼 데이터 소스 설정
|
||||||
|
*/
|
||||||
|
export interface ButtonDataSourceConfig {
|
||||||
|
tableName: string; // 조회할 테이블명 (예: item_routing_version)
|
||||||
|
filterColumn: string; // 필터링할 컬럼명 (예: item_code)
|
||||||
|
displayColumn: string; // 버튼에 표시할 컬럼명 (예: version_name)
|
||||||
|
valueColumn?: string; // 선택 시 전달할 값 컬럼 (기본: id)
|
||||||
|
orderColumn?: string; // 정렬 컬럼
|
||||||
|
orderDirection?: "ASC" | "DESC"; // 정렬 방향
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버튼 스타일 설정
|
||||||
|
*/
|
||||||
|
export interface ButtonStyleConfig {
|
||||||
|
variant?: "default" | "outline" | "secondary" | "ghost";
|
||||||
|
activeVariant?: "default" | "outline" | "secondary";
|
||||||
|
size?: "sm" | "default" | "lg";
|
||||||
|
// 기본 버전 표시 설정
|
||||||
|
defaultIndicator?: {
|
||||||
|
column: string; // 기본 여부 판단 컬럼 (예: is_default)
|
||||||
|
value?: string; // 기본 값 (기본: "Y" 또는 true)
|
||||||
|
showStar?: boolean; // 별표 아이콘 표시
|
||||||
|
badgeText?: string; // 뱃지 텍스트 (예: "기본")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추가 버튼 설정
|
||||||
|
*/
|
||||||
|
export interface AddButtonConfig {
|
||||||
|
show?: boolean;
|
||||||
|
label?: string; // 기본: "+ 버전 추가"
|
||||||
|
modalScreenId?: number;
|
||||||
|
position?: "header" | "inline"; // header: 헤더 우측, inline: 버튼들과 함께
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 설정 (하위 테이블 연동)
|
||||||
|
*/
|
||||||
|
export interface EventConfig {
|
||||||
|
// 선택 시 하위 테이블 필터링
|
||||||
|
targetTable?: string; // 필터링할 테이블명 (예: item_routing_detail)
|
||||||
|
targetFilterColumn?: string; // 필터 컬럼명 (예: routing_version_id)
|
||||||
|
// 커스텀 이벤트
|
||||||
|
customEventName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달)
|
||||||
|
*/
|
||||||
|
export interface ModalLinkConfig {
|
||||||
|
enabled?: boolean; // 모달 연동 활성화
|
||||||
|
targetScreenId?: number; // 열릴 모달 화면 ID
|
||||||
|
triggerType?: "button" | "external"; // button: 별도 버튼, external: 외부 버튼에서 호출
|
||||||
|
buttonLabel?: string; // 버튼 텍스트 (triggerType이 button일 때)
|
||||||
|
buttonPosition?: "header" | "inline"; // 버튼 위치
|
||||||
|
// 데이터 매핑: 선택된 버튼 데이터 → 모달 초기값
|
||||||
|
dataMapping?: {
|
||||||
|
sourceField: string; // 버튼 데이터의 필드명 (예: "id", "value")
|
||||||
|
targetField: string; // 모달에 전달할 필드명 (예: "routing_version_id")
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 설정
|
||||||
|
*/
|
||||||
|
export interface RelatedDataButtonsConfig {
|
||||||
|
// 소스 매핑 (좌측 패널 연결)
|
||||||
|
sourceMapping: {
|
||||||
|
sourceTable: string; // 좌측 패널 테이블명
|
||||||
|
sourceColumn: string; // 필터에 사용할 컬럼 (예: item_code)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 표시 설정
|
||||||
|
headerDisplay?: HeaderDisplayConfig;
|
||||||
|
|
||||||
|
// 버튼 데이터 소스
|
||||||
|
buttonDataSource: ButtonDataSourceConfig;
|
||||||
|
|
||||||
|
// 버튼 스타일
|
||||||
|
buttonStyle?: ButtonStyleConfig;
|
||||||
|
|
||||||
|
// 추가 버튼
|
||||||
|
addButton?: AddButtonConfig;
|
||||||
|
|
||||||
|
// 이벤트 설정
|
||||||
|
events?: EventConfig;
|
||||||
|
|
||||||
|
// 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달)
|
||||||
|
modalLink?: ModalLinkConfig;
|
||||||
|
|
||||||
|
// 자동 선택
|
||||||
|
autoSelectFirst?: boolean; // 첫 번째 (또는 기본) 항목 자동 선택
|
||||||
|
|
||||||
|
// 빈 상태 메시지
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버튼 아이템 데이터
|
||||||
|
*/
|
||||||
|
export interface ButtonItem {
|
||||||
|
id: string;
|
||||||
|
displayText: string;
|
||||||
|
value: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
rawData: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
@ -1744,7 +1744,9 @@ function RowNumberingConfigSection({
|
||||||
<SelectValue placeholder="컬럼 선택" />
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tableColumns.map((col, index) => (
|
{tableColumns
|
||||||
|
.filter((col) => col.field && col.field.trim() !== "")
|
||||||
|
.map((col, index) => (
|
||||||
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
|
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
|
||||||
{col.label || col.field}
|
{col.label || col.field}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -66,32 +66,11 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
|
|
||||||
|
|
||||||
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
|
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
|
||||||
|
|
||||||
// componentConfig 또는 config 또는 component.componentConfig 사용
|
// componentConfig 또는 config 또는 component.componentConfig 사용
|
||||||
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
||||||
|
|
||||||
console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", {
|
|
||||||
hasComponentConfig: !!componentConfig,
|
|
||||||
hasConfig: !!config,
|
|
||||||
hasComponentComponentConfig: !!component?.componentConfig,
|
|
||||||
finalConfig,
|
|
||||||
splitRatio: finalConfig.splitRatio,
|
|
||||||
leftScreenId: finalConfig.leftScreenId,
|
|
||||||
rightScreenId: finalConfig.rightScreenId,
|
|
||||||
componentType: component?.componentType,
|
|
||||||
componentId: component?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 formData 별도 로그 (명확한 확인)
|
|
||||||
console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", {
|
|
||||||
hasFormData: !!formData,
|
|
||||||
formDataKeys: formData ? Object.keys(formData) : [],
|
|
||||||
formData: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%", height: "100%", ...style }}>
|
<div style={{ width: "100%", height: "100%", ...style }}>
|
||||||
<ScreenSplitPanel
|
<ScreenSplitPanel
|
||||||
|
|
|
||||||
|
|
@ -1522,9 +1522,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
{availableRightTables.map((table) => (
|
{availableRightTables.map((table) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={table.tableName}
|
key={table.tableName}
|
||||||
value={table.tableName}
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
onSelect={(value) => {
|
onSelect={() => {
|
||||||
updateRightPanel({ tableName: value });
|
updateRightPanel({ tableName: table.tableName });
|
||||||
setRightTableOpen(false);
|
setRightTableOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,4 @@
|
||||||
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
- [split-panel-layout (v1)](../split-panel-layout/README.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer
|
||||||
SplitPanelLayout2Renderer.registerSelf();
|
SplitPanelLayout2Renderer.registerSelf();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ import { codeCache } from "@/lib/caching/codeCache";
|
||||||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||||
import { getFullImageUrl } from "@/lib/api/client";
|
import { getFullImageUrl } from "@/lib/api/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__relatedButtonsTargetTables?: Set<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -201,6 +208,9 @@ export interface TableListComponentProps {
|
||||||
) => void;
|
) => void;
|
||||||
onConfigChange?: (config: any) => void;
|
onConfigChange?: (config: any) => void;
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
|
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
|
||||||
|
parentTabId?: string; // 부모 탭 ID
|
||||||
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -217,7 +227,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
config,
|
config,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
formData: propFormData, // 🆕 부모에서 전달받은 formData
|
formData: propFormData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
onSelectedRowsChange,
|
onSelectedRowsChange,
|
||||||
|
|
@ -225,7 +235,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
refreshKey,
|
refreshKey,
|
||||||
tableName,
|
tableName,
|
||||||
userId,
|
userId,
|
||||||
screenId, // 화면 ID 추출
|
screenId,
|
||||||
|
parentTabId,
|
||||||
|
parentTabsComponentId,
|
||||||
}) => {
|
}) => {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 설정 및 스타일
|
// 설정 및 스타일
|
||||||
|
|
@ -304,6 +316,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||||||
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태
|
||||||
|
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
|
||||||
|
filterColumn: string;
|
||||||
|
filterValue: any;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부
|
||||||
|
const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => {
|
||||||
|
// 초기값: 전역 레지스트리에서 확인
|
||||||
|
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) {
|
||||||
|
return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || "");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
// TableOptions Context
|
// TableOptions Context
|
||||||
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||||
const [filters, setFilters] = useState<TableFilter[]>([]);
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||||
|
|
@ -994,7 +1021,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onGroupChange: setGrouping,
|
onGroupChange: setGrouping,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
||||||
onGroupSumChange: setGroupSumConfig, // 🆕 그룹별 합산 설정
|
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
|
||||||
|
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
||||||
|
parentTabId,
|
||||||
|
parentTabsComponentId,
|
||||||
|
screenId: screenId ? Number(screenId) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
registerTable(registration);
|
registerTable(registration);
|
||||||
|
|
@ -1268,18 +1299,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📡 [TableList] API 호출 시작 [${columnName}]:`, {
|
|
||||||
url: `/table-categories/${targetTable}/${targetColumn}/values`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||||
|
|
||||||
console.log(`📡 [TableList] API 응답 [${columnName}]:`, {
|
|
||||||
success: response.data.success,
|
|
||||||
dataLength: response.data.data?.length,
|
|
||||||
rawData: response.data,
|
|
||||||
items: response.data.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||||
|
|
@ -1291,18 +1313,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
label: item.valueLabel,
|
label: item.valueLabel,
|
||||||
color: item.color,
|
color: item.color,
|
||||||
};
|
};
|
||||||
console.log(` 🔑 [${columnName}] "${key}" => "${item.valueLabel}" (색상: ${item.color})`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(mapping).length > 0) {
|
if (Object.keys(mapping).length > 0) {
|
||||||
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
|
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
|
||||||
mappings[columnName] = mapping;
|
mappings[columnName] = mapping;
|
||||||
console.log(`✅ [TableList] 카테고리 매핑 로드 완료 [${columnName}]:`, {
|
|
||||||
columnName,
|
|
||||||
mappingCount: Object.keys(mapping).length,
|
|
||||||
mappingKeys: Object.keys(mapping),
|
|
||||||
mapping,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
|
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
|
||||||
}
|
}
|
||||||
|
|
@ -1342,7 +1357,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
col.columnName,
|
col.columnName,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
console.log("🔍 [TableList] additionalJoinInfo 컬럼:", additionalJoinColumns);
|
|
||||||
|
|
||||||
// 조인 테이블별로 그룹화
|
// 조인 테이블별로 그룹화
|
||||||
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
|
const joinedTableColumns: Record<string, { columnName: string; actualColumn: string }[]> = {};
|
||||||
|
|
@ -1375,7 +1389,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 [TableList] 조인 테이블별 컬럼:", joinedTableColumns);
|
|
||||||
|
|
||||||
// 조인된 테이블별로 inputType 정보 가져오기
|
// 조인된 테이블별로 inputType 정보 가져오기
|
||||||
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
const newJoinedColumnMeta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
|
||||||
|
|
@ -1421,9 +1434,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
if (Object.keys(mapping).length > 0) {
|
if (Object.keys(mapping).length > 0) {
|
||||||
mappings[col.columnName] = mapping;
|
mappings[col.columnName] = mapping;
|
||||||
console.log(`✅ [TableList] 조인 테이블 카테고리 매핑 로드 완료 [${col.columnName}]:`, {
|
|
||||||
mappingCount: Object.keys(mapping).length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1442,16 +1452,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
|
console.log("✅ [TableList] 조인 컬럼 메타데이터 설정:", newJoinedColumnMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📊 [TableList] 전체 카테고리 매핑 설정:", {
|
|
||||||
mappingsCount: Object.keys(mappings).length,
|
|
||||||
mappingsKeys: Object.keys(mappings),
|
|
||||||
mappings,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(mappings).length > 0) {
|
if (Object.keys(mappings).length > 0) {
|
||||||
setCategoryMappings(mappings);
|
setCategoryMappings(mappings);
|
||||||
setCategoryMappingsKey((prev) => prev + 1);
|
setCategoryMappingsKey((prev) => prev + 1);
|
||||||
console.log("✅ [TableList] setCategoryMappings 호출 완료");
|
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
|
console.warn("⚠️ [TableList] 매핑이 비어있어 상태 업데이트 스킵");
|
||||||
}
|
}
|
||||||
|
|
@ -1473,11 +1476,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
const fetchTableDataInternal = useCallback(async () => {
|
const fetchTableDataInternal = useCallback(async () => {
|
||||||
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
|
|
||||||
tableName: tableConfig.selectedTable,
|
|
||||||
isDesignMode,
|
|
||||||
currentPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tableConfig.selectedTable || isDesignMode) {
|
if (!tableConfig.selectedTable || isDesignMode) {
|
||||||
setData([]);
|
setData([]);
|
||||||
|
|
@ -1501,13 +1499,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
||||||
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
||||||
|
|
||||||
console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", {
|
|
||||||
hasSplitPanelContext: !!splitPanelContext,
|
|
||||||
tableName: tableConfig.selectedTable,
|
|
||||||
selectedLeftData: splitPanelContext?.selectedLeftData,
|
|
||||||
linkedFilters: splitPanelContext?.linkedFilters,
|
|
||||||
splitPanelPosition: splitPanelPosition,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (splitPanelContext) {
|
if (splitPanelContext) {
|
||||||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||||
|
|
@ -1523,19 +1514,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||||
|
|
||||||
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
|
const allLinkedFilters = splitPanelContext.getLinkedFilterValues();
|
||||||
console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters);
|
|
||||||
|
|
||||||
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||||||
|
// 연결 필터는 코드 값이므로 정확한 매칭(equals)을 사용해야 함
|
||||||
for (const [key, value] of Object.entries(allLinkedFilters)) {
|
for (const [key, value] of Object.entries(allLinkedFilters)) {
|
||||||
if (key.includes(".")) {
|
if (key.includes(".")) {
|
||||||
const [tableName, columnName] = key.split(".");
|
const [tableName, columnName] = key.split(".");
|
||||||
if (tableName === tableConfig.selectedTable) {
|
if (tableName === tableConfig.selectedTable) {
|
||||||
linkedFilterValues[columnName] = value;
|
// 연결 필터는 코드 값이므로 equals 연산자 사용
|
||||||
|
linkedFilterValues[columnName] = { value, operator: "equals" };
|
||||||
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
|
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
|
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용 (equals)
|
||||||
linkedFilterValues[key] = value;
|
linkedFilterValues[key] = { value, operator: "equals" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1560,7 +1552,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 현재 테이블에 동일한 컬럼이 있는지 확인
|
// 현재 테이블에 동일한 컬럼이 있는지 확인
|
||||||
if (tableColumns.includes(colName)) {
|
if (tableColumns.includes(colName)) {
|
||||||
linkedFilterValues[colName] = colValue;
|
// 자동 컬럼 매칭도 equals 연산자 사용
|
||||||
|
linkedFilterValues[colName] = { value: colValue, operator: "equals" };
|
||||||
hasLinkedFiltersConfigured = true;
|
hasLinkedFiltersConfigured = true;
|
||||||
console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`);
|
console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`);
|
||||||
}
|
}
|
||||||
|
|
@ -1586,10 +1579,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 필터와 연결 필터 병합
|
// 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
|
||||||
|
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
||||||
|
if (isRelatedButtonTarget && !relatedButtonFilter) {
|
||||||
|
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
|
||||||
|
setData([]);
|
||||||
|
setTotalItems(0);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 필터 값 준비
|
||||||
|
let relatedButtonFilterValues: Record<string, any> = {};
|
||||||
|
if (relatedButtonFilter) {
|
||||||
|
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
|
||||||
|
value: relatedButtonFilter.filterValue,
|
||||||
|
operator: "equals",
|
||||||
|
};
|
||||||
|
console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
|
||||||
const filters = {
|
const filters = {
|
||||||
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
|
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
|
||||||
...linkedFilterValues,
|
...linkedFilterValues,
|
||||||
|
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||||
};
|
};
|
||||||
const hasFilters = Object.keys(filters).length > 0;
|
const hasFilters = Object.keys(filters).length > 0;
|
||||||
|
|
||||||
|
|
@ -1652,7 +1666,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
|
|
||||||
|
|
||||||
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
// 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
let excludeFilterParam: any = undefined;
|
let excludeFilterParam: any = undefined;
|
||||||
|
|
@ -1787,6 +1800,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
splitPanelPosition,
|
splitPanelPosition,
|
||||||
currentSplitPosition,
|
currentSplitPosition,
|
||||||
splitPanelContext?.selectedLeftData,
|
splitPanelContext?.selectedLeftData,
|
||||||
|
// 🆕 RelatedDataButtons 필터 추가
|
||||||
|
relatedButtonFilter,
|
||||||
|
isRelatedButtonTarget,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fetchTableDataDebounced = useCallback(
|
const fetchTableDataDebounced = useCallback(
|
||||||
|
|
@ -2143,16 +2159,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
|
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
|
||||||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||||||
|
|
||||||
console.log("🔗 [TableList] 셀 클릭 - 분할 패널 위치 확인:", {
|
|
||||||
rowIndex,
|
|
||||||
colIndex,
|
|
||||||
splitPanelPosition,
|
|
||||||
currentSplitPosition,
|
|
||||||
effectiveSplitPosition,
|
|
||||||
hasSplitPanelContext: !!splitPanelContext,
|
|
||||||
isCurrentlySelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||||
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
|
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
|
||||||
if (!isCurrentlySelected) {
|
if (!isCurrentlySelected) {
|
||||||
|
|
@ -2162,10 +2168,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 분할 패널 컨텍스트에 데이터 저장
|
// 분할 패널 컨텍스트에 데이터 저장
|
||||||
splitPanelContext.setSelectedLeftData(row);
|
splitPanelContext.setSelectedLeftData(row);
|
||||||
console.log("🔗 [TableList] 셀 클릭으로 분할 패널 좌측 데이터 저장:", {
|
|
||||||
row,
|
|
||||||
parentDataMapping: splitPanelContext.parentDataMapping,
|
|
||||||
});
|
|
||||||
|
|
||||||
// onSelectedRowsChange 콜백 호출
|
// onSelectedRowsChange 콜백 호출
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
|
|
@ -2885,7 +2887,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(tableStateKey, JSON.stringify(state));
|
localStorage.setItem(tableStateKey, JSON.stringify(state));
|
||||||
console.log("✅ 테이블 상태 저장:", tableStateKey);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 상태 저장 실패:", error);
|
console.error("❌ 테이블 상태 저장 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -2927,7 +2928,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setHeaderFilters(filters);
|
setHeaderFilters(filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 테이블 상태 복원:", tableStateKey);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 상태 복원 실패:", error);
|
console.error("❌ 테이블 상태 복원 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -2948,7 +2948,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
setShowGridLines(true);
|
setShowGridLines(true);
|
||||||
setHeaderFilters({});
|
setHeaderFilters({});
|
||||||
toast.success("테이블 설정이 초기화되었습니다.");
|
toast.success("테이블 설정이 초기화되었습니다.");
|
||||||
console.log("✅ 테이블 상태 초기화:", tableStateKey);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 테이블 상태 초기화 실패:", error);
|
console.error("❌ 테이블 상태 초기화 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -4105,13 +4104,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 선택된 컬럼들의 값을 구분자로 조합
|
// 선택된 컬럼들의 값을 구분자로 조합
|
||||||
const values = displayColumns
|
const values = displayColumns
|
||||||
.map((colName: string) => {
|
.map((colName: string) => {
|
||||||
// 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
// 🎯 백엔드 alias 규칙: ${sourceColumn}_${displayColumn}
|
||||||
let cellValue = rowData[colName];
|
// 예: manager 컬럼에서 user_name 선택 시 → manager_user_name
|
||||||
|
|
||||||
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
|
|
||||||
if (cellValue === null || cellValue === undefined) {
|
|
||||||
const joinedKey = `${column.columnName}_${colName}`;
|
const joinedKey = `${column.columnName}_${colName}`;
|
||||||
cellValue = rowData[joinedKey];
|
let cellValue = rowData[joinedKey];
|
||||||
|
|
||||||
|
// fallback: 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
|
||||||
|
if (cellValue === null || cellValue === undefined) {
|
||||||
|
cellValue = rowData[colName];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cellValue === null || cellValue === undefined) return "";
|
if (cellValue === null || cellValue === undefined) return "";
|
||||||
|
|
@ -4234,9 +4234,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (values.length === 1) {
|
if (values.length === 1) {
|
||||||
const categoryData = mapping?.[values[0]];
|
const categoryData = mapping?.[values[0]];
|
||||||
const displayLabel = categoryData?.label || values[0];
|
const displayLabel = categoryData?.label || values[0];
|
||||||
const displayColor = categoryData?.color || "#64748b";
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
if (displayColor === "none") {
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4259,9 +4260,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
{values.map((val, idx) => {
|
{values.map((val, idx) => {
|
||||||
const categoryData = mapping?.[val];
|
const categoryData = mapping?.[val];
|
||||||
const displayLabel = categoryData?.label || val;
|
const displayLabel = categoryData?.label || val;
|
||||||
const displayColor = categoryData?.color || "#64748b";
|
const displayColor = categoryData?.color;
|
||||||
|
|
||||||
if (displayColor === "none") {
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-sm">
|
<span key={idx} className="text-sm">
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
|
|
@ -4820,6 +4822,88 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
};
|
};
|
||||||
}, [tableConfig.selectedTable, isDesignMode]);
|
}, [tableConfig.selectedTable, isDesignMode]);
|
||||||
|
|
||||||
|
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
|
||||||
|
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
|
||||||
|
if (isTarget) {
|
||||||
|
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
|
||||||
|
setIsRelatedButtonTarget(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRelatedButtonRegister = (event: CustomEvent) => {
|
||||||
|
const { targetTable } = event.detail || {};
|
||||||
|
if (targetTable === tableConfig.selectedTable) {
|
||||||
|
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
|
||||||
|
setIsRelatedButtonTarget(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRelatedButtonUnregister = (event: CustomEvent) => {
|
||||||
|
const { targetTable } = event.detail || {};
|
||||||
|
if (targetTable === tableConfig.selectedTable) {
|
||||||
|
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
|
||||||
|
setIsRelatedButtonTarget(false);
|
||||||
|
setRelatedButtonFilter(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
||||||
|
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
|
||||||
|
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
|
||||||
|
};
|
||||||
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
|
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRelatedButtonSelect = (event: CustomEvent) => {
|
||||||
|
const { targetTable, filterColumn, filterValue } = event.detail || {};
|
||||||
|
|
||||||
|
// 이 테이블이 대상 테이블인지 확인
|
||||||
|
if (targetTable === tableConfig.selectedTable) {
|
||||||
|
// filterValue가 null이면 선택 해제 (빈 상태)
|
||||||
|
if (filterValue === null || filterValue === undefined) {
|
||||||
|
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
|
||||||
|
setRelatedButtonFilter(null);
|
||||||
|
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
|
||||||
|
} else {
|
||||||
|
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
|
||||||
|
tableName: tableConfig.selectedTable,
|
||||||
|
filterColumn,
|
||||||
|
filterValue,
|
||||||
|
});
|
||||||
|
setRelatedButtonFilter({ filterColumn, filterValue });
|
||||||
|
setIsRelatedButtonTarget(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
|
||||||
|
};
|
||||||
|
}, [tableConfig.selectedTable]);
|
||||||
|
|
||||||
|
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesignMode) {
|
||||||
|
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
|
||||||
|
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
|
||||||
|
relatedButtonFilter,
|
||||||
|
isRelatedButtonTarget
|
||||||
|
});
|
||||||
|
setRefreshTrigger((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [relatedButtonFilter, isDesignMode]);
|
||||||
|
|
||||||
// 🎯 컬럼 너비 자동 계산 (내용 기반)
|
// 🎯 컬럼 너비 자동 계산 (내용 기반)
|
||||||
const calculateOptimalColumnWidth = useCallback(
|
const calculateOptimalColumnWidth = useCallback(
|
||||||
(columnName: string, displayName: string): number => {
|
(columnName: string, displayName: string): number => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
||||||
|
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||||
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
||||||
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
|
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
|
||||||
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
||||||
|
|
@ -49,8 +50,9 @@ interface TableSearchWidgetProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
|
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
|
||||||
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
|
const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = useTableOptions();
|
||||||
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
|
||||||
|
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
|
||||||
|
|
||||||
// 높이 관리 context (실제 화면에서만 사용)
|
// 높이 관리 context (실제 화면에서만 사용)
|
||||||
let setWidgetHeight:
|
let setWidgetHeight:
|
||||||
|
|
@ -64,6 +66,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
setWidgetHeight = undefined;
|
setWidgetHeight = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 탭별 필터 값 저장 (탭 ID -> 필터 값)
|
||||||
|
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
|
||||||
|
|
||||||
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
|
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [groupingOpen, setGroupingOpen] = useState(false);
|
const [groupingOpen, setGroupingOpen] = useState(false);
|
||||||
|
|
@ -88,17 +93,24 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
// Map을 배열로 변환
|
// Map을 배열로 변환
|
||||||
const allTableList = Array.from(registeredTables.values());
|
const allTableList = Array.from(registeredTables.values());
|
||||||
|
|
||||||
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
|
// 현재 활성 탭 ID 목록
|
||||||
const tableList = useMemo(() => {
|
const activeTabIds = useMemo(() => getAllActiveTabIds(), [activeTabs]);
|
||||||
// "auto"면 모든 테이블 반환
|
|
||||||
if (targetPanelPosition === "auto") {
|
|
||||||
return allTableList;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 테이블 ID 패턴으로 필터링
|
// 대상 패널 위치 + 활성 탭에 따라 테이블 필터링
|
||||||
// card-display-XXX: 좌측 패널 (카드 디스플레이)
|
const tableList = useMemo(() => {
|
||||||
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
|
// 1단계: 활성 탭 기반 필터링
|
||||||
const filteredTables = allTableList.filter(table => {
|
// - 활성 탭에 속한 테이블만 표시
|
||||||
|
// - 탭에 속하지 않은 테이블(parentTabId가 없는)도 포함
|
||||||
|
let filteredByTab = allTableList.filter(table => {
|
||||||
|
// 탭에 속하지 않는 테이블은 항상 표시
|
||||||
|
if (!table.parentTabId) return true;
|
||||||
|
// 활성 탭에 속한 테이블만 표시
|
||||||
|
return activeTabIds.includes(table.parentTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2단계: 대상 패널 위치에 따라 추가 필터링
|
||||||
|
if (targetPanelPosition !== "auto") {
|
||||||
|
filteredByTab = filteredByTab.filter(table => {
|
||||||
const tableId = table.tableId.toLowerCase();
|
const tableId = table.tableId.toLowerCase();
|
||||||
|
|
||||||
if (targetPanelPosition === "left") {
|
if (targetPanelPosition === "left") {
|
||||||
|
|
@ -112,26 +124,17 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
|
|
||||||
if (filteredTables.length === 0) {
|
|
||||||
console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", {
|
|
||||||
targetPanelPosition,
|
|
||||||
allTablesCount: allTableList.length,
|
|
||||||
allTableIds: allTableList.map(t => t.tableId),
|
|
||||||
});
|
|
||||||
return allTableList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 [TableSearchWidget] 테이블 필터링:", {
|
// 필터링된 결과가 없으면 탭 기반 필터링 결과만 반환
|
||||||
targetPanelPosition,
|
if (filteredByTab.length === 0) {
|
||||||
allTablesCount: allTableList.length,
|
return allTableList.filter(table =>
|
||||||
filteredCount: filteredTables.length,
|
!table.parentTabId || activeTabIds.includes(table.parentTabId)
|
||||||
filteredTableIds: filteredTables.map(t => t.tableId),
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
return filteredTables;
|
return filteredByTab;
|
||||||
}, [allTableList, targetPanelPosition]);
|
}, [allTableList, targetPanelPosition, activeTabIds]);
|
||||||
|
|
||||||
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
||||||
const currentTable = useMemo(() => {
|
const currentTable = useMemo(() => {
|
||||||
|
|
@ -159,15 +162,38 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
|
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
|
||||||
if (!selectedTableId || !isCurrentTableInTarget) {
|
if (!selectedTableId || !isCurrentTableInTarget) {
|
||||||
const targetTable = tableList[0];
|
const targetTable = tableList[0];
|
||||||
console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", {
|
|
||||||
targetPanelPosition,
|
|
||||||
selectedTableId: targetTable.tableId,
|
|
||||||
tableName: targetTable.tableName,
|
|
||||||
});
|
|
||||||
setSelectedTableId(targetTable.tableId);
|
setSelectedTableId(targetTable.tableId);
|
||||||
}
|
}
|
||||||
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
|
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
|
||||||
|
|
||||||
|
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
|
||||||
|
const currentTableTabId = currentTable?.parentTabId;
|
||||||
|
|
||||||
|
// 탭별 필터 값 저장 키 생성
|
||||||
|
const getTabFilterStorageKey = (tableName: string, tabId?: string) => {
|
||||||
|
const baseKey = screenId
|
||||||
|
? `table_filter_values_${tableName}_screen_${screenId}`
|
||||||
|
: `table_filter_values_${tableName}`;
|
||||||
|
return tabId ? `${baseKey}_tab_${tabId}` : baseKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 탭 변경 시 이전 탭의 필터 값 저장 + 새 탭의 필터 값 복원
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentTable?.tableName) return;
|
||||||
|
|
||||||
|
// 현재 필터 값이 있으면 탭별로 저장
|
||||||
|
if (Object.keys(filterValues).length > 0 && currentTableTabId) {
|
||||||
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(filterValues));
|
||||||
|
|
||||||
|
// 메모리 캐시에도 저장
|
||||||
|
setTabFilterValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentTableTabId]: filterValues
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [currentTableTabId, currentTable?.tableName]);
|
||||||
|
|
||||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTable?.tableName) return;
|
if (!currentTable?.tableName) return;
|
||||||
|
|
@ -182,14 +208,32 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
width: f.width || 200,
|
width: f.width || 200,
|
||||||
}));
|
}));
|
||||||
setActiveFilters(activeFiltersList);
|
setActiveFilters(activeFiltersList);
|
||||||
|
|
||||||
|
// 탭별 저장된 필터 값 복원
|
||||||
|
if (currentTableTabId) {
|
||||||
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
||||||
|
const savedValues = localStorage.getItem(storageKey);
|
||||||
|
if (savedValues) {
|
||||||
|
try {
|
||||||
|
const parsedValues = JSON.parse(savedValues);
|
||||||
|
setFilterValues(parsedValues);
|
||||||
|
// 즉시 필터 적용
|
||||||
|
setTimeout(() => applyFilters(parsedValues), 100);
|
||||||
|
} catch {
|
||||||
|
setFilterValues({});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFilterValues({});
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
// 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기
|
||||||
const storageKey = screenId
|
const filterConfigKey = screenId
|
||||||
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}`
|
||||||
: `table_filters_${currentTable.tableName}`;
|
: `table_filters_${currentTable.tableName}`;
|
||||||
const savedFilters = localStorage.getItem(storageKey);
|
const savedFilters = localStorage.getItem(filterConfigKey);
|
||||||
|
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -210,16 +254,39 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
operator: "contains",
|
operator: "contains",
|
||||||
value: "",
|
value: "",
|
||||||
filterType: f.filterType,
|
filterType: f.filterType,
|
||||||
width: f.width || 200, // 저장된 너비 포함
|
width: f.width || 200,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setActiveFilters(activeFiltersList);
|
setActiveFilters(activeFiltersList);
|
||||||
|
|
||||||
|
// 탭별 저장된 필터 값 복원
|
||||||
|
if (currentTableTabId) {
|
||||||
|
const valuesStorageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
||||||
|
const savedValues = localStorage.getItem(valuesStorageKey);
|
||||||
|
if (savedValues) {
|
||||||
|
try {
|
||||||
|
const parsedValues = JSON.parse(savedValues);
|
||||||
|
setFilterValues(parsedValues);
|
||||||
|
// 즉시 필터 적용
|
||||||
|
setTimeout(() => applyFilters(parsedValues), 100);
|
||||||
|
} catch {
|
||||||
|
setFilterValues({});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFilterValues({});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFilterValues({});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장된 필터 불러오기 실패:", error);
|
console.error("저장된 필터 불러오기 실패:", error);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 필터 설정이 없으면 초기화
|
||||||
|
setFilterValues({});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
|
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||||
|
|
||||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -317,6 +384,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
|
|
||||||
setFilterValues(newValues);
|
setFilterValues(newValues);
|
||||||
|
|
||||||
|
// 탭별 필터 값 저장
|
||||||
|
if (currentTable?.tableName && currentTableTabId) {
|
||||||
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(newValues));
|
||||||
|
}
|
||||||
|
|
||||||
// 실시간 검색: 값 변경 시 즉시 필터 적용
|
// 실시간 검색: 값 변경 시 즉시 필터 적용
|
||||||
applyFilters(newValues);
|
applyFilters(newValues);
|
||||||
};
|
};
|
||||||
|
|
@ -374,12 +447,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 [TableSearchWidget] 필터 적용:", {
|
|
||||||
currentTableId: currentTable?.tableId,
|
|
||||||
currentTableName: currentTable?.tableName,
|
|
||||||
filtersCount: filtersWithValues.length,
|
|
||||||
filtersWithValues,
|
|
||||||
});
|
|
||||||
currentTable?.onFilterChange(filtersWithValues);
|
currentTable?.onFilterChange(filtersWithValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -388,6 +455,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
setFilterValues({});
|
setFilterValues({});
|
||||||
setSelectedLabels({});
|
setSelectedLabels({});
|
||||||
currentTable?.onFilterChange([]);
|
currentTable?.onFilterChange([]);
|
||||||
|
|
||||||
|
// 탭별 저장된 필터 값도 초기화
|
||||||
|
if (currentTable?.tableName && currentTableTabId) {
|
||||||
|
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터 입력 필드 렌더링
|
// 필터 입력 필드 렌더링
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,6 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
persistSelection: tabsConfig.persistSelection || false,
|
persistSelection: tabsConfig.persistSelection || false,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("🎨 TabsWidget 렌더링:", {
|
|
||||||
componentId: component.id,
|
|
||||||
tabs: tabsComponent.tabs,
|
|
||||||
tabsLength: tabsComponent.tabs.length,
|
|
||||||
component,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TabsWidget 동적 로드
|
// TabsWidget 동적 로드
|
||||||
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||||
|
|
|
||||||
|
|
@ -55,29 +55,11 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
||||||
onClick?.();
|
onClick?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
// DOM에 전달하면 안 되는 React-specific props 필터링 - 모든 커스텀 props 제거
|
||||||
const {
|
// domProps를 사용하지 않고 필요한 props만 명시적으로 전달
|
||||||
selectedScreen,
|
|
||||||
onZoneComponentDrop,
|
|
||||||
onZoneClick,
|
|
||||||
componentConfig: _componentConfig,
|
|
||||||
component: _component,
|
|
||||||
isSelected: _isSelected,
|
|
||||||
onClick: _onClick,
|
|
||||||
onDragStart: _onDragStart,
|
|
||||||
onDragEnd: _onDragEnd,
|
|
||||||
size: _size,
|
|
||||||
position: _position,
|
|
||||||
style: _style,
|
|
||||||
screenId: _screenId,
|
|
||||||
tableName: _tableName,
|
|
||||||
onRefresh: _onRefresh,
|
|
||||||
onClose: _onClose,
|
|
||||||
...domProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label
|
<label
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
FormDataState,
|
FormDataState,
|
||||||
RepeatSectionItem,
|
RepeatSectionItem,
|
||||||
SelectOptionConfig,
|
SelectOptionConfig,
|
||||||
|
OptionalFieldGroupConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultConfig, generateUniqueId } from "./config";
|
import { defaultConfig, generateUniqueId } from "./config";
|
||||||
|
|
||||||
|
|
@ -177,6 +178,9 @@ export function UniversalFormModalComponent({
|
||||||
// 섹션 접힘 상태
|
// 섹션 접힘 상태
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
|
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 활성화 상태 (섹션ID-그룹ID 조합)
|
||||||
|
const [activatedOptionalFieldGroups, setActivatedOptionalFieldGroups] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Select 옵션 캐시
|
// Select 옵션 캐시
|
||||||
const [selectOptionsCache, setSelectOptionsCache] = useState<{
|
const [selectOptionsCache, setSelectOptionsCache] = useState<{
|
||||||
[key: string]: { value: string; label: string }[];
|
[key: string]: { value: string; label: string }[];
|
||||||
|
|
@ -351,6 +355,7 @@ export function UniversalFormModalComponent({
|
||||||
const newFormData: FormDataState = {};
|
const newFormData: FormDataState = {};
|
||||||
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
|
||||||
const newCollapsed = new Set<string>();
|
const newCollapsed = new Set<string>();
|
||||||
|
const newActivatedGroups = new Set<string>();
|
||||||
|
|
||||||
// 섹션별 초기화
|
// 섹션별 초기화
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
|
|
@ -386,12 +391,47 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
newFormData[field.columnName] = value;
|
newFormData[field.columnName] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 처리
|
||||||
|
if (section.optionalFieldGroups) {
|
||||||
|
for (const group of section.optionalFieldGroups) {
|
||||||
|
const key = `${section.id}-${group.id}`;
|
||||||
|
|
||||||
|
// 수정 모드: triggerField 값이 triggerValueOnAdd와 일치하면 그룹 자동 활성화
|
||||||
|
if (effectiveInitialData && group.triggerField && group.triggerValueOnAdd !== undefined) {
|
||||||
|
const triggerValue = effectiveInitialData[group.triggerField];
|
||||||
|
if (triggerValue === group.triggerValueOnAdd) {
|
||||||
|
newActivatedGroups.add(key);
|
||||||
|
console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`);
|
||||||
|
|
||||||
|
// 활성화된 그룹의 필드값도 초기화
|
||||||
|
for (const field of group.fields) {
|
||||||
|
let value = field.defaultValue ?? "";
|
||||||
|
const parentField = field.parentFieldName || field.columnName;
|
||||||
|
if (effectiveInitialData[parentField] !== undefined) {
|
||||||
|
value = effectiveInitialData[parentField];
|
||||||
|
}
|
||||||
|
newFormData[field.columnName] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신규 등록 모드: triggerValueOnRemove를 기본값으로 설정
|
||||||
|
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
|
||||||
|
// effectiveInitialData에 해당 값이 없는 경우에만 기본값 설정
|
||||||
|
if (!effectiveInitialData || effectiveInitialData[group.triggerField] === undefined) {
|
||||||
|
newFormData[group.triggerField] = group.triggerValueOnRemove;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
setRepeatSections(newRepeatSections);
|
setRepeatSections(newRepeatSections);
|
||||||
setCollapsedSections(newCollapsed);
|
setCollapsedSections(newCollapsed);
|
||||||
|
setActivatedOptionalFieldGroups(newActivatedGroups);
|
||||||
setOriginalData(effectiveInitialData || {});
|
setOriginalData(effectiveInitialData || {});
|
||||||
|
|
||||||
// 채번규칙 자동 생성
|
// 채번규칙 자동 생성
|
||||||
|
|
@ -575,6 +615,49 @@ export function UniversalFormModalComponent({
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 활성화
|
||||||
|
const activateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => {
|
||||||
|
const section = config.sections.find((s) => s.id === sectionId);
|
||||||
|
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const key = `${sectionId}-${groupId}`;
|
||||||
|
setActivatedOptionalFieldGroups((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(key);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연동 필드 값 변경 (추가 시)
|
||||||
|
if (group.triggerField && group.triggerValueOnAdd !== undefined) {
|
||||||
|
handleFieldChange(group.triggerField, group.triggerValueOnAdd);
|
||||||
|
}
|
||||||
|
}, [config, handleFieldChange]);
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 비활성화
|
||||||
|
const deactivateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => {
|
||||||
|
const section = config.sections.find((s) => s.id === sectionId);
|
||||||
|
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const key = `${sectionId}-${groupId}`;
|
||||||
|
setActivatedOptionalFieldGroups((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(key);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연동 필드 값 변경 (제거 시)
|
||||||
|
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
|
||||||
|
handleFieldChange(group.triggerField, group.triggerValueOnRemove);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 필드 값 초기화
|
||||||
|
group.fields.forEach((field) => {
|
||||||
|
handleFieldChange(field.columnName, field.defaultValue || "");
|
||||||
|
});
|
||||||
|
}, [config, handleFieldChange]);
|
||||||
|
|
||||||
// Select 옵션 로드
|
// Select 옵션 로드
|
||||||
const loadSelectOptions = useCallback(
|
const loadSelectOptions = useCallback(
|
||||||
async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
|
async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
|
||||||
|
|
@ -587,9 +670,10 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (optionConfig.type === "static") {
|
if (optionConfig.type === "static") {
|
||||||
|
// 직접 입력: 설정된 정적 옵션 사용
|
||||||
options = optionConfig.staticOptions || [];
|
options = optionConfig.staticOptions || [];
|
||||||
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
} else if (optionConfig.type === "table" && optionConfig.tableName) {
|
||||||
// POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
// 테이블 참조: POST 방식으로 테이블 데이터 조회 (autoFilter 포함)
|
||||||
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
|
const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
|
@ -613,15 +697,23 @@ export function UniversalFormModalComponent({
|
||||||
value: String(row[optionConfig.valueColumn || "id"]),
|
value: String(row[optionConfig.valueColumn || "id"]),
|
||||||
label: String(row[optionConfig.labelColumn || "name"]),
|
label: String(row[optionConfig.labelColumn || "name"]),
|
||||||
}));
|
}));
|
||||||
} else if (optionConfig.type === "code" && optionConfig.codeCategory) {
|
} else if (optionConfig.type === "code" && optionConfig.categoryKey) {
|
||||||
const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
|
// 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회
|
||||||
|
// categoryKey 형식: "tableName.columnName"
|
||||||
|
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
|
||||||
|
if (categoryTable && categoryColumn) {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/table-categories/${categoryTable}/${categoryColumn}/values`
|
||||||
|
);
|
||||||
if (response.data?.success && response.data?.data) {
|
if (response.data?.success && response.data?.data) {
|
||||||
options = response.data.data.map((code: any) => ({
|
// 라벨값을 DB에 저장 (화면에 표시되는 값 그대로 저장)
|
||||||
value: code.code_value || code.codeValue,
|
options = response.data.data.map((item: any) => ({
|
||||||
label: code.code_name || code.codeName,
|
value: item.valueLabel || item.value_label,
|
||||||
|
label: item.valueLabel || item.value_label,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 캐시 저장
|
// 캐시 저장
|
||||||
setSelectOptionsCache((prev) => ({ ...prev, [fieldId]: options }));
|
setSelectOptionsCache((prev) => ({ ...prev, [fieldId]: options }));
|
||||||
|
|
@ -1500,6 +1592,15 @@ export function UniversalFormModalComponent({
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 옵셔널 필드 그룹 렌더링 */}
|
||||||
|
{section.optionalFieldGroups && section.optionalFieldGroups.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{section.optionalFieldGroups.map((group) =>
|
||||||
|
renderOptionalFieldGroup(section, group, sectionColumns)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1507,6 +1608,175 @@ export function UniversalFormModalComponent({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 접힘 상태 관리
|
||||||
|
const [collapsedOptionalGroups, setCollapsedOptionalGroups] = useState<Set<string>>(() => {
|
||||||
|
// 초기 접힘 상태 설정
|
||||||
|
const initialCollapsed = new Set<string>();
|
||||||
|
config.sections.forEach((section) => {
|
||||||
|
section.optionalFieldGroups?.forEach((group) => {
|
||||||
|
if (group.defaultCollapsed) {
|
||||||
|
initialCollapsed.add(`${section.id}-${group.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return initialCollapsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 렌더링
|
||||||
|
const renderOptionalFieldGroup = (
|
||||||
|
section: FormSectionConfig,
|
||||||
|
group: OptionalFieldGroupConfig,
|
||||||
|
sectionColumns: number
|
||||||
|
) => {
|
||||||
|
const key = `${section.id}-${group.id}`;
|
||||||
|
const isActivated = activatedOptionalFieldGroups.has(key);
|
||||||
|
const isCollapsed = collapsedOptionalGroups.has(key);
|
||||||
|
const groupColumns = group.columns || sectionColumns;
|
||||||
|
const addButtonText = group.addButtonText || `+ ${group.title} 추가`;
|
||||||
|
const removeButtonText = group.removeButtonText || "제거";
|
||||||
|
|
||||||
|
// 비활성화 상태: 추가 버튼만 표시
|
||||||
|
if (!isActivated) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="hover:border-primary/50 hover:bg-muted/30 border-muted rounded-lg border-2 border-dashed p-3 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">{group.title}</p>
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-muted-foreground/70 mt-0.5 text-xs">{group.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => activateOptionalFieldGroup(section.id, group.id)}
|
||||||
|
className="h-8 shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 활성화 상태: 필드 그룹 표시
|
||||||
|
// collapsible 설정에 따라 접기/펼치기 지원
|
||||||
|
if (group.collapsible) {
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={group.id}
|
||||||
|
open={!isCollapsed}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCollapsedOptionalGroups((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (open) {
|
||||||
|
newSet.delete(key);
|
||||||
|
} else {
|
||||||
|
newSet.add(key);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="border-primary/30 bg-muted/10 rounded-lg border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-3">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="flex items-center gap-2 text-left hover:opacity-80">
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{group.title}</p>
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (group.confirmRemove) {
|
||||||
|
if (confirm(`${group.title}을(를) 제거하시겠습니까?\n입력한 내용이 초기화됩니다.`)) {
|
||||||
|
deactivateOptionalFieldGroup(section.id, group.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deactivateOptionalFieldGroup(section.id, group.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive h-7 shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
{removeButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="grid gap-3 px-3 pb-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
|
||||||
|
{group.fields.map((field) =>
|
||||||
|
renderFieldWithColumns(
|
||||||
|
field,
|
||||||
|
formData[field.columnName],
|
||||||
|
(value) => handleFieldChange(field.columnName, value),
|
||||||
|
`${section.id}-${group.id}-${field.id}`,
|
||||||
|
groupColumns
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 접기 비활성화: 일반 표시
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="border-primary/30 bg-muted/10 rounded-lg border p-3">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{group.title}</p>
|
||||||
|
{group.description && (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (group.confirmRemove) {
|
||||||
|
if (confirm(`${group.title}을(를) 제거하시겠습니까?\n입력한 내용이 초기화됩니다.`)) {
|
||||||
|
deactivateOptionalFieldGroup(section.id, group.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deactivateOptionalFieldGroup(section.id, group.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-destructive h-7 shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
{removeButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3" style={{ gridTemplateColumns: "repeat(12, 1fr)" }}>
|
||||||
|
{group.fields.map((field) =>
|
||||||
|
renderFieldWithColumns(
|
||||||
|
field,
|
||||||
|
formData[field.columnName],
|
||||||
|
(value) => handleFieldChange(field.columnName, value),
|
||||||
|
`${section.id}-${group.id}-${field.id}`,
|
||||||
|
groupColumns
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// 반복 섹션 렌더링
|
// 반복 섹션 렌더링
|
||||||
const renderRepeatableSection = (section: FormSectionConfig, isCollapsed: boolean) => {
|
const renderRepeatableSection = (section: FormSectionConfig, isCollapsed: boolean) => {
|
||||||
const items = repeatSections[section.id] || [];
|
const items = repeatSections[section.id] || [];
|
||||||
|
|
|
||||||
|
|
@ -499,7 +499,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
onOpenChange={setSectionLayoutModalOpen}
|
onOpenChange={setSectionLayoutModalOpen}
|
||||||
section={selectedSection}
|
section={selectedSection}
|
||||||
onSave={(updates) => {
|
onSave={(updates) => {
|
||||||
|
// config 업데이트
|
||||||
updateSection(selectedSection.id, updates);
|
updateSection(selectedSection.id, updates);
|
||||||
|
// selectedSection 상태도 업데이트 (최신 상태 유지)
|
||||||
|
setSelectedSection({ ...selectedSection, ...updates });
|
||||||
setSectionLayoutModalOpen(false);
|
setSectionLayoutModalOpen(false);
|
||||||
}}
|
}}
|
||||||
onOpenFieldDetail={(field) => {
|
onOpenFieldDetail={(field) => {
|
||||||
|
|
@ -522,18 +525,30 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
field={selectedField}
|
field={selectedField}
|
||||||
onSave={(updates) => {
|
onSave={(updatedField) => {
|
||||||
|
// updatedField는 FieldDetailSettingsModal에서 전달된 전체 필드 객체
|
||||||
|
const updatedSection = {
|
||||||
|
...selectedSection,
|
||||||
|
// 기본 필드 목록에서 업데이트
|
||||||
|
fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
|
||||||
|
// 옵셔널 필드 그룹 내 필드도 업데이트
|
||||||
|
optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({
|
||||||
|
...group,
|
||||||
|
fields: group.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// config 업데이트
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
sections: config.sections.map((s) =>
|
sections: config.sections.map((s) =>
|
||||||
s.id === selectedSection.id
|
s.id === selectedSection.id ? updatedSection : s
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
fields: s.fields.map((f) => (f.id === selectedField.id ? { ...f, ...updates } : f)),
|
|
||||||
}
|
|
||||||
: s,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// selectedSection과 selectedField 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영)
|
||||||
|
setSelectedSection(updatedSection);
|
||||||
|
setSelectedField(updatedField as FormFieldConfig);
|
||||||
setFieldDetailModalOpen(false);
|
setFieldDetailModalOpen(false);
|
||||||
setSectionLayoutModalOpen(true);
|
setSectionLayoutModalOpen(true);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,30 @@ export const defaultSectionConfig = {
|
||||||
itemTitle: "항목 {index}",
|
itemTitle: "항목 {index}",
|
||||||
confirmRemove: false,
|
confirmRemove: false,
|
||||||
},
|
},
|
||||||
|
optionalFieldGroups: [],
|
||||||
linkedFieldGroups: [],
|
linkedFieldGroups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 기본 옵셔널 필드 그룹 설정
|
||||||
|
export const defaultOptionalFieldGroupConfig = {
|
||||||
|
id: "",
|
||||||
|
fields: [],
|
||||||
|
// 섹션 스타일 설정
|
||||||
|
title: "옵셔널 그룹",
|
||||||
|
description: "",
|
||||||
|
columns: undefined, // undefined면 부모 섹션 columns 상속
|
||||||
|
collapsible: false,
|
||||||
|
defaultCollapsed: false,
|
||||||
|
// 버튼 설정
|
||||||
|
addButtonText: "",
|
||||||
|
removeButtonText: "제거",
|
||||||
|
confirmRemove: false,
|
||||||
|
// 연동 필드 설정
|
||||||
|
triggerField: "",
|
||||||
|
triggerValueOnAdd: "",
|
||||||
|
triggerValueOnRemove: "",
|
||||||
|
};
|
||||||
|
|
||||||
// 기본 연동 필드 그룹 설정
|
// 기본 연동 필드 그룹 설정
|
||||||
export const defaultLinkedFieldGroupConfig = {
|
export const defaultLinkedFieldGroupConfig = {
|
||||||
id: "",
|
id: "",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,17 @@ import {
|
||||||
SELECT_OPTION_TYPE_OPTIONS,
|
SELECT_OPTION_TYPE_OPTIONS,
|
||||||
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
LINKED_FIELD_DISPLAY_FORMAT_OPTIONS,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
// 카테고리 컬럼 타입 (table_column_category_values 용)
|
||||||
|
interface CategoryColumnOption {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
valueCount: number;
|
||||||
|
// 조합키: tableName.columnName
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 도움말 텍스트 컴포넌트
|
// 도움말 텍스트 컴포넌트
|
||||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -49,6 +60,10 @@ export function FieldDetailSettingsModal({
|
||||||
// 로컬 상태로 필드 설정 관리
|
// 로컬 상태로 필드 설정 관리
|
||||||
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
const [localField, setLocalField] = useState<FormFieldConfig>(field);
|
||||||
|
|
||||||
|
// 전체 카테고리 컬럼 목록 상태
|
||||||
|
const [categoryColumns, setCategoryColumns] = useState<CategoryColumnOption[]>([]);
|
||||||
|
const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false);
|
||||||
|
|
||||||
// open이 변경될 때마다 필드 데이터 동기화
|
// open이 변경될 때마다 필드 데이터 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -56,6 +71,49 @@ export function FieldDetailSettingsModal({
|
||||||
}
|
}
|
||||||
}, [open, field]);
|
}, [open, field]);
|
||||||
|
|
||||||
|
// 모든 카테고리 컬럼 목록 로드 (모달 열릴 때)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllCategoryColumns = async () => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
setLoadingCategoryColumns(true);
|
||||||
|
try {
|
||||||
|
// /api/table-categories/all-columns API 호출
|
||||||
|
const response = await apiClient.get("/table-categories/all-columns");
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// 중복 제거를 위해 Map 사용
|
||||||
|
const uniqueMap = new Map<string, CategoryColumnOption>();
|
||||||
|
response.data.data.forEach((col: any) => {
|
||||||
|
const tableName = col.tableName || col.table_name;
|
||||||
|
const columnName = col.columnName || col.column_name;
|
||||||
|
const key = `${tableName}.${columnName}`;
|
||||||
|
|
||||||
|
// 이미 존재하는 경우 valueCount가 더 큰 것을 유지
|
||||||
|
if (!uniqueMap.has(key)) {
|
||||||
|
uniqueMap.set(key, {
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
columnLabel: col.columnLabel || col.column_label || columnName,
|
||||||
|
valueCount: parseInt(col.valueCount || col.value_count || "0"),
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setCategoryColumns(Array.from(uniqueMap.values()));
|
||||||
|
} else {
|
||||||
|
setCategoryColumns([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCategoryColumns([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingCategoryColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllCategoryColumns();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// 필드 업데이트 함수
|
// 필드 업데이트 함수
|
||||||
const updateField = (updates: Partial<FormFieldConfig>) => {
|
const updateField = (updates: Partial<FormFieldConfig>) => {
|
||||||
setLocalField((prev) => ({ ...prev, ...updates }));
|
setLocalField((prev) => ({ ...prev, ...updates }));
|
||||||
|
|
@ -107,7 +165,7 @@ export function FieldDetailSettingsModal({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 소스 테이블 컬럼 목록
|
// 소스 테이블 컬럼 목록 (연결 필드용)
|
||||||
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
|
const sourceTableColumns = localField.linkedFieldGroup?.sourceTable
|
||||||
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
|
? tableColumns[localField.linkedFieldGroup.sourceTable] || []
|
||||||
: [];
|
: [];
|
||||||
|
|
@ -248,7 +306,7 @@ export function FieldDetailSettingsModal({
|
||||||
<span>Select 옵션 설정</span>
|
<span>Select 옵션 설정</span>
|
||||||
{localField.selectOptions?.type && (
|
{localField.selectOptions?.type && (
|
||||||
<span className="text-[9px] text-muted-foreground">
|
<span className="text-[9px] text-muted-foreground">
|
||||||
({localField.selectOptions.type === "table" ? "테이블 참조" : localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
|
({localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,7 +322,7 @@ export function FieldDetailSettingsModal({
|
||||||
updateField({
|
updateField({
|
||||||
selectOptions: {
|
selectOptions: {
|
||||||
...localField.selectOptions,
|
...localField.selectOptions,
|
||||||
type: value as "static" | "table" | "code",
|
type: value as "static" | "code",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -463,23 +521,32 @@ export function FieldDetailSettingsModal({
|
||||||
|
|
||||||
{localField.selectOptions?.type === "code" && (
|
{localField.selectOptions?.type === "code" && (
|
||||||
<div className="space-y-2 pt-2 border-t">
|
<div className="space-y-2 pt-2 border-t">
|
||||||
<HelpText>공통코드: 시스템 공통코드에서 옵션을 가져옵니다.</HelpText>
|
<HelpText>공통코드: 코드설정에서 등록한 카테고리 값을 가져옵니다.</HelpText>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">코드 카테고리</Label>
|
<Label className="text-[10px]">카테고리 선택</Label>
|
||||||
<Input
|
<Select
|
||||||
value={localField.selectOptions?.codeCategory || ""}
|
value={localField.selectOptions?.categoryKey || ""}
|
||||||
onChange={(e) =>
|
onValueChange={(value) =>
|
||||||
updateField({
|
updateField({
|
||||||
selectOptions: {
|
selectOptions: {
|
||||||
...localField.selectOptions,
|
...localField.selectOptions,
|
||||||
codeCategory: e.target.value,
|
categoryKey: value,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="DEPT_TYPE"
|
>
|
||||||
className="h-7 text-xs mt-1"
|
<SelectTrigger className="h-7 text-xs mt-1">
|
||||||
/>
|
<SelectValue placeholder={loadingCategoryColumns ? "로딩 중..." : "카테고리 선택"} />
|
||||||
<HelpText>공통코드 카테고리를 입력하세요 (예: DEPT_TYPE, USER_STATUS)</HelpText>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categoryColumns.map((col, idx) => (
|
||||||
|
<SelectItem key={`${col.key}-${idx}`} value={col.key}>
|
||||||
|
{col.columnLabel} - {col.tableName} ({col.valueCount}개)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<HelpText>코드설정에서 등록한 카테고리를 선택하세요</HelpText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -841,3 +908,4 @@ export function FieldDetailSettingsModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -795,3 +795,4 @@ export function SaveSettingsModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FormSectionConfig, FormFieldConfig, FIELD_TYPE_OPTIONS } from "../types";
|
import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types";
|
||||||
import { defaultFieldConfig, generateFieldId } from "../config";
|
import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config";
|
||||||
|
|
||||||
// 도움말 텍스트 컴포넌트
|
// 도움말 텍스트 컴포넌트
|
||||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -36,6 +36,7 @@ export function SectionLayoutModal({
|
||||||
onSave,
|
onSave,
|
||||||
onOpenFieldDetail,
|
onOpenFieldDetail,
|
||||||
}: SectionLayoutModalProps) {
|
}: SectionLayoutModalProps) {
|
||||||
|
|
||||||
// 로컬 상태로 섹션 관리
|
// 로컬 상태로 섹션 관리
|
||||||
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
|
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ export function SectionLayoutModal({
|
||||||
}
|
}
|
||||||
}, [open, section]);
|
}, [open, section]);
|
||||||
|
|
||||||
|
|
||||||
// 섹션 업데이트 함수
|
// 섹션 업데이트 함수
|
||||||
const updateSection = (updates: Partial<FormSectionConfig>) => {
|
const updateSection = (updates: Partial<FormSectionConfig>) => {
|
||||||
setLocalSection((prev) => ({ ...prev, ...updates }));
|
setLocalSection((prev) => ({ ...prev, ...updates }));
|
||||||
|
|
@ -497,6 +499,427 @@ export function SectionLayoutModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 옵셔널 필드 그룹 */}
|
||||||
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-xs font-semibold">옵셔널 필드 그룹</h3>
|
||||||
|
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
|
||||||
|
{localSection.optionalFieldGroups?.length || 0}개
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const newGroup: OptionalFieldGroupConfig = {
|
||||||
|
id: generateUniqueId("optgroup"),
|
||||||
|
title: `옵셔널 그룹 ${(localSection.optionalFieldGroups?.length || 0) + 1}`,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
updateSection({
|
||||||
|
optionalFieldGroups: [...(localSection.optionalFieldGroups || []), newGroup],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
그룹 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HelpText>
|
||||||
|
섹션 내에서 "추가" 버튼을 눌러야 표시되는 필드 그룹입니다.
|
||||||
|
<br />
|
||||||
|
예: 해외 판매 정보 (인코텀즈, 결제조건, 통화 등)
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
{(!localSection.optionalFieldGroups || localSection.optionalFieldGroups.length === 0) ? (
|
||||||
|
<div className="text-center py-6 border border-dashed rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">옵셔널 필드 그룹이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{localSection.optionalFieldGroups.map((group, groupIndex) => (
|
||||||
|
<div key={group.id} className="border rounded-lg p-3 bg-muted/30">
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-[9px]">그룹 {groupIndex + 1}</Badge>
|
||||||
|
<span className="text-xs font-medium">{group.title}</span>
|
||||||
|
<Badge variant="secondary" className="text-[8px] px-1">
|
||||||
|
{group.fields.length}개 필드
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
updateSection({
|
||||||
|
optionalFieldGroups: localSection.optionalFieldGroups?.filter((g) => g.id !== group.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 기본 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 제목 및 설명 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px]">그룹 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={group.title}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, title: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-6 text-[9px]"
|
||||||
|
placeholder="예: 해외 판매 정보"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px]">그룹 설명</Label>
|
||||||
|
<Input
|
||||||
|
value={group.description || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, description: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-6 text-[9px]"
|
||||||
|
placeholder="해외 판매 시 추가 정보 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 및 옵션 */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[8px]">열 수</Label>
|
||||||
|
<Select
|
||||||
|
value={String(group.columns || "inherit")}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? { ...g, columns: value === "inherit" ? undefined : parseInt(value) }
|
||||||
|
: g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-0.5 h-5 text-[8px]">
|
||||||
|
<SelectValue placeholder="상속" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inherit">상속</SelectItem>
|
||||||
|
<SelectItem value="1">1열</SelectItem>
|
||||||
|
<SelectItem value="2">2열</SelectItem>
|
||||||
|
<SelectItem value="3">3열</SelectItem>
|
||||||
|
<SelectItem value="4">4열</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1 pb-0.5">
|
||||||
|
<Switch
|
||||||
|
checked={group.collapsible || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, collapsible: checked } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="scale-50"
|
||||||
|
/>
|
||||||
|
<span className="text-[8px]">접기 가능</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1 pb-0.5">
|
||||||
|
<Switch
|
||||||
|
checked={group.defaultCollapsed || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, defaultCollapsed: checked } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
disabled={!group.collapsible}
|
||||||
|
className="scale-50"
|
||||||
|
/>
|
||||||
|
<span className="text-[8px]">기본 접힘</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1 pb-0.5">
|
||||||
|
<Switch
|
||||||
|
checked={group.confirmRemove || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, confirmRemove: checked } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="scale-50"
|
||||||
|
/>
|
||||||
|
<span className="text-[8px]">제거 확인</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2" />
|
||||||
|
|
||||||
|
{/* 버튼 텍스트 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-medium">버튼 설정</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[8px]">추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={group.addButtonText || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, addButtonText: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-5 text-[8px]"
|
||||||
|
placeholder="+ 해외 판매 설정 추가"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[8px]">제거 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={group.removeButtonText || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, removeButtonText: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-5 text-[8px]"
|
||||||
|
placeholder="제거"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2" />
|
||||||
|
|
||||||
|
{/* 연동 필드 설정 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-[9px] font-medium">연동 필드 설정 (선택)</Label>
|
||||||
|
<HelpText>추가/제거 시 다른 필드의 값을 자동으로 변경합니다</HelpText>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-[8px]">연동 필드 (컬럼명)</Label>
|
||||||
|
<Input
|
||||||
|
value={group.triggerField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, triggerField: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-5 text-[8px]"
|
||||||
|
placeholder="sales_type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[8px]">추가 시 값</Label>
|
||||||
|
<Input
|
||||||
|
value={group.triggerValueOnAdd || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, triggerValueOnAdd: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-5 text-[8px]"
|
||||||
|
placeholder="해외"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-[8px]">제거 시 값</Label>
|
||||||
|
<Input
|
||||||
|
value={group.triggerValueOnRemove || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, triggerValueOnRemove: e.target.value } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="mt-0.5 h-5 text-[8px]"
|
||||||
|
placeholder="국내"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="my-2" />
|
||||||
|
|
||||||
|
{/* 그룹 내 필드 목록 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[9px] font-medium">필드 목록</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const newField: FormFieldConfig = {
|
||||||
|
...defaultFieldConfig,
|
||||||
|
id: generateFieldId(),
|
||||||
|
label: `필드 ${group.fields.length + 1}`,
|
||||||
|
columnName: `field_${group.fields.length + 1}`,
|
||||||
|
};
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id ? { ...g, fields: [...g.fields, newField] } : g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="h-5 text-[8px] px-1.5"
|
||||||
|
>
|
||||||
|
<Plus className="h-2.5 w-2.5 mr-0.5" />
|
||||||
|
필드 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.fields.length === 0 ? (
|
||||||
|
<div className="text-center py-3 border border-dashed rounded">
|
||||||
|
<p className="text-[9px] text-muted-foreground">필드를 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.fields.map((field, fieldIndex) => (
|
||||||
|
<div key={field.id} className="flex items-center gap-2 bg-white/50 rounded p-1.5">
|
||||||
|
<div className="flex-1 grid grid-cols-4 gap-1">
|
||||||
|
<Input
|
||||||
|
value={field.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
fields: g.fields.map((f) =>
|
||||||
|
f.id === field.id ? { ...f, label: e.target.value } : f
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="h-5 text-[8px]"
|
||||||
|
placeholder="라벨"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={field.columnName}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
fields: g.fields.map((f) =>
|
||||||
|
f.id === field.id ? { ...f, columnName: e.target.value } : f
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="h-5 text-[8px]"
|
||||||
|
placeholder="컬럼명"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={field.fieldType}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
fields: g.fields.map((f) =>
|
||||||
|
f.id === field.id
|
||||||
|
? { ...f, fieldType: value as FormFieldConfig["fieldType"] }
|
||||||
|
: f
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[8px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_TYPE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={String(field.gridSpan || 3)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? {
|
||||||
|
...g,
|
||||||
|
fields: g.fields.map((f) =>
|
||||||
|
f.id === field.id ? { ...f, gridSpan: parseInt(value) } : f
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-5 text-[8px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">1/4</SelectItem>
|
||||||
|
<SelectItem value="4">1/3</SelectItem>
|
||||||
|
<SelectItem value="6">1/2</SelectItem>
|
||||||
|
<SelectItem value="12">전체</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleOpenFieldDetail(field)}
|
||||||
|
className="h-5 px-1.5 text-[8px]"
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-2.5 w-2.5 mr-0.5" />
|
||||||
|
상세
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newGroups = localSection.optionalFieldGroups?.map((g) =>
|
||||||
|
g.id === group.id
|
||||||
|
? { ...g, fields: g.fields.filter((f) => f.id !== field.id) }
|
||||||
|
: g
|
||||||
|
);
|
||||||
|
updateSection({ optionalFieldGroups: newGroups });
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ export interface SelectOptionConfig {
|
||||||
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
|
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
|
||||||
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
|
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
|
||||||
filterCondition?: string;
|
filterCondition?: string;
|
||||||
// 공통코드 기반 옵션
|
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블)
|
||||||
codeCategory?: string;
|
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
|
||||||
|
categoryKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번규칙 설정
|
// 채번규칙 설정
|
||||||
|
|
@ -153,6 +154,29 @@ export interface RepeatSectionConfig {
|
||||||
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
|
confirmRemove?: boolean; // 삭제 시 확인 (기본: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 설정 (섹션 내에서 추가 버튼으로 표시되는 필드 그룹)
|
||||||
|
export interface OptionalFieldGroupConfig {
|
||||||
|
id: string; // 그룹 고유 ID
|
||||||
|
fields: FormFieldConfig[]; // 그룹에 포함된 필드들
|
||||||
|
|
||||||
|
// 섹션 스타일 설정 (활성화 시 표시되는 영역)
|
||||||
|
title: string; // 그룹 제목 (예: "해외 판매 정보")
|
||||||
|
description?: string; // 그룹 설명
|
||||||
|
columns?: number; // 필드 배치 컬럼 수 (기본: 부모 섹션 columns 상속)
|
||||||
|
collapsible?: boolean; // 접을 수 있는지 (기본: false)
|
||||||
|
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
|
||||||
|
|
||||||
|
// 버튼 설정
|
||||||
|
addButtonText?: string; // 추가 버튼 텍스트 (기본: "+ {title} 추가")
|
||||||
|
removeButtonText?: string; // 제거 버튼 텍스트 (기본: "제거")
|
||||||
|
confirmRemove?: boolean; // 제거 시 확인 (기본: false)
|
||||||
|
|
||||||
|
// 연동 필드 설정 (추가/제거 시 다른 필드 값 변경)
|
||||||
|
triggerField?: string; // 값을 변경할 필드 (columnName)
|
||||||
|
triggerValueOnAdd?: any; // 추가 시 설정할 값 (예: "해외")
|
||||||
|
triggerValueOnRemove?: any; // 제거 시 설정할 값 (예: "국내")
|
||||||
|
}
|
||||||
|
|
||||||
// 섹션 설정
|
// 섹션 설정
|
||||||
export interface FormSectionConfig {
|
export interface FormSectionConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -166,6 +190,9 @@ export interface FormSectionConfig {
|
||||||
repeatable?: boolean;
|
repeatable?: boolean;
|
||||||
repeatConfig?: RepeatSectionConfig;
|
repeatConfig?: RepeatSectionConfig;
|
||||||
|
|
||||||
|
// 옵셔널 필드 그룹 (섹션 내에서 추가 버튼으로 표시)
|
||||||
|
optionalFieldGroups?: OptionalFieldGroupConfig[];
|
||||||
|
|
||||||
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
// 연동 필드 그룹 (부서코드/부서명 등 연동 저장)
|
||||||
linkedFieldGroups?: LinkedFieldGroup[];
|
linkedFieldGroups?: LinkedFieldGroup[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export type ButtonActionType =
|
||||||
| "edit" // 편집
|
| "edit" // 편집
|
||||||
| "copy" // 복사 (품목코드 초기화)
|
| "copy" // 복사 (품목코드 초기화)
|
||||||
| "navigate" // 페이지 이동
|
| "navigate" // 페이지 이동
|
||||||
|
| "openRelatedModal" // 연관 데이터 버튼의 선택 데이터로 모달 열기
|
||||||
| "openModalWithData" // 데이터를 전달하면서 모달 열기
|
| "openModalWithData" // 데이터를 전달하면서 모달 열기
|
||||||
| "modal" // 모달 열기
|
| "modal" // 모달 열기
|
||||||
| "control" // 제어 흐름
|
| "control" // 제어 흐름
|
||||||
|
|
@ -28,7 +29,8 @@ export type ButtonActionType =
|
||||||
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
||||||
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
||||||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||||||
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
|
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||||||
|
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -211,6 +213,37 @@ export interface ButtonActionConfig {
|
||||||
maxSelection?: number; // 최대 선택 개수
|
maxSelection?: number; // 최대 선택 개수
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 연관 데이터 버튼 모달 열기 관련
|
||||||
|
relatedModalConfig?: {
|
||||||
|
targetScreenId: number; // 열릴 모달 화면 ID
|
||||||
|
componentId?: string; // 특정 RelatedDataButtons 컴포넌트 지정 (선택사항)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 즉시 저장 (Quick Insert) 관련
|
||||||
|
quickInsertConfig?: {
|
||||||
|
targetTable: string; // 저장할 테이블명
|
||||||
|
columnMappings: Array<{
|
||||||
|
targetColumn: string; // 대상 테이블의 컬럼명
|
||||||
|
sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; // 값 소스 타입
|
||||||
|
sourceComponentId?: string; // 컴포넌트에서 값을 가져올 경우 컴포넌트 ID
|
||||||
|
sourceColumnName?: string; // 컴포넌트의 columnName (formData 접근용)
|
||||||
|
sourceColumn?: string; // 좌측 패널 또는 컴포넌트의 특정 컬럼
|
||||||
|
fixedValue?: any; // 고정값
|
||||||
|
userField?: "userId" | "userName" | "companyCode"; // currentUser 타입일 때 사용할 필드
|
||||||
|
}>;
|
||||||
|
duplicateCheck?: {
|
||||||
|
enabled: boolean; // 중복 체크 활성화 여부
|
||||||
|
columns?: string[]; // 중복 체크할 컬럼들
|
||||||
|
errorMessage?: string; // 중복 시 에러 메시지
|
||||||
|
};
|
||||||
|
afterInsert?: {
|
||||||
|
refreshData?: boolean; // 저장 후 데이터 새로고침 (테이블리스트, 카드 디스플레이)
|
||||||
|
clearComponents?: boolean; // 저장 후 컴포넌트 값 초기화
|
||||||
|
showSuccessMessage?: boolean; // 성공 메시지 표시 여부 (기본: true)
|
||||||
|
successMessage?: string; // 성공 메시지
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -265,6 +298,12 @@ export interface ButtonActionContext {
|
||||||
|
|
||||||
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
|
||||||
splitPanelParentData?: Record<string, any>;
|
splitPanelParentData?: Record<string, any>;
|
||||||
|
|
||||||
|
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
|
||||||
|
splitPanelContext?: {
|
||||||
|
selectedLeftData?: Record<string, any>;
|
||||||
|
refreshRightPanel?: () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -329,6 +368,9 @@ export class ButtonActionExecutor {
|
||||||
case "openModalWithData":
|
case "openModalWithData":
|
||||||
return await this.handleOpenModalWithData(config, context);
|
return await this.handleOpenModalWithData(config, context);
|
||||||
|
|
||||||
|
case "openRelatedModal":
|
||||||
|
return await this.handleOpenRelatedModal(config, context);
|
||||||
|
|
||||||
case "modal":
|
case "modal":
|
||||||
return await this.handleModal(config, context);
|
return await this.handleModal(config, context);
|
||||||
|
|
||||||
|
|
@ -365,6 +407,9 @@ export class ButtonActionExecutor {
|
||||||
case "swap_fields":
|
case "swap_fields":
|
||||||
return await this.handleSwapFields(config, context);
|
return await this.handleSwapFields(config, context);
|
||||||
|
|
||||||
|
case "quickInsert":
|
||||||
|
return await this.handleQuickInsert(config, context);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -376,6 +421,49 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필수 항목 검증
|
||||||
|
*/
|
||||||
|
private static validateRequiredFields(context: ButtonActionContext): { isValid: boolean; missingFields: string[] } {
|
||||||
|
const missingFields: string[] = [];
|
||||||
|
const { formData, allComponents } = context;
|
||||||
|
|
||||||
|
if (!allComponents || allComponents.length === 0) {
|
||||||
|
console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵");
|
||||||
|
return { isValid: true, missingFields: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
allComponents.forEach((component: any) => {
|
||||||
|
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
|
||||||
|
const isRequired =
|
||||||
|
component.required === true ||
|
||||||
|
component.style?.required === true ||
|
||||||
|
component.componentConfig?.required === true;
|
||||||
|
|
||||||
|
const columnName = component.columnName || component.style?.columnName;
|
||||||
|
const label = component.label || component.style?.label || columnName;
|
||||||
|
|
||||||
|
if (isRequired && columnName) {
|
||||||
|
const value = formData[columnName];
|
||||||
|
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
|
||||||
|
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
|
||||||
|
console.log("🔍 [validateRequiredFields] 필수 항목 누락:", {
|
||||||
|
columnName,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
isRequired,
|
||||||
|
});
|
||||||
|
missingFields.push(label || columnName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: missingFields.length === 0,
|
||||||
|
missingFields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
||||||
*/
|
*/
|
||||||
|
|
@ -415,6 +503,19 @@ export class ButtonActionExecutor {
|
||||||
hasOnSave: !!onSave,
|
hasOnSave: !!onSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ✅ 필수 항목 검증
|
||||||
|
console.log("🔍 [handleSave] 필수 항목 검증 시작:", {
|
||||||
|
hasAllComponents: !!context.allComponents,
|
||||||
|
allComponentsLength: context.allComponents?.length || 0,
|
||||||
|
});
|
||||||
|
const requiredValidation = this.validateRequiredFields(context);
|
||||||
|
if (!requiredValidation.isValid) {
|
||||||
|
console.log("❌ [handleSave] 필수 항목 누락:", requiredValidation.missingFields);
|
||||||
|
toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log("✅ [handleSave] 필수 항목 검증 통과");
|
||||||
|
|
||||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행");
|
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행");
|
||||||
|
|
@ -872,6 +973,20 @@ export class ButtonActionExecutor {
|
||||||
itemCount: parsedData.length,
|
itemCount: parsedData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 범용 폼 모달의 공통 필드 추출 (order_no, manager_id 등)
|
||||||
|
// "범용_폼_모달" 키에서 공통 필드를 가져옴
|
||||||
|
const universalFormData = context.formData["범용_폼_모달"] as Record<string, unknown> | undefined;
|
||||||
|
const commonFields: Record<string, unknown> = {};
|
||||||
|
if (universalFormData && typeof universalFormData === "object") {
|
||||||
|
// 공통 필드 복사 (내부 메타 필드 제외)
|
||||||
|
for (const [key, value] of Object.entries(universalFormData)) {
|
||||||
|
if (!key.startsWith("_") && !key.endsWith("_numberingRuleId") && value !== undefined && value !== "") {
|
||||||
|
commonFields[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields);
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of parsedData) {
|
for (const item of parsedData) {
|
||||||
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||||||
|
|
||||||
|
|
@ -889,9 +1004,11 @@ export class ButtonActionExecutor {
|
||||||
delete dataToSave.id;
|
delete dataToSave.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 정보 추가
|
// 🆕 공통 필드 병합 + 사용자 정보 추가
|
||||||
|
// 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선)
|
||||||
const dataWithMeta: Record<string, unknown> = {
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
...dataToSave,
|
...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등)
|
||||||
|
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
|
||||||
created_by: context.userId,
|
created_by: context.userId,
|
||||||
updated_by: context.userId,
|
updated_by: context.userId,
|
||||||
company_code: context.companyCode,
|
company_code: context.companyCode,
|
||||||
|
|
@ -1778,6 +1895,100 @@ export class ButtonActionExecutor {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연관 데이터 버튼의 선택 데이터로 모달 열기
|
||||||
|
* RelatedDataButtons 컴포넌트에서 선택된 버튼 데이터를 모달로 전달
|
||||||
|
*/
|
||||||
|
private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
|
||||||
|
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
|
||||||
|
|
||||||
|
console.log("🔍 [openRelatedModal] 설정 확인:", {
|
||||||
|
config,
|
||||||
|
relatedModalConfig: config.relatedModalConfig,
|
||||||
|
targetScreenId: config.targetScreenId,
|
||||||
|
finalTargetScreenId: targetScreenId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetScreenId) {
|
||||||
|
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
|
||||||
|
toast.error("모달 화면 ID가 설정되지 않았습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelatedDataButtons에서 선택된 데이터 가져오기
|
||||||
|
const relatedData = window.__relatedButtonsSelectedData;
|
||||||
|
|
||||||
|
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
|
||||||
|
relatedData,
|
||||||
|
selectedItem: relatedData?.selectedItem,
|
||||||
|
config: relatedData?.config,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!relatedData?.selectedItem) {
|
||||||
|
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
|
||||||
|
toast.warning("먼저 버튼을 선택해주세요.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selectedItem, config: relatedConfig } = relatedData;
|
||||||
|
|
||||||
|
// 데이터 매핑 적용
|
||||||
|
const initialData: Record<string, any> = {};
|
||||||
|
|
||||||
|
console.log("🔍 [openRelatedModal] 매핑 설정:", {
|
||||||
|
modalLink: relatedConfig?.modalLink,
|
||||||
|
dataMapping: relatedConfig?.modalLink?.dataMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
|
||||||
|
relatedConfig.modalLink.dataMapping.forEach(mapping => {
|
||||||
|
console.log("🔍 [openRelatedModal] 매핑 처리:", {
|
||||||
|
mapping,
|
||||||
|
sourceField: mapping.sourceField,
|
||||||
|
targetField: mapping.targetField,
|
||||||
|
selectedItemValue: selectedItem.value,
|
||||||
|
selectedItemId: selectedItem.id,
|
||||||
|
rawDataValue: selectedItem.rawData[mapping.sourceField],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mapping.sourceField === "value") {
|
||||||
|
initialData[mapping.targetField] = selectedItem.value;
|
||||||
|
} else if (mapping.sourceField === "id") {
|
||||||
|
initialData[mapping.targetField] = selectedItem.id;
|
||||||
|
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
|
||||||
|
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 기본 매핑: id를 routing_version_id로 전달
|
||||||
|
console.log("🔍 [openRelatedModal] 기본 매핑 사용");
|
||||||
|
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📤 [openRelatedModal] 모달 열기:", {
|
||||||
|
targetScreenId,
|
||||||
|
selectedItem,
|
||||||
|
initialData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
|
||||||
|
window.dispatchEvent(new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: targetScreenId,
|
||||||
|
title: config.modalTitle,
|
||||||
|
description: config.modalDescription,
|
||||||
|
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
|
||||||
|
onSuccess: () => {
|
||||||
|
// 성공 후 데이터 새로고침
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTableData"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 액션 처리
|
* 모달 액션 처리
|
||||||
* 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용)
|
* 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용)
|
||||||
|
|
@ -5190,6 +5401,313 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 즉시 저장 (Quick Insert) 액션 처리
|
||||||
|
* 화면에서 선택한 데이터를 특정 테이블에 즉시 저장
|
||||||
|
*/
|
||||||
|
private static async handleQuickInsert(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log("⚡ Quick Insert 액션 실행:", { config, context });
|
||||||
|
|
||||||
|
const quickInsertConfig = config.quickInsertConfig;
|
||||||
|
if (!quickInsertConfig?.targetTable) {
|
||||||
|
toast.error("대상 테이블이 설정되지 않았습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { formData, splitPanelContext, userId, userName, companyCode } = context;
|
||||||
|
|
||||||
|
console.log("⚡ Quick Insert 상세 정보:", {
|
||||||
|
targetTable: quickInsertConfig.targetTable,
|
||||||
|
columnMappings: quickInsertConfig.columnMappings,
|
||||||
|
formData: formData,
|
||||||
|
formDataKeys: Object.keys(formData || {}),
|
||||||
|
splitPanelContext: splitPanelContext,
|
||||||
|
selectedLeftData: splitPanelContext?.selectedLeftData,
|
||||||
|
allComponents: context.allComponents,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컬럼 매핑에 따라 저장할 데이터 구성
|
||||||
|
const insertData: Record<string, any> = {};
|
||||||
|
const columnMappings = quickInsertConfig.columnMappings || [];
|
||||||
|
|
||||||
|
for (const mapping of columnMappings) {
|
||||||
|
console.log(`📍 매핑 처리 시작:`, mapping);
|
||||||
|
|
||||||
|
if (!mapping.targetColumn) {
|
||||||
|
console.log(`📍 targetColumn 없음, 스킵`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: any = undefined;
|
||||||
|
|
||||||
|
switch (mapping.sourceType) {
|
||||||
|
case "component":
|
||||||
|
console.log(`📍 component 타입 처리:`, {
|
||||||
|
sourceComponentId: mapping.sourceComponentId,
|
||||||
|
sourceColumnName: mapping.sourceColumnName,
|
||||||
|
targetColumn: mapping.targetColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트의 현재 값
|
||||||
|
if (mapping.sourceComponentId) {
|
||||||
|
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
|
||||||
|
if (mapping.sourceColumnName) {
|
||||||
|
value = formData?.[mapping.sourceColumnName];
|
||||||
|
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 없으면 컴포넌트 ID로 직접 찾기
|
||||||
|
if (value === undefined) {
|
||||||
|
value = formData?.[mapping.sourceComponentId];
|
||||||
|
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
|
||||||
|
if (value === undefined && context.allComponents) {
|
||||||
|
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||||||
|
console.log(`📍 방법3 찾은 컴포넌트:`, comp);
|
||||||
|
if (comp?.columnName) {
|
||||||
|
value = formData?.[comp.columnName];
|
||||||
|
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId} → ${comp.columnName} = ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
|
||||||
|
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
|
||||||
|
value = formData[mapping.targetColumn];
|
||||||
|
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
|
||||||
|
if (value === undefined) {
|
||||||
|
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceColumn이 지정된 경우 해당 속성 추출
|
||||||
|
if (mapping.sourceColumn && value && typeof value === "object") {
|
||||||
|
value = value[mapping.sourceColumn];
|
||||||
|
console.log(`📍 sourceColumn 추출: ${mapping.sourceColumn} = ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "leftPanel":
|
||||||
|
console.log(`📍 leftPanel 타입 처리:`, {
|
||||||
|
sourceColumn: mapping.sourceColumn,
|
||||||
|
selectedLeftData: splitPanelContext?.selectedLeftData,
|
||||||
|
});
|
||||||
|
// 좌측 패널 선택 데이터
|
||||||
|
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
|
||||||
|
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
|
||||||
|
console.log(`📍 leftPanel 값: ${mapping.sourceColumn} = ${value}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "fixed":
|
||||||
|
console.log(`📍 fixed 타입 처리: fixedValue = ${mapping.fixedValue}`);
|
||||||
|
// 고정값
|
||||||
|
value = mapping.fixedValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "currentUser":
|
||||||
|
console.log(`📍 currentUser 타입 처리: userField = ${mapping.userField}`);
|
||||||
|
// 현재 사용자 정보
|
||||||
|
switch (mapping.userField) {
|
||||||
|
case "userId":
|
||||||
|
value = userId;
|
||||||
|
break;
|
||||||
|
case "userName":
|
||||||
|
value = userName;
|
||||||
|
break;
|
||||||
|
case "companyCode":
|
||||||
|
value = companyCode;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
console.log(`📍 currentUser 값: ${value}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
|
||||||
|
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
insertData[mapping.targetColumn] = value;
|
||||||
|
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
|
||||||
|
} else {
|
||||||
|
console.log(`📍 값이 비어있어서 insertData에 추가 안됨`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 좌측 패널 선택 데이터에서 자동 매핑 (대상 테이블에 존재하는 컬럼만)
|
||||||
|
if (splitPanelContext?.selectedLeftData) {
|
||||||
|
const leftData = splitPanelContext.selectedLeftData;
|
||||||
|
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
||||||
|
|
||||||
|
// 대상 테이블의 컬럼 목록 조회
|
||||||
|
let targetTableColumns: string[] = [];
|
||||||
|
try {
|
||||||
|
const columnsResponse = await apiClient.get(
|
||||||
|
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
|
||||||
|
);
|
||||||
|
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||||
|
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
||||||
|
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
|
||||||
|
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("대상 테이블 컬럼 조회 실패:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(leftData)) {
|
||||||
|
// 이미 매핑된 컬럼은 스킵
|
||||||
|
if (insertData[key] !== undefined) {
|
||||||
|
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
||||||
|
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
|
||||||
|
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
|
||||||
|
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
|
||||||
|
if (systemColumns.includes(key)) {
|
||||||
|
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
||||||
|
if (key.endsWith('_label') || key.endsWith('_name')) {
|
||||||
|
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값이 있으면 자동 추가
|
||||||
|
if (val !== undefined && val !== null && val !== '') {
|
||||||
|
insertData[key] = val;
|
||||||
|
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("⚡ Quick Insert 최종 데이터:", insertData, "키 개수:", Object.keys(insertData).length);
|
||||||
|
|
||||||
|
// 필수 데이터 검증
|
||||||
|
if (Object.keys(insertData).length === 0) {
|
||||||
|
toast.error("저장할 데이터가 없습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
console.log("📍 중복 체크 설정:", {
|
||||||
|
enabled: quickInsertConfig.duplicateCheck?.enabled,
|
||||||
|
columns: quickInsertConfig.duplicateCheck?.columns,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
||||||
|
const duplicateCheckData: Record<string, any> = {};
|
||||||
|
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
||||||
|
if (insertData[col] !== undefined) {
|
||||||
|
// 백엔드가 { value, operator } 형식을 기대하므로 변환
|
||||||
|
duplicateCheckData[col] = { value: insertData[col], operator: "equals" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📍 중복 체크 조건:", duplicateCheckData);
|
||||||
|
|
||||||
|
if (Object.keys(duplicateCheckData).length > 0) {
|
||||||
|
try {
|
||||||
|
const checkResponse = await apiClient.post(
|
||||||
|
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
search: duplicateCheckData,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📍 중복 체크 응답:", checkResponse.data);
|
||||||
|
|
||||||
|
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
|
||||||
|
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
|
||||||
|
console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0);
|
||||||
|
|
||||||
|
if (Array.isArray(existingData) && existingData.length > 0) {
|
||||||
|
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("중복 체크 오류:", error);
|
||||||
|
// 중복 체크 실패해도 저장은 시도
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("📍 중복 체크 비활성화 또는 컬럼 미설정");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 저장
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
||||||
|
insertData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
console.log("✅ Quick Insert 저장 성공");
|
||||||
|
|
||||||
|
// 저장 후 동작 설정 로그
|
||||||
|
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
|
||||||
|
|
||||||
|
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
|
||||||
|
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
|
||||||
|
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
|
||||||
|
console.log("📍 데이터 새로고침 여부:", shouldRefresh);
|
||||||
|
|
||||||
|
if (shouldRefresh) {
|
||||||
|
console.log("📍 데이터 새로고침 이벤트 발송");
|
||||||
|
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
||||||
|
console.log("✅ refreshTable, refreshCardDisplay 이벤트 발송 완료");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 값 초기화
|
||||||
|
if (quickInsertConfig.afterInsert?.clearComponents && context.onFormDataChange) {
|
||||||
|
for (const mapping of columnMappings) {
|
||||||
|
if (mapping.sourceType === "component" && mapping.sourceComponentId) {
|
||||||
|
// sourceColumnName이 있으면 그것을 사용, 없으면 sourceComponentId 사용
|
||||||
|
const fieldName = mapping.sourceColumnName || mapping.sourceComponentId;
|
||||||
|
context.onFormDataChange(fieldName, null);
|
||||||
|
console.log(`📍 컴포넌트 값 초기화: ${fieldName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
|
||||||
|
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(response.data?.message || "저장에 실패했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ Quick Insert 오류:", error);
|
||||||
|
toast.error(error.response?.data?.message || "저장 중 오류가 발생했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
|
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
|
||||||
* 🆕 위치정보 수집 기능 추가
|
* 🆕 위치정보 수집 기능 추가
|
||||||
|
|
@ -5643,4 +6161,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
||||||
successMessage: "필드 값이 교환되었습니다.",
|
successMessage: "필드 값이 교환되었습니다.",
|
||||||
errorMessage: "필드 값 교환 중 오류가 발생했습니다.",
|
errorMessage: "필드 값 교환 중 오류가 발생했습니다.",
|
||||||
},
|
},
|
||||||
|
quickInsert: {
|
||||||
|
type: "quickInsert",
|
||||||
|
successMessage: "저장되었습니다.",
|
||||||
|
errorMessage: "저장 중 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,6 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
|
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
|
||||||
const isSimpleConfigPanel = [
|
const isSimpleConfigPanel = [
|
||||||
"autocomplete-search-input",
|
"autocomplete-search-input",
|
||||||
"entity-search-input",
|
|
||||||
"modal-repeater-table",
|
"modal-repeater-table",
|
||||||
"conditional-container",
|
"conditional-container",
|
||||||
].includes(componentId);
|
].includes(componentId);
|
||||||
|
|
@ -406,6 +405,19 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
|
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entity-search-input은 currentComponent 정보 필요 (참조 테이블 자동 로드용)
|
||||||
|
// 그리고 allComponents 필요 (연쇄관계 부모 필드 선택용)
|
||||||
|
if (componentId === "entity-search-input") {
|
||||||
|
return (
|
||||||
|
<ConfigPanelComponent
|
||||||
|
config={config}
|
||||||
|
onConfigChange={onChange}
|
||||||
|
currentComponent={currentComponent}
|
||||||
|
allComponents={allComponents}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 selected-items-detail-input은 특별한 props 사용
|
// 🆕 selected-items-detail-input은 특별한 props 사용
|
||||||
if (componentId === "selected-items-detail-input") {
|
if (componentId === "selected-items-detail-input") {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
|
||||||
// 기타
|
// 기타
|
||||||
label: "text-display",
|
label: "text-display",
|
||||||
code: "select-basic", // 코드 타입은 선택상자 사용
|
code: "select-basic", // 코드 타입은 선택상자 사용
|
||||||
entity: "select-basic", // 엔티티 타입은 선택상자 사용
|
entity: "entity-search-input", // 엔티티 타입은 전용 검색 입력 사용
|
||||||
category: "select-basic", // 카테고리 타입은 선택상자 사용
|
category: "select-basic", // 카테고리 타입은 선택상자 사용
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@
|
||||||
import { WebType } from "./unified-core";
|
import { WebType } from "./unified-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 9개 핵심 입력 타입
|
* 핵심 입력 타입
|
||||||
*/
|
*/
|
||||||
export type BaseInputType =
|
export type BaseInputType =
|
||||||
| "text" // 텍스트
|
| "text" // 텍스트
|
||||||
|
| "textarea" // 텍스트 에리어 (여러 줄)
|
||||||
| "number" // 숫자
|
| "number" // 숫자
|
||||||
| "date" // 날짜
|
| "date" // 날짜
|
||||||
| "code" // 코드
|
| "code" // 코드
|
||||||
|
|
@ -34,16 +35,18 @@ export interface DetailTypeOption {
|
||||||
* 입력 타입별 세부 타입 매핑
|
* 입력 타입별 세부 타입 매핑
|
||||||
*/
|
*/
|
||||||
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]> = {
|
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]> = {
|
||||||
// 텍스트 → text, email, tel, url, textarea, password
|
// 텍스트 → text, email, tel, url, password
|
||||||
text: [
|
text: [
|
||||||
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
|
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
|
||||||
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
|
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
|
||||||
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
|
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
|
||||||
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
|
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
|
||||||
{ value: "textarea", label: "여러 줄 텍스트", description: "긴 텍스트 입력" },
|
|
||||||
{ value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" },
|
{ value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 텍스트 에리어 → textarea
|
||||||
|
textarea: [{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" }],
|
||||||
|
|
||||||
// 숫자 → number, decimal, currency, percentage
|
// 숫자 → number, decimal, currency, percentage
|
||||||
number: [
|
number: [
|
||||||
{ value: "number", label: "정수", description: "정수 숫자 입력" },
|
{ value: "number", label: "정수", description: "정수 숫자 입력" },
|
||||||
|
|
@ -102,8 +105,13 @@ export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>
|
||||||
* 웹타입에서 기본 입력 타입 추출
|
* 웹타입에서 기본 입력 타입 추출
|
||||||
*/
|
*/
|
||||||
export function getBaseInputType(webType: WebType): BaseInputType {
|
export function getBaseInputType(webType: WebType): BaseInputType {
|
||||||
|
// textarea (별도 타입으로 분리)
|
||||||
|
if (webType === "textarea") {
|
||||||
|
return "textarea";
|
||||||
|
}
|
||||||
|
|
||||||
// text 계열
|
// text 계열
|
||||||
if (["text", "email", "tel", "url", "textarea", "password"].includes(webType)) {
|
if (["text", "email", "tel", "url", "password"].includes(webType)) {
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,6 +175,7 @@ export function getDefaultDetailType(baseInputType: BaseInputType): WebType {
|
||||||
*/
|
*/
|
||||||
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
|
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
|
||||||
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
|
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
|
||||||
|
{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" },
|
||||||
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
|
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
|
||||||
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
|
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
|
||||||
{ value: "code", label: "코드", description: "공통 코드 선택" },
|
{ value: "code", label: "코드", description: "공통 코드 선택" },
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
* 주의: 이 파일을 수정할 때는 반드시 백엔드 타입도 함께 업데이트 해야 합니다.
|
* 주의: 이 파일을 수정할 때는 반드시 백엔드 타입도 함께 업데이트 해야 합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 9개 핵심 입력 타입
|
// 핵심 입력 타입
|
||||||
export type InputType =
|
export type InputType =
|
||||||
| "text" // 텍스트
|
| "text" // 텍스트
|
||||||
|
| "textarea" // 텍스트 에리어 (여러 줄 입력)
|
||||||
| "number" // 숫자
|
| "number" // 숫자
|
||||||
| "date" // 날짜
|
| "date" // 날짜
|
||||||
| "code" // 코드
|
| "code" // 코드
|
||||||
|
|
@ -42,6 +43,13 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
|
||||||
category: "basic",
|
category: "basic",
|
||||||
icon: "Type",
|
icon: "Type",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "textarea",
|
||||||
|
label: "텍스트 에리어",
|
||||||
|
description: "여러 줄 텍스트 입력",
|
||||||
|
category: "basic",
|
||||||
|
icon: "AlignLeft",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "number",
|
value: "number",
|
||||||
label: "숫자",
|
label: "숫자",
|
||||||
|
|
@ -130,6 +138,11 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>>
|
||||||
maxLength: 500,
|
maxLength: 500,
|
||||||
placeholder: "텍스트를 입력하세요",
|
placeholder: "텍스트를 입력하세요",
|
||||||
},
|
},
|
||||||
|
textarea: {
|
||||||
|
maxLength: 2000,
|
||||||
|
rows: 4,
|
||||||
|
placeholder: "내용을 입력하세요",
|
||||||
|
},
|
||||||
number: {
|
number: {
|
||||||
min: 0,
|
min: 0,
|
||||||
step: 1,
|
step: 1,
|
||||||
|
|
@ -163,13 +176,17 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>>
|
||||||
radio: {
|
radio: {
|
||||||
inline: false,
|
inline: false,
|
||||||
},
|
},
|
||||||
|
image: {
|
||||||
|
placeholder: "이미지를 선택하세요",
|
||||||
|
accept: "image/*",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레거시 웹 타입 → 입력 타입 매핑
|
// 레거시 웹 타입 → 입력 타입 매핑
|
||||||
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
|
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
|
||||||
// 텍스트 관련
|
// 텍스트 관련
|
||||||
text: "text",
|
text: "text",
|
||||||
textarea: "text",
|
textarea: "textarea",
|
||||||
email: "text",
|
email: "text",
|
||||||
tel: "text",
|
tel: "text",
|
||||||
url: "text",
|
url: "text",
|
||||||
|
|
@ -204,6 +221,7 @@ export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
|
||||||
// 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용)
|
// 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용)
|
||||||
export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
|
export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
|
||||||
text: "text",
|
text: "text",
|
||||||
|
textarea: "textarea",
|
||||||
number: "number",
|
number: "number",
|
||||||
date: "date",
|
date: "date",
|
||||||
code: "code",
|
code: "code",
|
||||||
|
|
@ -212,6 +230,7 @@ export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
|
||||||
select: "select",
|
select: "select",
|
||||||
checkbox: "checkbox",
|
checkbox: "checkbox",
|
||||||
radio: "radio",
|
radio: "radio",
|
||||||
|
image: "image",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 입력 타입 변환 함수
|
// 입력 타입 변환 함수
|
||||||
|
|
@ -226,6 +245,11 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
|
||||||
trim: true,
|
trim: true,
|
||||||
maxLength: 500,
|
maxLength: 500,
|
||||||
},
|
},
|
||||||
|
textarea: {
|
||||||
|
type: "string",
|
||||||
|
trim: true,
|
||||||
|
maxLength: 2000,
|
||||||
|
},
|
||||||
number: {
|
number: {
|
||||||
type: "number",
|
type: "number",
|
||||||
allowFloat: true,
|
allowFloat: true,
|
||||||
|
|
@ -258,4 +282,8 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
|
||||||
type: "string",
|
type: "string",
|
||||||
options: true,
|
options: true,
|
||||||
},
|
},
|
||||||
|
image: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,8 @@ export interface EntityTypeConfig {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
displayFormat?: "simple" | "detailed" | "custom"; // 표시 형식
|
displayFormat?: "simple" | "detailed" | "custom"; // 표시 형식
|
||||||
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||||
|
// UI 모드
|
||||||
|
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -428,6 +430,111 @@ export interface ButtonTypeConfig {
|
||||||
// ButtonActionType과 관련된 설정은 control-management.ts에서 정의
|
// ButtonActionType과 관련된 설정은 control-management.ts에서 정의
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 즉시 저장(quickInsert) 설정 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 즉시 저장 컬럼 매핑 설정
|
||||||
|
* 저장할 테이블의 각 컬럼에 대해 값을 어디서 가져올지 정의
|
||||||
|
*/
|
||||||
|
export interface QuickInsertColumnMapping {
|
||||||
|
/** 저장할 테이블의 대상 컬럼명 */
|
||||||
|
targetColumn: string;
|
||||||
|
|
||||||
|
/** 값 소스 타입 */
|
||||||
|
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
|
||||||
|
|
||||||
|
// sourceType별 추가 설정
|
||||||
|
/** component: 값을 가져올 컴포넌트 ID */
|
||||||
|
sourceComponentId?: string;
|
||||||
|
|
||||||
|
/** component: 컴포넌트의 columnName (formData 접근용) */
|
||||||
|
sourceColumnName?: string;
|
||||||
|
|
||||||
|
/** leftPanel: 좌측 선택 데이터의 컬럼명 */
|
||||||
|
sourceColumn?: string;
|
||||||
|
|
||||||
|
/** fixed: 고정값 */
|
||||||
|
fixedValue?: any;
|
||||||
|
|
||||||
|
/** currentUser: 사용자 정보 필드 */
|
||||||
|
userField?: "userId" | "userName" | "companyCode" | "deptCode";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 즉시 저장 후 동작 설정
|
||||||
|
*/
|
||||||
|
export interface QuickInsertAfterAction {
|
||||||
|
/** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */
|
||||||
|
refreshData?: boolean;
|
||||||
|
|
||||||
|
/** 초기화할 컴포넌트 ID 목록 */
|
||||||
|
clearComponents?: string[];
|
||||||
|
|
||||||
|
/** 성공 메시지 표시 여부 */
|
||||||
|
showSuccessMessage?: boolean;
|
||||||
|
|
||||||
|
/** 커스텀 성공 메시지 */
|
||||||
|
successMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 체크 설정
|
||||||
|
*/
|
||||||
|
export interface QuickInsertDuplicateCheck {
|
||||||
|
/** 중복 체크 활성화 */
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
/** 중복 체크할 컬럼들 */
|
||||||
|
columns: string[];
|
||||||
|
|
||||||
|
/** 중복 시 에러 메시지 */
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 즉시 저장(quickInsert) 버튼 액션 설정
|
||||||
|
*
|
||||||
|
* 화면에서 entity 타입 선택박스로 데이터를 선택한 후,
|
||||||
|
* 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const config: QuickInsertConfig = {
|
||||||
|
* targetTable: "process_equipment",
|
||||||
|
* columnMappings: [
|
||||||
|
* {
|
||||||
|
* targetColumn: "equipment_code",
|
||||||
|
* sourceType: "component",
|
||||||
|
* sourceComponentId: "equipment-select"
|
||||||
|
* },
|
||||||
|
* {
|
||||||
|
* targetColumn: "process_code",
|
||||||
|
* sourceType: "leftPanel",
|
||||||
|
* sourceColumn: "process_code"
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* afterInsert: {
|
||||||
|
* refreshData: true,
|
||||||
|
* clearComponents: ["equipment-select"],
|
||||||
|
* showSuccessMessage: true
|
||||||
|
* }
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface QuickInsertConfig {
|
||||||
|
/** 저장할 대상 테이블명 */
|
||||||
|
targetTable: string;
|
||||||
|
|
||||||
|
/** 컬럼 매핑 설정 */
|
||||||
|
columnMappings: QuickInsertColumnMapping[];
|
||||||
|
|
||||||
|
/** 저장 후 동작 설정 */
|
||||||
|
afterInsert?: QuickInsertAfterAction;
|
||||||
|
|
||||||
|
/** 중복 체크 설정 (선택사항) */
|
||||||
|
duplicateCheck?: QuickInsertDuplicateCheck;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 플로우 단계별 버튼 표시 설정
|
* 플로우 단계별 버튼 표시 설정
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,16 @@ export interface TableRegistration {
|
||||||
columns: TableColumn[];
|
columns: TableColumn[];
|
||||||
dataCount?: number; // 현재 표시된 데이터 건수
|
dataCount?: number; // 현재 표시된 데이터 건수
|
||||||
|
|
||||||
|
// 탭 관련 정보 (탭 내부에 있는 테이블의 경우)
|
||||||
|
parentTabId?: string; // 부모 탭 ID
|
||||||
|
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
|
||||||
|
screenId?: number; // 소속 화면 ID
|
||||||
|
|
||||||
// 콜백 함수들
|
// 콜백 함수들
|
||||||
onFilterChange: (filters: TableFilter[]) => void;
|
onFilterChange: (filters: TableFilter[]) => void;
|
||||||
onGroupChange: (groups: string[]) => void;
|
onGroupChange: (groups: string[]) => void;
|
||||||
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
||||||
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경
|
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
|
||||||
|
|
||||||
// 데이터 조회 함수 (선택 타입 필터용)
|
// 데이터 조회 함수 (선택 타입 필터용)
|
||||||
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
||||||
|
|
@ -77,4 +82,8 @@ export interface TableOptionsContextValue {
|
||||||
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
|
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
|
||||||
selectedTableId: string | null;
|
selectedTableId: string | null;
|
||||||
setSelectedTableId: (tableId: string | null) => void;
|
setSelectedTableId: (tableId: string | null) => void;
|
||||||
|
|
||||||
|
// 활성 탭 기반 필터링
|
||||||
|
getActiveTabTables: () => TableRegistration[]; // 현재 활성 탭의 테이블만 반환
|
||||||
|
getTablesForTab: (tabId: string) => TableRegistration[]; // 특정 탭의 테이블만 반환
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,9 @@ export type ButtonActionType =
|
||||||
// 제어관리 전용
|
// 제어관리 전용
|
||||||
| "control"
|
| "control"
|
||||||
// 데이터 전달
|
// 데이터 전달
|
||||||
| "transferData"; // 선택된 데이터를 다른 컴포넌트/화면으로 전달
|
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
|
||||||
|
// 즉시 저장
|
||||||
|
| "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 타입 정의
|
* 컴포넌트 타입 정의
|
||||||
|
|
@ -328,6 +330,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
|
||||||
"newWindow",
|
"newWindow",
|
||||||
"control",
|
"control",
|
||||||
"transferData",
|
"transferData",
|
||||||
|
"quickInsert",
|
||||||
];
|
];
|
||||||
return actionTypes.includes(value as ButtonActionType);
|
return actionTypes.includes(value as ButtonActionType);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1685,3 +1685,5 @@ const 출고등록_설정: ScreenSplitPanel = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -532,3 +532,5 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -519,3 +519,5 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue