jskim-node #390
|
|
@ -18,45 +18,6 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
|
||||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호(password) 타입 컬럼의 값을 빈 문자열로 마스킹
|
|
||||||
* - table_type_columns에서 input_type = 'password'인 컬럼을 조회
|
|
||||||
* - 데이터 응답에서 해당 컬럼 값을 비워서 해시값 노출 방지
|
|
||||||
*/
|
|
||||||
async function maskPasswordColumns(tableName: string, data: any): Promise<any> {
|
|
||||||
try {
|
|
||||||
const passwordCols = await query<{ column_name: string }>(
|
|
||||||
`SELECT DISTINCT column_name FROM table_type_columns
|
|
||||||
WHERE table_name = $1 AND input_type = 'password'`,
|
|
||||||
[tableName]
|
|
||||||
);
|
|
||||||
if (passwordCols.length === 0) return data;
|
|
||||||
|
|
||||||
const passwordColumnNames = new Set(passwordCols.map(c => c.column_name));
|
|
||||||
|
|
||||||
// 단일 객체 처리
|
|
||||||
const maskRow = (row: any) => {
|
|
||||||
if (!row || typeof row !== "object") return row;
|
|
||||||
const masked = { ...row };
|
|
||||||
for (const col of passwordColumnNames) {
|
|
||||||
if (col in masked) {
|
|
||||||
masked[col] = ""; // 해시값 대신 빈 문자열
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return masked;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data.map(maskRow);
|
|
||||||
}
|
|
||||||
return maskRow(data);
|
|
||||||
} catch (error) {
|
|
||||||
// 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지)
|
|
||||||
console.warn("⚠️ password 컬럼 마스킹 실패:", error);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GetTableDataParams {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
@ -661,14 +622,14 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
|
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
|
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -687,7 +648,7 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
|
data: result[0],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
import tableCategoryValueService from "./tableCategoryValueService";
|
import tableCategoryValueService from "./tableCategoryValueService";
|
||||||
import { PasswordUtils } from "../utils/passwordUtils";
|
|
||||||
|
|
||||||
export interface FormDataResult {
|
export interface FormDataResult {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -860,33 +859,6 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비밀번호(password) 타입 컬럼 처리
|
|
||||||
// - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지)
|
|
||||||
// - 값이 있으면 암호화 후 저장
|
|
||||||
try {
|
|
||||||
const passwordCols = await query<{ column_name: string }>(
|
|
||||||
`SELECT DISTINCT column_name FROM table_type_columns
|
|
||||||
WHERE table_name = $1 AND input_type = 'password'`,
|
|
||||||
[tableName]
|
|
||||||
);
|
|
||||||
for (const { column_name } of passwordCols) {
|
|
||||||
if (column_name in changedFields) {
|
|
||||||
const pwValue = changedFields[column_name];
|
|
||||||
if (!pwValue || pwValue === "") {
|
|
||||||
// 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거)
|
|
||||||
delete changedFields[column_name];
|
|
||||||
console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`);
|
|
||||||
} else {
|
|
||||||
// 값 있음 → 암호화하여 저장
|
|
||||||
changedFields[column_name] = PasswordUtils.encrypt(pwValue);
|
|
||||||
console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (pwError) {
|
|
||||||
console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 변경된 필드가 없으면 업데이트 건너뛰기
|
// 변경된 필드가 없으면 업데이트 건너뛰기
|
||||||
if (Object.keys(changedFields).length === 0) {
|
if (Object.keys(changedFields).length === 0) {
|
||||||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||||||
|
|
|
||||||
|
|
@ -2231,11 +2231,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
|
// 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
|
||||||
const allLayerComponents = useMemo(() => {
|
const allLayerComponents = useMemo(() => {
|
||||||
return layers.flatMap((layer) => layer.components);
|
return layers.flatMap((layer) => layer.components);
|
||||||
}, [layers]);
|
}, [layers]);
|
||||||
|
|
||||||
|
// 🔧 활성 레이어 컴포넌트만 통합 (저장/데이터 수집용)
|
||||||
|
// 기본 레이어(base) + 현재 활성화된 조건부 레이어만 포함
|
||||||
|
// 비활성 레이어의 중복 columnName 컴포넌트가 저장 데이터를 오염시키는 문제 해결
|
||||||
|
const visibleLayerComponents = useMemo(() => {
|
||||||
|
return layers
|
||||||
|
.filter((layer) => layer.type === "base" || activeLayerIds.includes(layer.id))
|
||||||
|
.flatMap((layer) => layer.components);
|
||||||
|
}, [layers, activeLayerIds]);
|
||||||
|
|
||||||
// 🆕 레이어별 컴포넌트 렌더링 함수
|
// 🆕 레이어별 컴포넌트 렌더링 함수
|
||||||
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
||||||
// 활성화되지 않은 레이어는 렌더링하지 않음
|
// 활성화되지 않은 레이어는 렌더링하지 않음
|
||||||
|
|
@ -2272,7 +2281,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2344,7 +2353,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2387,7 +2396,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2423,7 +2432,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2433,7 +2442,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
|
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitPanelProvider>
|
<SplitPanelProvider>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
[config, component.config, component.id],
|
[config, component.config, component.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환)
|
||||||
|
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
|
||||||
|
const sourceKeyField = componentConfig.sourceKeyField || "item_id";
|
||||||
|
|
||||||
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
|
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
|
||||||
const dataSourceId = useMemo(
|
const dataSourceId = useMemo(
|
||||||
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
|
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
|
||||||
|
|
@ -228,7 +232,21 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const editTableName = new URLSearchParams(window.location.search).get("tableName");
|
const editTableName = new URLSearchParams(window.location.search).get("tableName");
|
||||||
const allTableData: Record<string, Record<string, any>[]> = {};
|
const allTableData: Record<string, Record<string, any>[]> = {};
|
||||||
|
|
||||||
if (firstRecord.customer_id && firstRecord.item_id) {
|
// 동적 필터 구성: parentDataMapping의 targetField + sourceKeyField
|
||||||
|
const editFilters: Record<string, any> = {};
|
||||||
|
const parentMappings = componentConfig.parentDataMapping || [];
|
||||||
|
parentMappings.forEach((mapping: any) => {
|
||||||
|
if (mapping.targetField && firstRecord[mapping.targetField]) {
|
||||||
|
editFilters[mapping.targetField] = firstRecord[mapping.targetField];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (firstRecord[sourceKeyField]) {
|
||||||
|
editFilters[sourceKeyField] = firstRecord[sourceKeyField];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequiredKeys = Object.keys(editFilters).length >= 2;
|
||||||
|
|
||||||
|
if (hasRequiredKeys) {
|
||||||
try {
|
try {
|
||||||
const { dataApi } = await import("@/lib/api/data");
|
const { dataApi } = await import("@/lib/api/data");
|
||||||
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
|
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
|
||||||
|
|
@ -238,10 +256,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
|
|
||||||
for (const table of allTables) {
|
for (const table of allTables) {
|
||||||
const response = await dataApi.getTableData(table, {
|
const response = await dataApi.getTableData(table, {
|
||||||
filters: {
|
filters: editFilters,
|
||||||
customer_id: firstRecord.customer_id,
|
|
||||||
item_id: firstRecord.item_id,
|
|
||||||
},
|
|
||||||
sortBy: "created_date",
|
sortBy: "created_date",
|
||||||
sortOrder: "desc",
|
sortOrder: "desc",
|
||||||
});
|
});
|
||||||
|
|
@ -350,8 +365,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem: ItemData = {
|
const newItem: ItemData = {
|
||||||
// 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음)
|
// 수정 모드: sourceKeyField를 우선 사용 (id는 가격레코드의 PK일 수 있음)
|
||||||
id: String(firstRecord.item_id || firstRecord.id || "edit"),
|
id: String(firstRecord[sourceKeyField] || firstRecord.id || "edit"),
|
||||||
originalData: firstRecord,
|
originalData: firstRecord,
|
||||||
fieldGroups: mainFieldGroups,
|
fieldGroups: mainFieldGroups,
|
||||||
};
|
};
|
||||||
|
|
@ -635,39 +650,39 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const mainGroups = groupsByTable.get(mainTable) || [];
|
const mainGroups = groupsByTable.get(mainTable) || [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
// item_id 추출: originalData.item_id를 최우선 사용
|
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
|
||||||
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
|
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
|
||||||
let itemId: string | null = null;
|
let sourceKeyValue: string | null = null;
|
||||||
|
|
||||||
// 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값)
|
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
|
||||||
if (item.originalData && item.originalData.item_id) {
|
if (item.originalData && item.originalData[sourceKeyField]) {
|
||||||
itemId = item.originalData.item_id;
|
sourceKeyValue = item.originalData[sourceKeyField];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
|
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
|
||||||
if (!itemId) {
|
if (!sourceKeyValue) {
|
||||||
mainGroups.forEach((group) => {
|
mainGroups.forEach((group) => {
|
||||||
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
||||||
groupFields.forEach((field) => {
|
groupFields.forEach((field) => {
|
||||||
if (field.name === "item_id" && field.autoFillFrom && item.originalData) {
|
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
|
||||||
itemId = item.originalData[field.autoFillFrom] || null;
|
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3순위: fallback (최후의 수단)
|
// 3순위: fallback (최후의 수단)
|
||||||
if (!itemId && item.originalData) {
|
if (!sourceKeyValue && item.originalData) {
|
||||||
itemId = item.originalData.id || null;
|
sourceKeyValue = item.originalData.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!itemId) {
|
if (!sourceKeyValue) {
|
||||||
console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item);
|
console.error(`❌ [2단계 저장] ${sourceKeyField}를 찾을 수 없음:`, item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsert 공통 parentKeys: customer_id + item_id (정확한 매칭)
|
// upsert 공통 parentKeys: parentMapping 키 + sourceKeyField (정확한 매칭)
|
||||||
const itemParentKeys = { ...parentKeys, item_id: itemId };
|
const itemParentKeys = { ...parentKeys, [sourceKeyField]: sourceKeyValue };
|
||||||
|
|
||||||
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
|
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
|
||||||
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
|
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
|
||||||
|
|
@ -688,11 +703,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
if (entry._dbRecordId) {
|
if (entry._dbRecordId) {
|
||||||
record.id = entry._dbRecordId;
|
record.id = entry._dbRecordId;
|
||||||
}
|
}
|
||||||
// item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
|
// sourceKeyField는 정확한 sourceKeyValue 변수 사용 (autoFillFrom:"id" 오작동 방지)
|
||||||
record.item_id = itemId;
|
record[sourceKeyField] = sourceKeyValue;
|
||||||
// 나머지 autoFillFrom 필드 처리
|
// 나머지 autoFillFrom 필드 처리
|
||||||
groupFields.forEach((field) => {
|
groupFields.forEach((field) => {
|
||||||
if (field.name !== "item_id" && field.autoFillFrom && item.originalData) {
|
if (field.name !== sourceKeyField && field.autoFillFrom && item.originalData) {
|
||||||
const value = item.originalData[field.autoFillFrom];
|
const value = item.originalData[field.autoFillFrom];
|
||||||
if (value !== undefined && value !== null && !record[field.name]) {
|
if (value !== undefined && value !== null && !record[field.name]) {
|
||||||
record[field.name] = value;
|
record[field.name] = value;
|
||||||
|
|
@ -1700,7 +1715,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
// 디자인 모드: 샘플 데이터로 미리보기 표시
|
// 디자인 모드: 샘플 데이터로 미리보기 표시
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
const sampleDisplayCols = componentConfig.displayColumns || [];
|
const sampleDisplayCols = componentConfig.displayColumns || [];
|
||||||
const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== "item_id" && f.width !== "0px");
|
const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== sourceKeyField && f.width !== "0px");
|
||||||
const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
|
const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
|
||||||
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
|
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1183,31 +1183,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
|
|
||||||
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
|
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
|
||||||
|
// detail 모드: 선택 안 하면 아무것도 안 뜸, 선택하면 필터링
|
||||||
|
// join 모드: 선택 안 하면 전체, 선택하면 필터링
|
||||||
if (!leftItem) return;
|
if (!leftItem) return;
|
||||||
|
|
||||||
setIsLoadingRight(true);
|
setIsLoadingRight(true);
|
||||||
try {
|
try {
|
||||||
if (relationshipType === "detail") {
|
// detail / join 모두 동일한 필터링 로직 사용
|
||||||
// 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화)
|
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
|
||||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
{
|
||||||
|
|
||||||
// 🆕 엔티티 조인 API 사용
|
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
|
||||||
const rightDetailJoinColumns = extractAdditionalJoinColumns(
|
|
||||||
componentConfig.rightPanel?.columns,
|
|
||||||
rightTableName,
|
|
||||||
);
|
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
|
||||||
search: { id: primaryKey },
|
|
||||||
enableEntityJoin: true,
|
|
||||||
size: 1,
|
|
||||||
companyCodeOverride: companyCode,
|
|
||||||
additionalJoinColumns: rightDetailJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
|
||||||
});
|
|
||||||
|
|
||||||
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
|
|
||||||
setRightData(detail);
|
|
||||||
} else if (relationshipType === "join") {
|
|
||||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||||
const keys = componentConfig.rightPanel?.relation?.keys;
|
const keys = componentConfig.rightPanel?.relation?.keys;
|
||||||
const leftTable = componentConfig.leftPanel?.tableName;
|
const leftTable = componentConfig.leftPanel?.tableName;
|
||||||
|
|
@ -1443,8 +1427,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 탭의 dataFilter (API 전달용)
|
// 탭의 dataFilter (API 전달용)
|
||||||
const tabDataFilterForApi = (tabConfig as any).dataFilter;
|
const tabDataFilterForApi = (tabConfig as any).dataFilter;
|
||||||
|
|
||||||
|
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
|
||||||
|
const tabRelationType = tabConfig.relation?.type || "join";
|
||||||
|
|
||||||
if (!leftItem) {
|
if (!leftItem) {
|
||||||
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달)
|
if (tabRelationType === "detail") {
|
||||||
|
// detail 모드: 선택 안 하면 아무것도 안 뜸
|
||||||
|
resultData = [];
|
||||||
|
} else {
|
||||||
|
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
|
@ -1453,6 +1444,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
dataFilter: tabDataFilterForApi,
|
dataFilter: tabDataFilterForApi,
|
||||||
});
|
});
|
||||||
resultData = result.data || [];
|
resultData = result.data || [];
|
||||||
|
}
|
||||||
} else if (leftColumn && rightColumn) {
|
} else if (leftColumn && rightColumn) {
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
||||||
|
|
@ -2781,16 +2773,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
|
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
|
||||||
|
// join 모드: 초기 전체 로드 / detail 모드: 초기 로드 안 함
|
||||||
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
if (relationshipType === "join") {
|
if (relationshipType === "join") {
|
||||||
loadRightData(null);
|
loadRightData(null);
|
||||||
// 추가 탭도 전체 데이터 로드
|
}
|
||||||
|
// 추가 탭: 각 탭의 relation.type에 따라 초기 로드 결정
|
||||||
const tabs = componentConfig.rightPanel?.additionalTabs;
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
if (tabs && tabs.length > 0) {
|
if (tabs && tabs.length > 0) {
|
||||||
tabs.forEach((_: any, idx: number) => {
|
tabs.forEach((tab: any, idx: number) => {
|
||||||
|
const tabRelType = tab.relation?.type || "join";
|
||||||
|
if (tabRelType === "join") {
|
||||||
loadTabData(idx + 1, null);
|
loadTabData(idx + 1, null);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -4645,7 +4641,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
<p className="text-xs">선택한 항목의 관련 데이터가 여기에 표시됩니다</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1542,13 +1542,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
|
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
|
||||||
}, [loadedTableColumns, leftTableName]);
|
}, [loadedTableColumns, leftTableName]);
|
||||||
|
|
||||||
// 우측 테이블명 (상세 모드에서는 좌측과 동일)
|
// 우측 테이블명
|
||||||
const rightTableName = useMemo(() => {
|
const rightTableName = useMemo(() => {
|
||||||
if (relationshipType === "detail") {
|
|
||||||
return leftTableName; // 상세 모드에서는 좌측과 동일
|
|
||||||
}
|
|
||||||
return config.rightPanel?.tableName || "";
|
return config.rightPanel?.tableName || "";
|
||||||
}, [relationshipType, leftTableName, config.rightPanel?.tableName]);
|
}, [config.rightPanel?.tableName]);
|
||||||
|
|
||||||
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
||||||
const rightTableColumns = useMemo(() => {
|
const rightTableColumns = useMemo(() => {
|
||||||
|
|
@ -1567,8 +1564,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 조인 모드에서 우측 테이블 선택 시 사용할 테이블 목록
|
// 우측 테이블 선택 시 사용할 테이블 목록 (모든 모드에서 전체 테이블 선택 가능)
|
||||||
const availableRightTables = relationshipType === "join" ? allTables : tables;
|
const availableRightTables = allTables;
|
||||||
|
|
||||||
console.log("📊 분할패널 테이블 목록 상태:");
|
console.log("📊 분할패널 테이블 목록 상태:");
|
||||||
console.log(" - relationshipType:", relationshipType);
|
console.log(" - relationshipType:", relationshipType);
|
||||||
|
|
@ -1584,7 +1581,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
{
|
{
|
||||||
id: "basic",
|
id: "basic",
|
||||||
title: "기본 설정",
|
title: "기본 설정",
|
||||||
desc: `${relationshipType === "detail" ? "1건 상세보기" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
|
desc: `${relationshipType === "detail" ? "선택 시 표시" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1638,35 +1635,27 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<Select
|
<Select
|
||||||
value={relationshipType}
|
value={relationshipType}
|
||||||
onValueChange={(value: "join" | "detail") => {
|
onValueChange={(value: "join" | "detail") => {
|
||||||
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
|
|
||||||
if (value === "detail" && screenTableName) {
|
|
||||||
updateRightPanel({
|
|
||||||
relation: { ...config.rightPanel?.relation, type: value },
|
|
||||||
tableName: screenTableName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateRightPanel({
|
updateRightPanel({
|
||||||
relation: { ...config.rightPanel?.relation, type: value },
|
relation: { ...config.rightPanel?.relation, type: value },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 bg-white">
|
<SelectTrigger className="h-10 bg-white">
|
||||||
<SelectValue placeholder="표시 방식 선택">
|
<SelectValue placeholder="표시 방식 선택">
|
||||||
{relationshipType === "detail" ? "1건 상세보기" : "연관 목록"}
|
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="detail">
|
<SelectItem value="detail">
|
||||||
<div className="flex flex-col py-1">
|
<div className="flex flex-col py-1">
|
||||||
<span className="text-sm font-medium">1건 상세보기</span>
|
<span className="text-sm font-medium">선택 시 표시</span>
|
||||||
<span className="text-xs text-gray-500">좌측 클릭 시 해당 항목의 상세 정보 표시 (같은 테이블)</span>
|
<span className="text-xs text-gray-500">좌측 선택 시에만 우측 데이터 표시 / 미선택 시 빈 화면</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="join">
|
<SelectItem value="join">
|
||||||
<div className="flex flex-col py-1">
|
<div className="flex flex-col py-1">
|
||||||
<span className="text-sm font-medium">연관 목록</span>
|
<span className="text-sm font-medium">연관 목록</span>
|
||||||
<span className="text-xs text-gray-500">좌측 클릭 시 연관된 데이터 목록 표시 / 미선택 시 전체 표시</span>
|
<span className="text-xs text-gray-500">미선택 시 전체 표시 / 좌측 선택 시 필터링</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -2305,7 +2294,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 우측 패널 설정 */}
|
{/* 우측 패널 설정 */}
|
||||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "1건 상세보기" : "연관 목록"})</h3>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "선택 시 표시" : "연관 목록"})</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -2338,21 +2327,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
||||||
{relationshipType === "detail" ? (
|
|
||||||
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>테이블 (좌측과 동일)</Label>
|
<Label>우측 패널 테이블</Label>
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-gray-500">상세 모드에서는 좌측과 동일한 테이블을 사용합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 조건 필터 모드: 전체 테이블에서 선택 가능
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>필터링 대상 테이블</Label>
|
|
||||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -2394,7 +2370,6 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>표시 모드</Label>
|
<Label>표시 모드</Label>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue