Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
b05a883353
|
|
@ -193,10 +193,11 @@ export class EntityJoinController {
|
||||||
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
logger.info(`Entity 조인 설정 조회: ${tableName}`);
|
logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||||
|
|
||||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -224,11 +225,12 @@ export class EntityJoinController {
|
||||||
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
|
logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||||
|
|
||||||
const columns =
|
const columns =
|
||||||
await tableManagementService.getReferenceTableColumns(tableName);
|
await tableManagementService.getReferenceTableColumns(tableName, companyCode);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -408,11 +410,12 @@ export class EntityJoinController {
|
||||||
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
|
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||||
|
|
||||||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||||
|
|
||||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||||
|
|
@ -439,7 +442,7 @@ export class EntityJoinController {
|
||||||
try {
|
try {
|
||||||
const columns =
|
const columns =
|
||||||
await tableManagementService.getReferenceTableColumns(
|
await tableManagementService.getReferenceTableColumns(
|
||||||
config.referenceTable
|
config.referenceTable, companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||||
|
|
|
||||||
|
|
@ -742,6 +742,7 @@ router.post(
|
||||||
inserted: result.data?.inserted || 0,
|
inserted: result.data?.inserted || 0,
|
||||||
updated: result.data?.updated || 0,
|
updated: result.data?.updated || 0,
|
||||||
deleted: result.data?.deleted || 0,
|
deleted: result.data?.deleted || 0,
|
||||||
|
savedIds: result.data?.savedIds || [],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
||||||
|
|
|
||||||
|
|
@ -1519,11 +1519,12 @@ class DataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
|
const savedIds = Array.from(processedIds);
|
||||||
|
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { inserted, updated, deleted },
|
data: { inserted, updated, deleted, savedIds },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`UPSERT 오류 (${tableName}):`, error);
|
console.error(`UPSERT 오류 (${tableName}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,18 @@ export class EntityJoinService {
|
||||||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
|
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
|
||||||
|
* @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회)
|
||||||
*/
|
*/
|
||||||
async detectEntityJoins(
|
async detectEntityJoins(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
screenEntityConfigs?: Record<string, any>
|
screenEntityConfigs?: Record<string, any>,
|
||||||
|
companyCode?: string
|
||||||
): Promise<EntityJoinConfig[]> {
|
): Promise<EntityJoinConfig[]> {
|
||||||
try {
|
try {
|
||||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`);
|
||||||
|
|
||||||
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
|
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
|
||||||
// company_code = '*' (공통 설정) 우선 조회
|
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
|
||||||
const entityColumns = await query<{
|
const entityColumns = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
input_type: string;
|
input_type: string;
|
||||||
|
|
@ -33,14 +35,17 @@ export class EntityJoinService {
|
||||||
reference_column: string;
|
reference_column: string;
|
||||||
display_column: string | null;
|
display_column: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, input_type, reference_table, reference_column, display_column
|
`SELECT DISTINCT ON (column_name)
|
||||||
|
column_name, input_type, reference_table, reference_column, display_column
|
||||||
FROM table_type_columns
|
FROM table_type_columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND input_type IN ('entity', 'category')
|
AND input_type IN ('entity', 'category')
|
||||||
AND company_code = '*'
|
|
||||||
AND reference_table IS NOT NULL
|
AND reference_table IS NOT NULL
|
||||||
AND reference_table != ''`,
|
AND reference_table != ''
|
||||||
[tableName]
|
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||||
|
ORDER BY column_name,
|
||||||
|
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||||
|
companyCode ? [tableName, companyCode] : [tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||||
|
|
@ -272,7 +277,8 @@ export class EntityJoinService {
|
||||||
orderBy: string = "",
|
orderBy: string = "",
|
||||||
limit?: number,
|
limit?: number,
|
||||||
offset?: number,
|
offset?: number,
|
||||||
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
|
columnTypes?: Map<string, string>, // 컬럼명 → 데이터 타입 매핑
|
||||||
|
referenceTableColumns?: Map<string, string[]> // 🆕 참조 테이블별 전체 컬럼 목록
|
||||||
): { query: string; aliasMap: Map<string, string> } {
|
): { query: string; aliasMap: Map<string, string> } {
|
||||||
try {
|
try {
|
||||||
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
||||||
|
|
@ -338,115 +344,100 @@ export class EntityJoinService {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔧 _label 별칭 중복 방지를 위한 Set
|
// 🔧 생성된 별칭 중복 방지를 위한 Set
|
||||||
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
|
const generatedAliases = new Set<string>();
|
||||||
const generatedLabelAliases = new Set<string>();
|
|
||||||
|
|
||||||
const joinColumns = joinConfigs
|
const joinColumns = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
const alias = aliasMap.get(aliasKey);
|
const alias = aliasMap.get(aliasKey);
|
||||||
const displayColumns = config.displayColumns || [
|
|
||||||
config.displayColumn,
|
|
||||||
];
|
|
||||||
const separator = config.separator || " - ";
|
|
||||||
|
|
||||||
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
|
||||||
const resultColumns: string[] = [];
|
const resultColumns: string[] = [];
|
||||||
|
|
||||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT
|
||||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
const refTableCols = referenceTableColumns?.get(
|
||||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
`${config.referenceTable}:${config.sourceColumn}`
|
||||||
resultColumns.push(
|
) || referenceTableColumns?.get(config.referenceTable);
|
||||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
|
||||||
);
|
|
||||||
} else if (displayColumns.length === 1) {
|
|
||||||
// 단일 컬럼인 경우
|
|
||||||
const col = displayColumns[0];
|
|
||||||
|
|
||||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
if (refTableCols && refTableCols.length > 0) {
|
||||||
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
// 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요)
|
||||||
const isJoinTableColumn =
|
const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]);
|
||||||
config.referenceTable && config.referenceTable !== tableName;
|
|
||||||
|
for (const col of refTableCols) {
|
||||||
|
if (skipColumns.has(col)) continue;
|
||||||
|
|
||||||
|
const colAlias = `${config.sourceColumn}_${col}`;
|
||||||
|
if (generatedAliases.has(colAlias)) continue;
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
|
||||||
resultColumns.push(
|
resultColumns.push(
|
||||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
`COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"`
|
||||||
);
|
);
|
||||||
|
generatedAliases.add(colAlias);
|
||||||
|
}
|
||||||
|
|
||||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
// _label 필드도 추가 (기존 호환성)
|
||||||
// sourceColumn_label 형식으로 추가
|
const labelAlias = `${config.sourceColumn}_label`;
|
||||||
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
|
if (!generatedAliases.has(labelAlias)) {
|
||||||
const labelAlias = `${config.sourceColumn}_label`;
|
// 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn
|
||||||
if (!generatedLabelAliases.has(labelAlias)) {
|
const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name");
|
||||||
resultColumns.push(
|
const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn;
|
||||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
|
||||||
);
|
|
||||||
generatedLabelAliases.add(labelAlias);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
|
||||||
// 예: customer_code, item_number 등
|
|
||||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
|
||||||
// 🔧 중복 방지: referenceColumn도 한 번만 추가
|
|
||||||
const refColAlias = config.referenceColumn;
|
|
||||||
if (!generatedLabelAliases.has(refColAlias)) {
|
|
||||||
resultColumns.push(
|
|
||||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
|
|
||||||
);
|
|
||||||
generatedLabelAliases.add(refColAlias);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resultColumns.push(
|
resultColumns.push(
|
||||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
`COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"`
|
||||||
);
|
);
|
||||||
|
generatedAliases.add(labelAlias);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
|
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback)
|
||||||
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
|
const displayColumns = config.displayColumns || [config.displayColumn];
|
||||||
displayColumns.forEach((col) => {
|
|
||||||
|
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||||
|
resultColumns.push(
|
||||||
|
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||||
|
);
|
||||||
|
} else if (displayColumns.length === 1) {
|
||||||
|
const col = displayColumns[0];
|
||||||
const isJoinTableColumn =
|
const isJoinTableColumn =
|
||||||
config.referenceTable && config.referenceTable !== tableName;
|
config.referenceTable && config.referenceTable !== tableName;
|
||||||
|
|
||||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
|
||||||
|
|
||||||
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
|
|
||||||
if (generatedLabelAliases.has(individualAlias)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
if (isJoinTableColumn) {
|
||||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
|
||||||
resultColumns.push(
|
resultColumns.push(
|
||||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||||
);
|
);
|
||||||
|
const labelAlias = `${config.sourceColumn}_label`;
|
||||||
|
if (!generatedAliases.has(labelAlias)) {
|
||||||
|
resultColumns.push(
|
||||||
|
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||||
|
);
|
||||||
|
generatedAliases.add(labelAlias);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 기본 테이블 컬럼은 main 별칭 사용
|
|
||||||
resultColumns.push(
|
resultColumns.push(
|
||||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
generatedLabelAliases.add(individualAlias);
|
} else {
|
||||||
});
|
displayColumns.forEach((col) => {
|
||||||
|
const isJoinTableColumn =
|
||||||
|
config.referenceTable && config.referenceTable !== tableName;
|
||||||
|
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||||
|
if (generatedAliases.has(individualAlias)) return;
|
||||||
|
|
||||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
if (isJoinTableColumn) {
|
||||||
const isJoinTableColumn =
|
resultColumns.push(
|
||||||
config.referenceTable && config.referenceTable !== tableName;
|
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||||
if (
|
);
|
||||||
isJoinTableColumn &&
|
} else {
|
||||||
!displayColumns.includes(config.referenceColumn) &&
|
resultColumns.push(
|
||||||
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
|
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||||
) {
|
);
|
||||||
resultColumns.push(
|
}
|
||||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
generatedAliases.add(individualAlias);
|
||||||
);
|
});
|
||||||
generatedLabelAliases.add(config.referenceColumn);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모든 resultColumns를 반환
|
|
||||||
return resultColumns.join(", ");
|
return resultColumns.join(", ");
|
||||||
})
|
})
|
||||||
|
.filter(Boolean)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
// SELECT 절 구성
|
// SELECT 절 구성
|
||||||
|
|
@ -725,7 +716,7 @@ export class EntityJoinService {
|
||||||
/**
|
/**
|
||||||
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
||||||
*/
|
*/
|
||||||
async getReferenceTableColumns(tableName: string): Promise<
|
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
columnName: string;
|
columnName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -750,16 +741,19 @@ export class EntityJoinService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
|
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
|
||||||
|
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
|
||||||
const columnLabels = await query<{
|
const columnLabels = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
column_label: string | null;
|
column_label: string | null;
|
||||||
input_type: string | null;
|
input_type: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, column_label, input_type
|
`SELECT DISTINCT ON (column_name) column_name, column_label, input_type
|
||||||
FROM table_type_columns
|
FROM table_type_columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND company_code = '*'`,
|
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||||
[tableName]
|
ORDER BY column_name,
|
||||||
|
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||||
|
companyCode ? [tableName, companyCode] : [tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
||||||
|
|
|
||||||
|
|
@ -5379,9 +5379,11 @@ export class ScreenManagementService {
|
||||||
[screenId],
|
[screenId],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외)
|
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
|
||||||
zones = await query<any>(
|
zones = await query<any>(
|
||||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`,
|
`SELECT * FROM screen_conditional_zones
|
||||||
|
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||||
|
ORDER BY zone_id`,
|
||||||
[screenId, companyCode],
|
[screenId, companyCode],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2875,10 +2875,11 @@ export class TableManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
|
// Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달)
|
||||||
let joinConfigs = await entityJoinService.detectEntityJoins(
|
let joinConfigs = await entityJoinService.detectEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
options.screenEntityConfigs
|
options.screenEntityConfigs,
|
||||||
|
options.companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -3258,6 +3259,28 @@ export class TableManagementService {
|
||||||
startTime: number
|
startTime: number
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
try {
|
try {
|
||||||
|
// 🆕 참조 테이블별 전체 컬럼 목록 미리 조회
|
||||||
|
const referenceTableColumns = new Map<string, string[]>();
|
||||||
|
const uniqueRefTables = new Set(
|
||||||
|
joinConfigs
|
||||||
|
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
|
||||||
|
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const key of uniqueRefTables) {
|
||||||
|
const refTable = key.split(":")[0];
|
||||||
|
if (!referenceTableColumns.has(key)) {
|
||||||
|
const cols = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND table_schema = 'public'
|
||||||
|
ORDER BY ordinal_position`,
|
||||||
|
[refTable]
|
||||||
|
);
|
||||||
|
referenceTableColumns.set(key, cols.map((c) => c.column_name));
|
||||||
|
logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 조회 쿼리
|
// 데이터 조회 쿼리
|
||||||
const dataQuery = entityJoinService.buildJoinQuery(
|
const dataQuery = entityJoinService.buildJoinQuery(
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -3266,7 +3289,9 @@ export class TableManagementService {
|
||||||
whereClause,
|
whereClause,
|
||||||
orderBy,
|
orderBy,
|
||||||
limit,
|
limit,
|
||||||
offset
|
offset,
|
||||||
|
undefined,
|
||||||
|
referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달
|
||||||
).query;
|
).query;
|
||||||
|
|
||||||
// 카운트 쿼리
|
// 카운트 쿼리
|
||||||
|
|
@ -3767,12 +3792,12 @@ export class TableManagementService {
|
||||||
reference_table: string;
|
reference_table: string;
|
||||||
reference_column: string;
|
reference_column: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, reference_table, reference_column
|
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column
|
||||||
FROM table_type_columns
|
FROM table_type_columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND input_type = 'entity'
|
AND input_type = 'entity'
|
||||||
AND reference_table = $2
|
AND reference_table = $2
|
||||||
AND company_code = '*'
|
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[tableName, refTable]
|
[tableName, refTable]
|
||||||
);
|
);
|
||||||
|
|
@ -3883,7 +3908,7 @@ export class TableManagementService {
|
||||||
/**
|
/**
|
||||||
* 참조 테이블의 표시 컬럼 목록 조회
|
* 참조 테이블의 표시 컬럼 목록 조회
|
||||||
*/
|
*/
|
||||||
async getReferenceTableColumns(tableName: string): Promise<
|
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
columnName: string;
|
columnName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -3891,7 +3916,7 @@ export class TableManagementService {
|
||||||
inputType?: string;
|
inputType?: string;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
return await entityJoinService.getReferenceTableColumns(tableName, companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -5005,14 +5030,14 @@ export class TableManagementService {
|
||||||
input_type: string;
|
input_type: string;
|
||||||
display_column: string | null;
|
display_column: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, reference_column, input_type, display_column
|
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
|
||||||
FROM table_type_columns
|
FROM table_type_columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND input_type IN ('entity', 'category')
|
AND input_type IN ('entity', 'category')
|
||||||
AND reference_table = $2
|
AND reference_table = $2
|
||||||
AND reference_column IS NOT NULL
|
AND reference_column IS NOT NULL
|
||||||
AND reference_column != ''
|
AND reference_column != ''
|
||||||
AND company_code = '*'`,
|
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||||
[rightTable, leftTable]
|
[rightTable, leftTable]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -5034,14 +5059,14 @@ export class TableManagementService {
|
||||||
input_type: string;
|
input_type: string;
|
||||||
display_column: string | null;
|
display_column: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT column_name, reference_column, input_type, display_column
|
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
|
||||||
FROM table_type_columns
|
FROM table_type_columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND input_type IN ('entity', 'category')
|
AND input_type IN ('entity', 'category')
|
||||||
AND reference_table = $2
|
AND reference_table = $2
|
||||||
AND reference_column IS NOT NULL
|
AND reference_column IS NOT NULL
|
||||||
AND reference_column != ''
|
AND reference_column != ''
|
||||||
AND company_code = '*'`,
|
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||||
[leftTable, rightTable]
|
[leftTable, rightTable]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,16 @@ function ScreenViewPage() {
|
||||||
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
|
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
|
||||||
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
|
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
|
||||||
componentCount: l.components.length,
|
componentCount: l.components.length,
|
||||||
|
condition: l.condition ? {
|
||||||
|
targetComponentId: l.condition.targetComponentId,
|
||||||
|
operator: l.condition.operator,
|
||||||
|
value: l.condition.value,
|
||||||
|
} : "없음",
|
||||||
|
})));
|
||||||
|
console.log("🗺️ Zone 정보:", loadedZones.map(z => ({
|
||||||
|
zone_id: z.zone_id,
|
||||||
|
trigger_component_id: z.trigger_component_id,
|
||||||
|
trigger_operator: z.trigger_operator,
|
||||||
})));
|
})));
|
||||||
setConditionalLayers(layerDefinitions);
|
setConditionalLayers(layerDefinitions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -315,6 +325,9 @@ function ScreenViewPage() {
|
||||||
if (layer.condition) {
|
if (layer.condition) {
|
||||||
const { targetComponentId, operator, value } = layer.condition;
|
const { targetComponentId, operator, value } = layer.condition;
|
||||||
|
|
||||||
|
// 빈 targetComponentId는 무시
|
||||||
|
if (!targetComponentId) return;
|
||||||
|
|
||||||
// 트리거 컴포넌트 찾기 (기본 레이어에서)
|
// 트리거 컴포넌트 찾기 (기본 레이어에서)
|
||||||
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||||||
|
|
||||||
|
|
@ -329,16 +342,36 @@ function ScreenViewPage() {
|
||||||
let isMatch = false;
|
let isMatch = false;
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case "eq":
|
case "eq":
|
||||||
isMatch = targetValue == value;
|
// 문자열로 변환하여 비교 (타입 불일치 방지)
|
||||||
|
isMatch = String(targetValue ?? "") === String(value ?? "");
|
||||||
break;
|
break;
|
||||||
case "neq":
|
case "neq":
|
||||||
isMatch = targetValue != value;
|
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
||||||
break;
|
break;
|
||||||
case "in":
|
case "in":
|
||||||
isMatch = Array.isArray(value) && value.includes(targetValue);
|
if (Array.isArray(value)) {
|
||||||
|
isMatch = value.some(v => String(v) === String(targetValue ?? ""));
|
||||||
|
} else if (typeof value === "string" && value.includes(",")) {
|
||||||
|
// 쉼표로 구분된 문자열도 지원
|
||||||
|
isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? ""));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 디버그 로깅 (값이 존재할 때만)
|
||||||
|
if (targetValue !== undefined && targetValue !== "") {
|
||||||
|
console.log("🔍 [레이어 조건 평가]", {
|
||||||
|
layerId: layer.id,
|
||||||
|
layerName: layer.name,
|
||||||
|
targetComponentId,
|
||||||
|
fieldKey,
|
||||||
|
targetValue: String(targetValue),
|
||||||
|
conditionValue: String(value),
|
||||||
|
operator,
|
||||||
|
isMatch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isMatch) {
|
if (isMatch) {
|
||||||
newActiveIds.push(layer.id);
|
newActiveIds.push(layer.id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -24,6 +24,7 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
|
||||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||||
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||||
|
|
||||||
interface ScreenModalState {
|
interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -71,6 +72,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
|
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
|
||||||
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
||||||
|
|
||||||
|
// 🆕 조건부 레이어 상태 (Zone 기반)
|
||||||
|
const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
|
||||||
|
|
||||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||||
const [continuousMode, setContinuousMode] = useState(false);
|
const [continuousMode, setContinuousMode] = useState(false);
|
||||||
|
|
||||||
|
|
@ -80,6 +84,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 모달 닫기 확인 다이얼로그 표시 상태
|
// 모달 닫기 확인 다이얼로그 표시 상태
|
||||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||||
|
|
||||||
|
// 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기)
|
||||||
|
const formDataChangedRef = useRef(false);
|
||||||
|
|
||||||
// localStorage에서 연속 모드 상태 복원
|
// localStorage에서 연속 모드 상태 복원
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||||
|
|
@ -122,9 +129,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const contentWidth = maxX - minX;
|
const contentWidth = maxX - minX;
|
||||||
const contentHeight = maxY - minY;
|
const contentHeight = maxY - minY;
|
||||||
|
|
||||||
// 적절한 여백 추가
|
// 여백 없이 컨텐츠 크기 그대로 사용
|
||||||
const paddingX = 40;
|
const paddingX = 0;
|
||||||
const paddingY = 40;
|
const paddingY = 0;
|
||||||
|
|
||||||
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
||||||
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
||||||
|
|
@ -132,8 +139,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
return {
|
return {
|
||||||
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
||||||
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
||||||
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
|
offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준
|
||||||
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
|
offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,6 +185,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 🆕 모달 열린 시간 기록
|
// 🆕 모달 열린 시간 기록
|
||||||
modalOpenedAtRef.current = Date.now();
|
modalOpenedAtRef.current = Date.now();
|
||||||
|
|
||||||
|
// 폼 변경 추적 초기화
|
||||||
|
formDataChangedRef.current = false;
|
||||||
|
|
||||||
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
||||||
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||||
setSelectedData(eventSelectedData);
|
setSelectedData(eventSelectedData);
|
||||||
|
|
@ -397,6 +407,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
|
|
||||||
if (isContinuousMode) {
|
if (isContinuousMode) {
|
||||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||||
|
formDataChangedRef.current = false;
|
||||||
setFormData({});
|
setFormData({});
|
||||||
setResetKey((prev) => prev + 1);
|
setResetKey((prev) => prev + 1);
|
||||||
|
|
||||||
|
|
@ -563,6 +574,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
components,
|
components,
|
||||||
screenInfo: screenInfo,
|
screenInfo: screenInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 조건부 레이어/존 로드
|
||||||
|
loadConditionalLayersAndZones(screenId);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("화면 데이터가 없습니다");
|
throw new Error("화면 데이터가 없습니다");
|
||||||
}
|
}
|
||||||
|
|
@ -575,9 +589,155 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때 확인 다이얼로그 표시
|
// 🆕 조건부 레이어 & 존 로드 함수
|
||||||
|
const loadConditionalLayersAndZones = async (screenId: number) => {
|
||||||
|
try {
|
||||||
|
const [layersRes, zonesRes] = await Promise.all([
|
||||||
|
screenApi.getScreenLayers(screenId),
|
||||||
|
screenApi.getScreenZones(screenId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const loadedLayers = layersRes || [];
|
||||||
|
const loadedZones: ConditionalZone[] = zonesRes || [];
|
||||||
|
|
||||||
|
// 기본 레이어(layer_id=1) 제외
|
||||||
|
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
|
||||||
|
|
||||||
|
if (nonBaseLayers.length === 0) {
|
||||||
|
setConditionalLayers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = [];
|
||||||
|
|
||||||
|
for (const layer of nonBaseLayers) {
|
||||||
|
try {
|
||||||
|
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
|
||||||
|
|
||||||
|
let layerComponents: ComponentData[] = [];
|
||||||
|
if (layerLayout && isValidV2Layout(layerLayout)) {
|
||||||
|
const legacyLayout = convertV2ToLegacy(layerLayout);
|
||||||
|
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
|
||||||
|
} else if (layerLayout?.components) {
|
||||||
|
layerComponents = layerLayout.components;
|
||||||
|
}
|
||||||
|
|
||||||
|
// condition_config에서 zone_id, condition_value 추출
|
||||||
|
const cc = layer.condition_config || {};
|
||||||
|
const zone = loadedZones.find((z) => z.zone_id === cc.zone_id);
|
||||||
|
|
||||||
|
layerDefs.push({
|
||||||
|
id: `layer-${layer.layer_id}`,
|
||||||
|
name: layer.layer_name || `레이어 ${layer.layer_id}`,
|
||||||
|
type: "conditional",
|
||||||
|
zIndex: layer.layer_id,
|
||||||
|
isVisible: false,
|
||||||
|
isLocked: false,
|
||||||
|
zoneId: cc.zone_id,
|
||||||
|
conditionValue: cc.condition_value,
|
||||||
|
condition: zone
|
||||||
|
? {
|
||||||
|
targetComponentId: zone.trigger_component_id || "",
|
||||||
|
operator: (zone.trigger_operator || "eq") as any,
|
||||||
|
value: cc.condition_value || "",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
components: layerComponents,
|
||||||
|
zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용)
|
||||||
|
} as any);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
|
||||||
|
layerDefs.map((l) => ({
|
||||||
|
id: l.id, name: l.name, conditionValue: l.conditionValue,
|
||||||
|
componentCount: l.components.length,
|
||||||
|
condition: l.condition,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
setConditionalLayers(layerDefs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ScreenModal] 조건부 레이어 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
|
||||||
|
const activeConditionalComponents = useMemo(() => {
|
||||||
|
if (conditionalLayers.length === 0) return [];
|
||||||
|
|
||||||
|
const allComponents = screenData?.components || [];
|
||||||
|
const activeComps: ComponentData[] = [];
|
||||||
|
|
||||||
|
conditionalLayers.forEach((layer) => {
|
||||||
|
if (!layer.condition) return;
|
||||||
|
const { targetComponentId, operator, value } = layer.condition;
|
||||||
|
if (!targetComponentId) return;
|
||||||
|
|
||||||
|
// V2 레이아웃: overrides.columnName 우선
|
||||||
|
const comp = allComponents.find((c: any) => c.id === targetComponentId);
|
||||||
|
const fieldKey =
|
||||||
|
(comp as any)?.overrides?.columnName ||
|
||||||
|
(comp as any)?.columnName ||
|
||||||
|
(comp as any)?.componentConfig?.columnName ||
|
||||||
|
targetComponentId;
|
||||||
|
|
||||||
|
const targetValue = formData[fieldKey];
|
||||||
|
|
||||||
|
let isMatch = false;
|
||||||
|
switch (operator) {
|
||||||
|
case "eq":
|
||||||
|
isMatch = String(targetValue ?? "") === String(value ?? "");
|
||||||
|
break;
|
||||||
|
case "neq":
|
||||||
|
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||||
|
} else if (typeof value === "string" && value.includes(",")) {
|
||||||
|
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[ScreenModal] 레이어 조건 평가:", {
|
||||||
|
layerName: layer.name, fieldKey,
|
||||||
|
targetValue: String(targetValue ?? "(없음)"),
|
||||||
|
conditionValue: String(value), operator, isMatch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
// Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨)
|
||||||
|
const zoneX = layer.zone?.x || 0;
|
||||||
|
const zoneY = layer.zone?.y || 0;
|
||||||
|
|
||||||
|
const offsetComponents = layer.components.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
position: {
|
||||||
|
...c.position,
|
||||||
|
x: parseFloat(c.position?.x?.toString() || "0") + zoneX,
|
||||||
|
y: parseFloat(c.position?.y?.toString() || "0") + zoneY,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
activeComps.push(...offsetComponents);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return activeComps;
|
||||||
|
}, [formData, conditionalLayers, screenData?.components]);
|
||||||
|
|
||||||
|
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때
|
||||||
|
// 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기
|
||||||
const handleCloseAttempt = useCallback(() => {
|
const handleCloseAttempt = useCallback(() => {
|
||||||
setShowCloseConfirm(true);
|
if (formDataChangedRef.current) {
|
||||||
|
setShowCloseConfirm(true);
|
||||||
|
} else {
|
||||||
|
handleCloseInternal();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 확인 후 실제로 모달을 닫는 함수
|
// 확인 후 실제로 모달을 닫는 함수
|
||||||
|
|
@ -613,6 +773,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
setFormData({}); // 폼 데이터 초기화
|
setFormData({}); // 폼 데이터 초기화
|
||||||
setOriginalData(null); // 원본 데이터 초기화
|
setOriginalData(null); // 원본 데이터 초기화
|
||||||
setSelectedData([]); // 선택된 데이터 초기화
|
setSelectedData([]); // 선택된 데이터 초기화
|
||||||
|
setConditionalLayers([]); // 🆕 조건부 레이어 초기화
|
||||||
setContinuousMode(false);
|
setContinuousMode(false);
|
||||||
localStorage.setItem("screenModal_continuousMode", "false");
|
localStorage.setItem("screenModal_continuousMode", "false");
|
||||||
};
|
};
|
||||||
|
|
@ -624,36 +785,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const getModalStyle = () => {
|
const getModalStyle = () => {
|
||||||
if (!screenDimensions) {
|
if (!screenDimensions) {
|
||||||
return {
|
return {
|
||||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
|
||||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
|
||||||
needsScroll: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
|
||||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
|
|
||||||
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
|
|
||||||
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
|
|
||||||
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
|
|
||||||
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
|
|
||||||
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
|
|
||||||
const horizontalPadding = 16; // 좌우 패딩 최소화
|
|
||||||
|
|
||||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
|
|
||||||
const maxAvailableHeight = window.innerHeight * 0.95;
|
|
||||||
|
|
||||||
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
|
|
||||||
const needsScroll = totalHeight > maxAvailableHeight;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden p-0",
|
className: "overflow-hidden",
|
||||||
style: {
|
style: {
|
||||||
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
|
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||||
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
|
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
||||||
maxHeight: `${maxAvailableHeight}px`,
|
maxHeight: "calc(100dvh - 8px)",
|
||||||
maxWidth: "98vw",
|
maxWidth: "98vw",
|
||||||
|
padding: 0,
|
||||||
|
gap: 0,
|
||||||
},
|
},
|
||||||
needsScroll,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -730,7 +876,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||||
{...(modalStyle.style && { style: modalStyle.style })}
|
style={modalStyle.style}
|
||||||
// 바깥 클릭 시 바로 닫히지 않도록 방지
|
// 바깥 클릭 시 바로 닫히지 않도록 방지
|
||||||
onInteractOutside={(e) => {
|
onInteractOutside={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -755,7 +901,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
|
className="flex-1 min-h-0 flex items-start justify-center overflow-auto"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
@ -771,8 +917,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: `${screenDimensions?.width || 800}px`,
|
width: `${screenDimensions?.width || 800}px`,
|
||||||
height: `${screenDimensions?.height || 600}px`,
|
// 🆕 조건부 레이어 활성화 시 높이 자동 확장
|
||||||
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
|
minHeight: `${screenDimensions?.height || 600}px`,
|
||||||
|
height: (() => {
|
||||||
|
const baseHeight = screenDimensions?.height || 600;
|
||||||
|
if (activeConditionalComponents.length > 0) {
|
||||||
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
|
let maxBottom = 0;
|
||||||
|
activeConditionalComponents.forEach((comp: any) => {
|
||||||
|
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY;
|
||||||
|
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||||
|
maxBottom = Math.max(maxBottom, y + h);
|
||||||
|
});
|
||||||
|
return `${Math.max(baseHeight, maxBottom + 20)}px`;
|
||||||
|
}
|
||||||
|
return `${baseHeight}px`;
|
||||||
|
})(),
|
||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -908,6 +1068,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
// 사용자가 실제로 데이터를 변경한 것으로 표시
|
||||||
|
formDataChangedRef.current = true;
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -932,6 +1094,48 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||||
|
{activeConditionalComponents.map((component: any) => {
|
||||||
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
|
|
||||||
|
const adjustedComponent = {
|
||||||
|
...component,
|
||||||
|
position: {
|
||||||
|
...component.position,
|
||||||
|
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||||
|
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
|
key={`conditional-${component.id}-${resetKey}`}
|
||||||
|
component={adjustedComponent}
|
||||||
|
allComponents={[...(screenData?.components || []), ...activeConditionalComponents]}
|
||||||
|
formData={formData}
|
||||||
|
originalData={originalData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
formDataChangedRef.current = true;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onRefresh={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
}}
|
||||||
|
screenInfo={{
|
||||||
|
id: modalState.screenId!,
|
||||||
|
tableName: screenData?.screenInfo?.tableName,
|
||||||
|
}}
|
||||||
|
userId={userId}
|
||||||
|
userName={userName}
|
||||||
|
companyCode={user?.companyCode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
</ActiveTabProvider>
|
</ActiveTabProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -15,6 +15,8 @@ import { ComponentData } from "@/types/screen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||||
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||||
|
|
||||||
interface EditModalState {
|
interface EditModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -116,6 +118,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||||
|
|
||||||
|
// 🆕 조건부 레이어 상태 (Zone 기반)
|
||||||
|
const [zones, setZones] = useState<ConditionalZone[]>([]);
|
||||||
|
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||||
|
|
||||||
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
|
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
|
||||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||||
if (components.length === 0) {
|
if (components.length === 0) {
|
||||||
|
|
@ -360,16 +366,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// console.log("화면 데이터 로딩 시작:", screenId);
|
|
||||||
|
|
||||||
// 화면 정보와 레이아웃 데이터 로딩
|
// 화면 정보와 레이아웃 데이터 로딩
|
||||||
const [screenInfo, layoutData] = await Promise.all([
|
const [screenInfo, layoutData] = await Promise.all([
|
||||||
screenApi.getScreen(screenId),
|
screenApi.getScreen(screenId),
|
||||||
screenApi.getLayout(screenId),
|
screenApi.getLayout(screenId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// console.log("API 응답:", { screenInfo, layoutData });
|
|
||||||
|
|
||||||
if (screenInfo && layoutData) {
|
if (screenInfo && layoutData) {
|
||||||
const components = layoutData.components || [];
|
const components = layoutData.components || [];
|
||||||
|
|
||||||
|
|
@ -381,11 +383,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
components,
|
components,
|
||||||
screenInfo: screenInfo,
|
screenInfo: screenInfo,
|
||||||
});
|
});
|
||||||
// console.log("화면 데이터 설정 완료:", {
|
|
||||||
// componentsCount: components.length,
|
// 🆕 조건부 레이어/존 로드 (await으로 에러 포착)
|
||||||
// dimensions,
|
console.log("[EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작:", screenId);
|
||||||
// screenInfo,
|
try {
|
||||||
// });
|
await loadConditionalLayersAndZones(screenId, components);
|
||||||
|
} catch (layerErr) {
|
||||||
|
console.error("[EditModal] 조건부 레이어 로드 에러:", layerErr);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("화면 데이터가 없습니다");
|
throw new Error("화면 데이터가 없습니다");
|
||||||
}
|
}
|
||||||
|
|
@ -398,6 +403,165 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 조건부 레이어 & 존 로드 함수
|
||||||
|
const loadConditionalLayersAndZones = async (screenId: number, baseComponents: ComponentData[]) => {
|
||||||
|
console.log("[EditModal] loadConditionalLayersAndZones 호출됨:", screenId);
|
||||||
|
try {
|
||||||
|
// 레이어 목록 & 존 목록 병렬 로드
|
||||||
|
console.log("[EditModal] API 호출 시작: getScreenLayers, getScreenZones");
|
||||||
|
const [layersRes, zonesRes] = await Promise.all([
|
||||||
|
screenApi.getScreenLayers(screenId),
|
||||||
|
screenApi.getScreenZones(screenId),
|
||||||
|
]);
|
||||||
|
console.log("[EditModal] API 응답:", { layers: layersRes?.length, zones: zonesRes?.length });
|
||||||
|
|
||||||
|
const loadedLayers = layersRes || [];
|
||||||
|
const loadedZones: ConditionalZone[] = zonesRes || [];
|
||||||
|
|
||||||
|
setZones(loadedZones);
|
||||||
|
|
||||||
|
// 기본 레이어(layer_id=1) 제외한 조건부 레이어 처리
|
||||||
|
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
|
||||||
|
|
||||||
|
if (nonBaseLayers.length === 0) {
|
||||||
|
setConditionalLayers([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 조건부 레이어의 컴포넌트 로드
|
||||||
|
const layerDefinitions: LayerDefinition[] = [];
|
||||||
|
|
||||||
|
for (const layer of nonBaseLayers) {
|
||||||
|
try {
|
||||||
|
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
|
||||||
|
|
||||||
|
let layerComponents: ComponentData[] = [];
|
||||||
|
if (layerLayout && isValidV2Layout(layerLayout)) {
|
||||||
|
const legacyLayout = convertV2ToLegacy(layerLayout);
|
||||||
|
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
|
||||||
|
} else if (layerLayout?.components) {
|
||||||
|
layerComponents = layerLayout.components;
|
||||||
|
}
|
||||||
|
|
||||||
|
// condition_config에서 zone_id, condition_value 추출
|
||||||
|
const conditionConfig = layer.condition_config || {};
|
||||||
|
const layerZoneId = conditionConfig.zone_id;
|
||||||
|
const layerConditionValue = conditionConfig.condition_value;
|
||||||
|
|
||||||
|
// 이 레이어가 속한 Zone 찾기
|
||||||
|
const associatedZone = loadedZones.find(
|
||||||
|
(z) => z.zone_id === layerZoneId
|
||||||
|
);
|
||||||
|
|
||||||
|
layerDefinitions.push({
|
||||||
|
id: `layer-${layer.layer_id}`,
|
||||||
|
name: layer.layer_name || `레이어 ${layer.layer_id}`,
|
||||||
|
type: "conditional",
|
||||||
|
zIndex: layer.layer_id,
|
||||||
|
isVisible: false,
|
||||||
|
isLocked: false,
|
||||||
|
zoneId: layerZoneId,
|
||||||
|
conditionValue: layerConditionValue,
|
||||||
|
condition: associatedZone
|
||||||
|
? {
|
||||||
|
targetComponentId: associatedZone.trigger_component_id || "",
|
||||||
|
operator: (associatedZone.trigger_operator || "eq") as any,
|
||||||
|
value: layerConditionValue || "",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
components: layerComponents,
|
||||||
|
} as LayerDefinition & { components: ComponentData[] });
|
||||||
|
} catch (layerError) {
|
||||||
|
console.warn(`[EditModal] 레이어 ${layer.layer_id} 로드 실패:`, layerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
|
||||||
|
layerDefinitions.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name,
|
||||||
|
conditionValue: l.conditionValue,
|
||||||
|
condition: l.condition,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
setConditionalLayers(layerDefinitions);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[EditModal] 조건부 레이어 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
|
||||||
|
const activeConditionalLayerIds = useMemo(() => {
|
||||||
|
if (conditionalLayers.length === 0) return [];
|
||||||
|
|
||||||
|
const newActiveIds: string[] = [];
|
||||||
|
const allComponents = screenData?.components || [];
|
||||||
|
|
||||||
|
conditionalLayers.forEach((layer) => {
|
||||||
|
const layerWithComponents = layer as LayerDefinition & { components: ComponentData[] };
|
||||||
|
if (layerWithComponents.condition) {
|
||||||
|
const { targetComponentId, operator, value } = layerWithComponents.condition;
|
||||||
|
if (!targetComponentId) return;
|
||||||
|
|
||||||
|
// 트리거 컴포넌트의 columnName 찾기
|
||||||
|
// V2 레이아웃: overrides.columnName, 레거시: componentConfig.columnName
|
||||||
|
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||||||
|
const fieldKey =
|
||||||
|
(targetComponent as any)?.overrides?.columnName ||
|
||||||
|
(targetComponent as any)?.columnName ||
|
||||||
|
(targetComponent as any)?.componentConfig?.columnName ||
|
||||||
|
targetComponentId;
|
||||||
|
|
||||||
|
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
|
||||||
|
const targetValue = currentFormData[fieldKey];
|
||||||
|
|
||||||
|
let isMatch = false;
|
||||||
|
switch (operator) {
|
||||||
|
case "eq":
|
||||||
|
isMatch = String(targetValue ?? "") === String(value ?? "");
|
||||||
|
break;
|
||||||
|
case "neq":
|
||||||
|
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||||
|
} else if (typeof value === "string" && value.includes(",")) {
|
||||||
|
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디버그 로깅
|
||||||
|
console.log("[EditModal] 레이어 조건 평가:", {
|
||||||
|
layerId: layer.id,
|
||||||
|
layerName: layer.name,
|
||||||
|
targetComponentId,
|
||||||
|
fieldKey,
|
||||||
|
targetValue: targetValue !== undefined ? String(targetValue) : "(없음)",
|
||||||
|
conditionValue: String(value),
|
||||||
|
operator,
|
||||||
|
isMatch,
|
||||||
|
componentFound: !!targetComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
newActiveIds.push(layer.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newActiveIds;
|
||||||
|
}, [formData, groupData, conditionalLayers, screenData?.components]);
|
||||||
|
|
||||||
|
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기
|
||||||
|
const activeConditionalComponents = useMemo(() => {
|
||||||
|
return conditionalLayers
|
||||||
|
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
|
||||||
|
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []);
|
||||||
|
}, [conditionalLayers, activeConditionalLayerIds]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
|
@ -412,6 +576,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
});
|
});
|
||||||
setScreenData(null);
|
setScreenData(null);
|
||||||
setFormData({});
|
setFormData({});
|
||||||
|
setZones([]);
|
||||||
|
setConditionalLayers([]);
|
||||||
setOriginalData({});
|
setOriginalData({});
|
||||||
setGroupData([]); // 🆕
|
setGroupData([]); // 🆕
|
||||||
setOriginalGroupData([]); // 🆕
|
setOriginalGroupData([]); // 🆕
|
||||||
|
|
@ -1151,12 +1317,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
|
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
|
||||||
|
height: (() => {
|
||||||
|
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||||
|
if (activeConditionalComponents.length > 0) {
|
||||||
|
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
||||||
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
|
let maxBottom = 0;
|
||||||
|
activeConditionalComponents.forEach((comp) => {
|
||||||
|
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
||||||
|
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||||
|
maxBottom = Math.max(maxBottom, y + h);
|
||||||
|
});
|
||||||
|
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
||||||
|
}
|
||||||
|
return baseHeight;
|
||||||
|
})(),
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
maxHeight: "100%",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||||
{screenData.components.map((component) => {
|
{screenData.components.map((component) => {
|
||||||
// 컴포넌트 위치를 offset만큼 조정
|
// 컴포넌트 위치를 offset만큼 조정
|
||||||
const offsetX = screenDimensions?.offsetX || 0;
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
|
|
@ -1174,49 +1355,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||||
|
|
||||||
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
|
|
||||||
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
|
|
||||||
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
|
|
||||||
const hasUniversalFormModal = screenData.components.some(
|
const hasUniversalFormModal = screenData.components.some(
|
||||||
(c) => {
|
(c) => {
|
||||||
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
|
|
||||||
if (c.componentType === "universal-form-modal") return true;
|
if (c.componentType === "universal-form-modal") return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
|
|
||||||
// _tableSection_ 데이터가 있으면 buttonActions.ts의 handleUniversalFormModalTableSectionSave가 처리
|
|
||||||
const hasTableSectionData = Object.keys(formData).some(k =>
|
const hasTableSectionData = Object.keys(formData).some(k =>
|
||||||
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
|
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
|
|
||||||
// 단, _tableSection_ 데이터가 있으면 EditModal.handleSave 사용하지 않음 (buttonActions.ts가 처리)
|
|
||||||
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||||
|
|
||||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
|
||||||
const enrichedFormData = {
|
const enrichedFormData = {
|
||||||
...(groupData.length > 0 ? groupData[0] : formData),
|
...(groupData.length > 0 ? groupData[0] : formData),
|
||||||
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
|
tableName: screenData.screenInfo?.tableName,
|
||||||
screenId: modalState.screenId, // 화면 ID 추가
|
screenId: modalState.screenId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InteractiveScreenViewerDynamic
|
<InteractiveScreenViewerDynamic
|
||||||
key={component.id}
|
key={component.id}
|
||||||
component={adjustedComponent}
|
component={adjustedComponent}
|
||||||
allComponents={screenData.components}
|
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||||
formData={enrichedFormData}
|
formData={enrichedFormData}
|
||||||
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
|
originalData={originalData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
// 🆕 그룹 데이터가 있으면 처리
|
|
||||||
if (groupData.length > 0) {
|
if (groupData.length > 0) {
|
||||||
// ModalRepeaterTable의 경우 배열 전체를 받음
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
setGroupData(value);
|
setGroupData(value);
|
||||||
} else {
|
} else {
|
||||||
// 일반 필드는 모든 항목에 동일하게 적용
|
|
||||||
setGroupData((prev) =>
|
setGroupData((prev) =>
|
||||||
prev.map((item) => ({
|
prev.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
|
|
@ -1235,19 +1404,74 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
id: modalState.screenId!,
|
id: modalState.screenId!,
|
||||||
tableName: screenData.screenInfo?.tableName,
|
tableName: screenData.screenInfo?.tableName,
|
||||||
}}
|
}}
|
||||||
// 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
|
||||||
menuObjid={modalState.menuObjid}
|
menuObjid={modalState.menuObjid}
|
||||||
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
|
|
||||||
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
|
|
||||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||||
isInModal={true}
|
isInModal={true}
|
||||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
|
||||||
groupedData={groupedDataProp}
|
groupedData={groupedDataProp}
|
||||||
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
|
|
||||||
disabledFields={["order_no", "partner_id"]}
|
disabledFields={["order_no", "partner_id"]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||||
|
{activeConditionalComponents.map((component) => {
|
||||||
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
|
const labelSpace = 30;
|
||||||
|
|
||||||
|
const adjustedComponent = {
|
||||||
|
...component,
|
||||||
|
position: {
|
||||||
|
...component.position,
|
||||||
|
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||||
|
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichedFormData = {
|
||||||
|
...(groupData.length > 0 ? groupData[0] : formData),
|
||||||
|
tableName: screenData.screenInfo?.tableName,
|
||||||
|
screenId: modalState.screenId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
|
key={`conditional-${component.id}`}
|
||||||
|
component={adjustedComponent}
|
||||||
|
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||||
|
formData={enrichedFormData}
|
||||||
|
originalData={originalData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
if (groupData.length > 0) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
setGroupData(value);
|
||||||
|
} else {
|
||||||
|
setGroupData((prev) =>
|
||||||
|
prev.map((item) => ({
|
||||||
|
...item,
|
||||||
|
[fieldName]: value,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
screenInfo={{
|
||||||
|
id: modalState.screenId!,
|
||||||
|
tableName: screenData.screenInfo?.tableName,
|
||||||
|
}}
|
||||||
|
menuObjid={modalState.menuObjid}
|
||||||
|
isInModal={true}
|
||||||
|
groupedData={groupedDataProp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
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";
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ComponentData, ConditionalZone } from "@/types/screen-management";
|
import { ComponentData, ConditionalZone } from "@/types/screen-management";
|
||||||
|
|
||||||
|
|
@ -167,6 +168,99 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
|
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
|
||||||
|
|
||||||
|
// 동적 소스 옵션 캐시 (trigger_component_id → 옵션 배열)
|
||||||
|
const [dynamicOptionsCache, setDynamicOptionsCache] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||||
|
const [loadingDynamicOptions, setLoadingDynamicOptions] = useState<Set<string>>(new Set());
|
||||||
|
// 이미 로드 시도한 키를 추적 (중복 요청 방지)
|
||||||
|
const loadedKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 동적 소스 옵션 로드 함수
|
||||||
|
const loadDynamicOptions = useCallback(async (triggerCompId: string, comp: ComponentData) => {
|
||||||
|
const cacheKey = triggerCompId;
|
||||||
|
// 이미 로드 완료 또는 로드 중이면 스킵
|
||||||
|
if (loadedKeysRef.current.has(cacheKey)) return;
|
||||||
|
loadedKeysRef.current.add(cacheKey);
|
||||||
|
|
||||||
|
setLoadingDynamicOptions(prev => new Set(prev).add(cacheKey));
|
||||||
|
try {
|
||||||
|
const config = comp.componentConfig || {};
|
||||||
|
const isCategory = (comp as any).inputType === "category" || (comp as any).webType === "category";
|
||||||
|
const source = isCategory ? "category" : config.source;
|
||||||
|
const compTableName = (comp as any).tableName || config.tableName;
|
||||||
|
const compColumnName = (comp as any).columnName || config.columnName;
|
||||||
|
let fetchedOptions: { value: string; label: string }[] = [];
|
||||||
|
|
||||||
|
if (source === "category" || isCategory) {
|
||||||
|
// 카테고리 소스: /table-categories/:tableName/:columnName/values
|
||||||
|
const catTable = config.categoryTable || compTableName;
|
||||||
|
const catColumn = config.categoryColumn || compColumnName;
|
||||||
|
if (catTable && catColumn) {
|
||||||
|
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
// 트리 구조를 평탄화 (valueCode/valueLabel 사용)
|
||||||
|
const flattenTree = (items: any[]): { value: string; label: string }[] => {
|
||||||
|
const result: { value: string; label: string }[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
result.push({ value: item.valueCode, label: item.valueLabel });
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
result.push(...flattenTree(item.children));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
fetchedOptions = flattenTree(data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (source === "code" && config.codeGroup) {
|
||||||
|
// 공통코드 소스
|
||||||
|
const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (source === "entity" && config.entityTable) {
|
||||||
|
// 엔티티 소스
|
||||||
|
const valueCol = config.entityValueColumn || "id";
|
||||||
|
const labelCol = config.entityLabelColumn || "name";
|
||||||
|
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
|
||||||
|
params: { value: valueCol, label: labelCol },
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data;
|
||||||
|
}
|
||||||
|
} else if ((source === "distinct" || source === "select") && compTableName && compColumnName) {
|
||||||
|
// DISTINCT 소스
|
||||||
|
const isValidCol = compColumnName && !compColumnName.startsWith("comp_");
|
||||||
|
if (isValidCol) {
|
||||||
|
const response = await apiClient.get(`/entity/${compTableName}/distinct/${compColumnName}`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.success && data.data) {
|
||||||
|
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: String(item.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: fetchedOptions }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("트리거 옵션 동적 로드 실패:", error);
|
||||||
|
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: [] }));
|
||||||
|
} finally {
|
||||||
|
setLoadingDynamicOptions(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(cacheKey);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Zone 트리거 컴포넌트 업데이트
|
// Zone 트리거 컴포넌트 업데이트
|
||||||
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
|
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -176,12 +270,20 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
});
|
});
|
||||||
const loadedZones = await screenApi.getScreenZones(screenId!);
|
const loadedZones = await screenApi.getScreenZones(screenId!);
|
||||||
onZonesChange?.(loadedZones);
|
onZonesChange?.(loadedZones);
|
||||||
|
|
||||||
|
// 트리거 변경 시 해당 컴포넌트의 동적 옵션 캐시 초기화 → 새로 로드
|
||||||
|
loadedKeysRef.current.delete(triggerComponentId);
|
||||||
|
const triggerComp = baseLayerComponents.find(c => c.id === triggerComponentId);
|
||||||
|
if (triggerComp) {
|
||||||
|
loadDynamicOptions(triggerComponentId, triggerComp);
|
||||||
|
}
|
||||||
|
|
||||||
toast.success("트리거가 설정되었습니다.");
|
toast.success("트리거가 설정되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Zone 트리거 업데이트 실패:", error);
|
console.error("Zone 트리거 업데이트 실패:", error);
|
||||||
toast.error("트리거 설정에 실패했습니다.");
|
toast.error("트리거 설정에 실패했습니다.");
|
||||||
}
|
}
|
||||||
}, [screenId, onZonesChange]);
|
}, [screenId, onZonesChange, baseLayerComponents, loadDynamicOptions]);
|
||||||
|
|
||||||
// Zone 접힘/펼침 토글
|
// Zone 접힘/펼침 토글
|
||||||
const toggleZone = (zoneId: number) => {
|
const toggleZone = (zoneId: number) => {
|
||||||
|
|
@ -197,21 +299,48 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
|
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기
|
// Zone 트리거가 변경되면 동적 옵션 로드
|
||||||
|
useEffect(() => {
|
||||||
|
for (const zone of zones) {
|
||||||
|
if (!zone.trigger_component_id) continue;
|
||||||
|
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
|
||||||
|
if (!triggerComp) continue;
|
||||||
|
|
||||||
|
const config = triggerComp.componentConfig || {};
|
||||||
|
const source = config.source;
|
||||||
|
const isCategory = (triggerComp as any).inputType === "category" || (triggerComp as any).webType === "category";
|
||||||
|
|
||||||
|
// 정적 옵션이 아닌 경우에만 동적 로드
|
||||||
|
const hasStaticOptions = config.options && Array.isArray(config.options) && config.options.length > 0;
|
||||||
|
if (!hasStaticOptions && (source === "category" || source === "code" || source === "entity" || source === "distinct" || source === "select" || isCategory)) {
|
||||||
|
loadDynamicOptions(zone.trigger_component_id, triggerComp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [zones, baseLayerComponents, loadDynamicOptions]);
|
||||||
|
|
||||||
|
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 (정적 + 동적 지원)
|
||||||
const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => {
|
const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => {
|
||||||
if (!zone.trigger_component_id) return [];
|
if (!zone.trigger_component_id) return [];
|
||||||
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
|
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
|
||||||
if (!triggerComp) return [];
|
if (!triggerComp) return [];
|
||||||
|
|
||||||
const config = triggerComp.componentConfig || {};
|
const config = triggerComp.componentConfig || {};
|
||||||
// 정적 옵션 (v2-select static source)
|
|
||||||
if (config.options && Array.isArray(config.options)) {
|
// 1. 정적 옵션 우선 확인
|
||||||
|
if (config.options && Array.isArray(config.options) && config.options.length > 0) {
|
||||||
return config.options
|
return config.options
|
||||||
.filter((opt: any) => opt.value)
|
.filter((opt: any) => opt.value)
|
||||||
.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value }));
|
.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 동적 소스 옵션 (캐시에서 가져오기)
|
||||||
|
const cached = dynamicOptionsCache[zone.trigger_component_id];
|
||||||
|
if (cached && cached.length > 0) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, [baseLayerComponents]);
|
}, [baseLayerComponents, dynamicOptionsCache]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
|
|
@ -435,6 +564,17 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
{addingToZoneId === zone.zone_id ? (
|
{addingToZoneId === zone.zone_id ? (
|
||||||
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
|
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// 동적 옵션 로딩 중 표시
|
||||||
|
const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false;
|
||||||
|
if (isLoadingOpts) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 flex-1 text-[11px] text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
옵션 로딩 중...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const triggerOpts = getTriggerOptions(zone);
|
const triggerOpts = getTriggerOptions(zone);
|
||||||
// 이미 사용된 조건값 제외
|
// 이미 사용된 조건값 제외
|
||||||
const usedValues = new Set(
|
const usedValues = new Set(
|
||||||
|
|
|
||||||
|
|
@ -2179,7 +2179,12 @@ export default function ScreenDesigner({
|
||||||
if (USE_POP_API) {
|
if (USE_POP_API) {
|
||||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
} else if (USE_V2_API) {
|
} else if (USE_V2_API) {
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||||
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
|
...v2Layout,
|
||||||
|
layerId: currentLayerId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
||||||
}
|
}
|
||||||
|
|
@ -5594,7 +5599,12 @@ export default function ScreenDesigner({
|
||||||
if (USE_POP_API) {
|
if (USE_POP_API) {
|
||||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
} else if (USE_V2_API) {
|
} else if (USE_V2_API) {
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||||
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
|
...v2Layout,
|
||||||
|
layerId: currentLayerId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ export const dataApi = {
|
||||||
parentKeys: Record<string, any>,
|
parentKeys: Record<string, any>,
|
||||||
records: Array<Record<string, any>>,
|
records: Array<Record<string, any>>,
|
||||||
options?: { deleteOrphans?: boolean }
|
options?: { deleteOrphans?: boolean }
|
||||||
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
|
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; savedIds?: string[]; message?: string; error?: string }> => {
|
||||||
try {
|
try {
|
||||||
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
|
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
|
||||||
|
|
@ -689,6 +689,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
// 레코드에 id(기존 DB PK)가 있으면 EDIT 모드 → 고아 삭제
|
// 레코드에 id(기존 DB PK)가 있으면 EDIT 모드 → 고아 삭제
|
||||||
// id 없으면 CREATE 모드 → 기존 레코드 건드리지 않음
|
// id 없으면 CREATE 모드 → 기존 레코드 건드리지 않음
|
||||||
const mappingHasDbIds = mappingRecords.some((r) => !!r.id);
|
const mappingHasDbIds = mappingRecords.some((r) => !!r.id);
|
||||||
|
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
|
||||||
|
let savedMappingIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
const mappingResult = await dataApi.upsertGroupedRecords(
|
const mappingResult = await dataApi.upsertGroupedRecords(
|
||||||
mainTable,
|
mainTable,
|
||||||
|
|
@ -696,6 +698,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
mappingRecords,
|
mappingRecords,
|
||||||
{ deleteOrphans: mappingHasDbIds },
|
{ deleteOrphans: mappingHasDbIds },
|
||||||
);
|
);
|
||||||
|
// 백엔드에서 반환된 저장된 레코드 ID 목록
|
||||||
|
if (mappingResult.success && mappingResult.savedIds) {
|
||||||
|
savedMappingIds = mappingResult.savedIds;
|
||||||
|
console.log(`✅ ${mainTable} 저장 완료, savedIds:`, savedMappingIds);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`❌ ${mainTable} 저장 실패:`, err);
|
console.error(`❌ ${mainTable} 저장 실패:`, err);
|
||||||
}
|
}
|
||||||
|
|
@ -755,6 +762,18 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
priceRecords.push(emptyRecord);
|
priceRecords.push(emptyRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step1에서 저장된 매핑 ID를 디테일 레코드에 주입
|
||||||
|
// (customer_item_prices.mapping_id ← customer_item_mapping.id)
|
||||||
|
if (savedMappingIds.length > 0) {
|
||||||
|
const mappingId = savedMappingIds[0]; // 일반적으로 1:N (매핑 1개 : 단가 N개)
|
||||||
|
priceRecords.forEach((record) => {
|
||||||
|
if (!record.mapping_id) {
|
||||||
|
record.mapping_id = mappingId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`🔗 디테일 레코드에 mapping_id 주입: ${mappingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
const priceHasDbIds = priceRecords.some((r) => !!r.id);
|
const priceHasDbIds = priceRecords.some((r) => !!r.id);
|
||||||
try {
|
try {
|
||||||
const detailResult = await dataApi.upsertGroupedRecords(
|
const detailResult = await dataApi.upsertGroupedRecords(
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
|
|
||||||
|
// 추가 탭 관련 상태
|
||||||
|
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
|
||||||
|
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
|
||||||
|
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||||
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
||||||
|
|
@ -952,6 +957,67 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
[formatDateValue, formatNumberValue],
|
[formatDateValue, formatNumberValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
||||||
|
const extractAdditionalJoinColumns = useCallback((columns: any[] | undefined, tableName: string) => {
|
||||||
|
if (!columns || columns.length === 0) return undefined;
|
||||||
|
|
||||||
|
const joinColumns: Array<{
|
||||||
|
sourceTable: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
referenceTable: string;
|
||||||
|
joinAlias: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
// 방법 1: isEntityJoin 플래그가 있는 경우 (설정 패널에서 Entity 조인 컬럼으로 추가한 경우)
|
||||||
|
if (col.isEntityJoin && col.joinInfo) {
|
||||||
|
const existing = joinColumns.find(
|
||||||
|
(j) => j.referenceTable === col.joinInfo.referenceTable && j.joinAlias === col.joinInfo.joinAlias
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
joinColumns.push({
|
||||||
|
sourceTable: col.joinInfo.sourceTable || tableName,
|
||||||
|
sourceColumn: col.joinInfo.sourceColumn,
|
||||||
|
referenceTable: col.joinInfo.referenceTable,
|
||||||
|
joinAlias: col.joinInfo.joinAlias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 방법 2: "테이블명.컬럼명" 형식 (기존 좌측 패널 방식)
|
||||||
|
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
||||||
|
if (colName && colName.includes(".")) {
|
||||||
|
const [refTable, refColumn] = colName.split(".");
|
||||||
|
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
||||||
|
const existing = joinColumns.find(
|
||||||
|
(j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
joinColumns.push({
|
||||||
|
sourceTable: tableName,
|
||||||
|
sourceColumn: inferredSourceColumn,
|
||||||
|
referenceTable: refTable,
|
||||||
|
joinAlias: `${inferredSourceColumn}_${refColumn}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 이미 추가된 테이블이면 별칭만 추가
|
||||||
|
const newAlias = `${inferredSourceColumn}_${refColumn}`;
|
||||||
|
if (!joinColumns.find((j) => j.joinAlias === newAlias)) {
|
||||||
|
joinColumns.push({
|
||||||
|
sourceTable: tableName,
|
||||||
|
sourceColumn: inferredSourceColumn,
|
||||||
|
referenceTable: refTable,
|
||||||
|
joinAlias: newAlias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return joinColumns.length > 0 ? joinColumns : undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
const loadLeftData = useCallback(async () => {
|
const loadLeftData = useCallback(async () => {
|
||||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
|
@ -962,74 +1028,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
|
||||||
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
|
||||||
|
|
||||||
// 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환
|
// 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용)
|
||||||
const configuredColumns = componentConfig.leftPanel?.columns || [];
|
const leftJoinColumns = extractAdditionalJoinColumns(
|
||||||
const additionalJoinColumns: Array<{
|
componentConfig.leftPanel?.columns,
|
||||||
sourceTable: string;
|
leftTableName,
|
||||||
sourceColumn: string;
|
);
|
||||||
referenceTable: string;
|
|
||||||
joinAlias: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등)
|
console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns);
|
||||||
const sourceColumnMap: Record<string, string> = {};
|
|
||||||
|
|
||||||
configuredColumns.forEach((col: any) => {
|
|
||||||
const colName = typeof col === "string" ? col : col.name || col.columnName;
|
|
||||||
if (colName && colName.includes(".")) {
|
|
||||||
const [refTable, refColumn] = colName.split(".");
|
|
||||||
// 소스 컬럼 추론 (item_info → item_code 또는 warehouse_info → warehouse_id)
|
|
||||||
// 기본: _info → _code, 백업: _info → _id
|
|
||||||
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
|
|
||||||
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
|
|
||||||
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
|
|
||||||
const inferredSourceColumn = primarySourceColumn;
|
|
||||||
|
|
||||||
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
|
|
||||||
const existingJoin = additionalJoinColumns.find(
|
|
||||||
(j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existingJoin) {
|
|
||||||
// 새로운 조인 추가 (첫 번째 컬럼)
|
|
||||||
additionalJoinColumns.push({
|
|
||||||
sourceTable: leftTableName,
|
|
||||||
sourceColumn: inferredSourceColumn,
|
|
||||||
referenceTable: refTable,
|
|
||||||
joinAlias: `${inferredSourceColumn}_${refColumn}`,
|
|
||||||
});
|
|
||||||
sourceColumnMap[refTable] = inferredSourceColumn;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등)
|
|
||||||
// 단, 첫 번째 컬럼과 다른 경우만
|
|
||||||
const existingAliases = additionalJoinColumns
|
|
||||||
.filter((j) => j.referenceTable === refTable)
|
|
||||||
.map((j) => j.joinAlias);
|
|
||||||
const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`;
|
|
||||||
|
|
||||||
if (!existingAliases.includes(newAlias)) {
|
|
||||||
additionalJoinColumns.push({
|
|
||||||
sourceTable: leftTableName,
|
|
||||||
sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn,
|
|
||||||
referenceTable: refTable,
|
|
||||||
joinAlias: newAlias,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
|
|
||||||
console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
|
|
||||||
|
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 100,
|
size: 100,
|
||||||
search: filters, // 필터 조건 전달
|
search: filters,
|
||||||
enableEntityJoin: true, // 엔티티 조인 활성화
|
enableEntityJoin: true,
|
||||||
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
|
dataFilter: componentConfig.leftPanel?.dataFilter,
|
||||||
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼
|
additionalJoinColumns: leftJoinColumns,
|
||||||
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
|
companyCodeOverride: companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 디버깅: API 응답 데이터의 키 확인
|
// 🔍 디버깅: API 응답 데이터의 키 확인
|
||||||
|
|
@ -1088,11 +1102,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 🆕 엔티티 조인 API 사용
|
// 🆕 엔티티 조인 API 사용
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const rightDetailJoinColumns = extractAdditionalJoinColumns(
|
||||||
|
componentConfig.rightPanel?.columns,
|
||||||
|
rightTableName,
|
||||||
|
);
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
search: { id: primaryKey },
|
search: { id: primaryKey },
|
||||||
enableEntityJoin: true, // 엔티티 조인 활성화
|
enableEntityJoin: true,
|
||||||
size: 1,
|
size: 1,
|
||||||
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: rightDetailJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
|
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
|
||||||
|
|
@ -1136,6 +1155,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
const allResults: any[] = [];
|
const allResults: any[] = [];
|
||||||
|
|
||||||
|
// 🆕 우측 패널 Entity 조인 컬럼 추출 (그룹 합산용)
|
||||||
|
const rightJoinColumnsForGroup = extractAdditionalJoinColumns(
|
||||||
|
componentConfig.rightPanel?.columns,
|
||||||
|
rightTableName,
|
||||||
|
);
|
||||||
|
|
||||||
// 각 원본 항목에 대해 조회
|
// 각 원본 항목에 대해 조회
|
||||||
for (const originalItem of leftItem._originalItems) {
|
for (const originalItem of leftItem._originalItems) {
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
@ -1150,7 +1175,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
search: searchConditions,
|
search: searchConditions,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달
|
||||||
});
|
});
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
allResults.push(...result.data);
|
allResults.push(...result.data);
|
||||||
|
|
@ -1180,12 +1206,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
||||||
|
|
||||||
|
// 🆕 우측 패널 config의 Entity 조인 컬럼 추출
|
||||||
|
const rightJoinColumns = extractAdditionalJoinColumns(
|
||||||
|
componentConfig.rightPanel?.columns,
|
||||||
|
rightTableName,
|
||||||
|
);
|
||||||
|
if (rightJoinColumns) {
|
||||||
|
console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns);
|
||||||
|
}
|
||||||
|
|
||||||
// 엔티티 조인 API로 데이터 조회
|
// 엔티티 조인 API로 데이터 조회
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
search: searchConditions,
|
search: searchConditions,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
companyCodeOverride: companyCode, // 🆕 프리뷰용 회사 코드 오버라이드
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||||
|
|
@ -1255,14 +1291,117 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 추가 탭 데이터 로딩 함수
|
||||||
|
const loadTabData = useCallback(
|
||||||
|
async (tabIndex: number, leftItem: any) => {
|
||||||
|
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||||
|
if (!tabConfig || !leftItem || isDesignMode) return;
|
||||||
|
|
||||||
|
const tabTableName = tabConfig.tableName;
|
||||||
|
if (!tabTableName) return;
|
||||||
|
|
||||||
|
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
|
||||||
|
try {
|
||||||
|
const keys = tabConfig.relation?.keys;
|
||||||
|
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||||
|
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||||
|
|
||||||
|
// 🆕 탭 config의 Entity 조인 컬럼 추출
|
||||||
|
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
|
||||||
|
if (tabJoinColumns) {
|
||||||
|
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resultData: any[] = [];
|
||||||
|
|
||||||
|
if (leftColumn && rightColumn) {
|
||||||
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (keys && keys.length > 0) {
|
||||||
|
keys.forEach((key: any) => {
|
||||||
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
|
searchConditions[key.rightColumn] = {
|
||||||
|
value: leftItem[key.leftColumn],
|
||||||
|
operator: "equals",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const leftValue = leftItem[leftColumn];
|
||||||
|
if (leftValue !== undefined) {
|
||||||
|
searchConditions[rightColumn] = {
|
||||||
|
value: leftValue,
|
||||||
|
operator: "equals",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
search: searchConditions,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
||||||
|
});
|
||||||
|
resultData = result.data || [];
|
||||||
|
} else {
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
||||||
|
});
|
||||||
|
resultData = result.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
|
||||||
|
toast({
|
||||||
|
title: "데이터 로드 실패",
|
||||||
|
description: `탭 데이터를 불러올 수 없습니다.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 탭 변경 핸들러
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(newTabIndex: number) => {
|
||||||
|
setActiveTabIndex(newTabIndex);
|
||||||
|
|
||||||
|
if (selectedLeftItem) {
|
||||||
|
if (newTabIndex === 0) {
|
||||||
|
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!tabsData[newTabIndex]) {
|
||||||
|
loadTabData(newTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
||||||
|
);
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러
|
// 좌측 항목 선택 핸들러
|
||||||
const handleLeftItemSelect = useCallback(
|
const handleLeftItemSelect = useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||||
loadRightData(item);
|
setTabsData({}); // 모든 탭 데이터 초기화
|
||||||
|
|
||||||
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
// 현재 활성 탭에 따라 데이터 로드
|
||||||
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(item);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
||||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
if (leftTableName && !isDesignMode) {
|
if (leftTableName && !isDesignMode) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
|
|
@ -1271,7 +1410,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
|
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우측 항목 확장/축소 토글
|
// 우측 항목 확장/축소 토글
|
||||||
|
|
@ -1574,6 +1713,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 추가 탭의 테이블도 카테고리 로드 대상에 포함
|
||||||
|
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||||
|
additionalTabs.forEach((tab: any) => {
|
||||||
|
if (tab.tableName) {
|
||||||
|
tablesToLoad.add(tab.tableName);
|
||||||
|
}
|
||||||
|
// 추가 탭 컬럼에서 조인된 테이블 추출
|
||||||
|
(tab.columns || []).forEach((col: any) => {
|
||||||
|
const colName = col.name || col.columnName;
|
||||||
|
if (colName && colName.includes(".")) {
|
||||||
|
const joinTableName = colName.split(".")[0];
|
||||||
|
tablesToLoad.add(joinTableName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
|
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
|
||||||
|
|
||||||
// 각 테이블에 대해 카테고리 매핑 로드
|
// 각 테이블에 대해 카테고리 매핑 로드
|
||||||
|
|
@ -1625,7 +1780,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightCategoryMappings();
|
loadRightCategoryMappings();
|
||||||
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||||
|
|
||||||
// 항목 펼치기/접기 토글
|
// 항목 펼치기/접기 토글
|
||||||
const toggleExpand = useCallback((itemId: any) => {
|
const toggleExpand = useCallback((itemId: any) => {
|
||||||
|
|
@ -1668,13 +1823,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 수정 버튼 핸들러
|
// 수정 버튼 핸들러
|
||||||
const handleEditClick = useCallback(
|
const handleEditClick = useCallback(
|
||||||
(panel: "left" | "right", item: any) => {
|
(panel: "left" | "right", item: any) => {
|
||||||
// 🆕 우측 패널 수정 버튼 설정 확인
|
// 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원)
|
||||||
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
if (panel === "right") {
|
||||||
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
const editButtonConfig =
|
||||||
|
activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel?.editButton
|
||||||
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.editButton;
|
||||||
|
|
||||||
if (modalScreenId) {
|
const currentTableName =
|
||||||
// 커스텀 모달 화면 열기
|
activeTabIndex === 0
|
||||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
? componentConfig.rightPanel?.tableName || ""
|
||||||
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
|
||||||
|
|
||||||
|
if (editButtonConfig?.mode === "modal") {
|
||||||
|
const modalScreenId = editButtonConfig?.modalScreenId;
|
||||||
|
|
||||||
|
if (modalScreenId) {
|
||||||
|
// 커스텀 모달 화면 열기
|
||||||
|
const rightTableName = currentTableName;
|
||||||
|
|
||||||
// Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
|
// Primary Key 찾기 (우선순위: id > ID > user_id > {table}_id > 첫 번째 필드)
|
||||||
let primaryKeyName = "id";
|
let primaryKeyName = "id";
|
||||||
|
|
@ -1750,7 +1916,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1760,7 +1927,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setEditModalFormData({ ...item });
|
setEditModalFormData({ ...item });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
},
|
},
|
||||||
[componentConfig],
|
[componentConfig, activeTabIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 수정 모달 저장
|
// 수정 모달 저장
|
||||||
|
|
@ -2220,9 +2387,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (!isDesignMode) {
|
if (!isDesignMode) {
|
||||||
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
|
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
// 선택된 항목이 있으면 우측 패널도 새로고침
|
// 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침
|
||||||
if (selectedLeftItem) {
|
if (selectedLeftItem) {
|
||||||
loadRightData(selectedLeftItem);
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -2232,7 +2403,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("refreshTable", handleRefreshTable);
|
window.removeEventListener("refreshTable", handleRefreshTable);
|
||||||
};
|
};
|
||||||
}, [isDesignMode, loadLeftData, loadRightData, selectedLeftItem]);
|
}, [isDesignMode, loadLeftData, loadRightData, loadTabData, activeTabIndex, selectedLeftItem]);
|
||||||
|
|
||||||
// 리사이저 드래그 핸들러
|
// 리사이저 드래그 핸들러
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
|
@ -3021,24 +3192,63 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
style={{
|
style={{
|
||||||
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
||||||
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
|
||||||
padding: "0 1rem",
|
padding: "0 0.75rem",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">
|
<div className="flex items-center gap-0">
|
||||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
{/* 탭이 없으면 제목만, 있으면 탭으로 전환 */}
|
||||||
</CardTitle>
|
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
|
||||||
|
<div className="flex items-center gap-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTabChange(0)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-sm font-medium transition-colors",
|
||||||
|
activeTabIndex === 0
|
||||||
|
? "text-foreground border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{componentConfig.rightPanel?.title || "기본"}
|
||||||
|
</button>
|
||||||
|
{componentConfig.rightPanel?.additionalTabs?.map((tab: any, index: number) => (
|
||||||
|
<button
|
||||||
|
key={tab.tabId || `tab-${index}`}
|
||||||
|
onClick={() => handleTabChange(index + 1)}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-sm font-medium transition-colors",
|
||||||
|
activeTabIndex === index + 1
|
||||||
|
? "text-foreground border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label || `탭 ${index + 1}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CardTitle className="text-base font-semibold">
|
||||||
|
{componentConfig.rightPanel?.title || "우측 패널"}
|
||||||
|
</CardTitle>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{componentConfig.rightPanel?.showAdd && (
|
{activeTabIndex === 0
|
||||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
? componentConfig.rightPanel?.showAdd && (
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||||
추가
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
</Button>
|
추가
|
||||||
)}
|
</Button>
|
||||||
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
)
|
||||||
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.showAdd && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3057,8 +3267,139 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 우측 데이터/커스텀 */}
|
{/* 추가 탭 컨텐츠 */}
|
||||||
{componentConfig.rightPanel?.displayMode === "custom" ? (
|
{activeTabIndex > 0 ? (
|
||||||
|
(() => {
|
||||||
|
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any;
|
||||||
|
const currentTabData = tabsData[activeTabIndex] || [];
|
||||||
|
const isTabLoading = tabsLoading[activeTabIndex];
|
||||||
|
|
||||||
|
if (isTabLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedLeftItem) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
|
||||||
|
<p>좌측에서 항목을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTabData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
|
||||||
|
<p>관련 데이터가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 컬럼 설정
|
||||||
|
const tabColumns = currentTabConfig?.columns || [];
|
||||||
|
|
||||||
|
// 테이블 모드로 표시
|
||||||
|
if (currentTabConfig?.displayMode === "table") {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
{tabColumns.map((col: any) => (
|
||||||
|
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-medium">
|
||||||
|
{col.label || col.name}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-medium">작업</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentTabData.map((item: any, idx: number) => (
|
||||||
|
<tr key={item.id || idx} className="hover:bg-muted/50 border-b">
|
||||||
|
{tabColumns.map((col: any) => (
|
||||||
|
<td key={col.name} className="px-3 py-2 text-xs">
|
||||||
|
{formatCellValue(
|
||||||
|
col.name,
|
||||||
|
getEntityJoinValue(item, col.name),
|
||||||
|
rightCategoryMappings,
|
||||||
|
col.format,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleEditClick("right", item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleDeleteClick("right", item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리스트(카드) 모드로 표시
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{currentTabData.map((item: any, idx: number) => (
|
||||||
|
<div key={item.id || idx} className="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{tabColumns.map((col: any) => (
|
||||||
|
<span key={col.name}>
|
||||||
|
{formatCellValue(
|
||||||
|
col.name,
|
||||||
|
getEntityJoinValue(item, col.name),
|
||||||
|
rightCategoryMappings,
|
||||||
|
col.format,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleEditClick("right", item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
|
onClick={() => handleDeleteClick("right", item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : componentConfig.rightPanel?.displayMode === "custom" ? (
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
<div
|
<div
|
||||||
className="relative h-full w-full"
|
className="relative h-full w-full"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -50,6 +50,14 @@ export interface AdditionalTabConfig {
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
dateFormat?: string;
|
dateFormat?: string;
|
||||||
};
|
};
|
||||||
|
// Entity 조인 컬럼 정보
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
joinInfo?: {
|
||||||
|
sourceTable: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
referenceTable: string;
|
||||||
|
joinAlias: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
@ -145,6 +153,14 @@ export interface SplitPanelLayoutConfig {
|
||||||
suffix?: string; // 접미사 (예: "원", "개")
|
suffix?: string; // 접미사 (예: "원", "개")
|
||||||
dateFormat?: string; // 날짜 포맷 (type: "date")
|
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||||
};
|
};
|
||||||
|
// Entity 조인 컬럼 정보
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
joinInfo?: {
|
||||||
|
sourceTable: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
referenceTable: string;
|
||||||
|
joinAlias: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
// 추가 모달에서 입력받을 컬럼 설정
|
// 추가 모달에서 입력받을 컬럼 설정
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
@ -217,6 +233,14 @@ export interface SplitPanelLayoutConfig {
|
||||||
suffix?: string; // 접미사 (예: "원", "개")
|
suffix?: string; // 접미사 (예: "원", "개")
|
||||||
dateFormat?: string; // 날짜 포맷 (type: "date")
|
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||||
};
|
};
|
||||||
|
// Entity 조인 컬럼 정보
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
joinInfo?: {
|
||||||
|
sourceTable: string;
|
||||||
|
sourceColumn: string;
|
||||||
|
referenceTable: string;
|
||||||
|
joinAlias: string;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
// 추가 모달에서 입력받을 컬럼 설정
|
// 추가 모달에서 입력받을 컬럼 설정
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue