Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
4473743d5f
|
|
@ -2830,12 +2830,12 @@ export class NodeFlowExecutionService {
|
||||||
inputData: any,
|
inputData: any,
|
||||||
context: ExecutionContext
|
context: ExecutionContext
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { conditions, logic } = node.data;
|
const { conditions, logic, targetLookup } = node.data;
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}`
|
`🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}`
|
||||||
);
|
);
|
||||||
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`);
|
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`);
|
||||||
|
|
||||||
if (inputData) {
|
if (inputData) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -2865,6 +2865,9 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||||
for (const item of inputData) {
|
for (const item of inputData) {
|
||||||
|
// 타겟 테이블 조회 (DB 기존값 비교용)
|
||||||
|
const targetRow = await this.lookupTargetRow(targetLookup, item, context);
|
||||||
|
|
||||||
const results: boolean[] = [];
|
const results: boolean[] = [];
|
||||||
|
|
||||||
for (const condition of conditions) {
|
for (const condition of conditions) {
|
||||||
|
|
@ -2887,9 +2890,14 @@ export class NodeFlowExecutionService {
|
||||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 연산자 처리
|
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
|
||||||
let compareValue = condition.value;
|
let compareValue = condition.value;
|
||||||
if (condition.valueType === "field") {
|
if (condition.valueType === "target" && targetRow) {
|
||||||
|
compareValue = targetRow[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else if (condition.valueType === "field") {
|
||||||
compareValue = item[condition.value];
|
compareValue = item[condition.value];
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
|
@ -2931,6 +2939,9 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단일 객체인 경우
|
// 단일 객체인 경우
|
||||||
|
// 타겟 테이블 조회 (DB 기존값 비교용)
|
||||||
|
const targetRow = await this.lookupTargetRow(targetLookup, inputData, context);
|
||||||
|
|
||||||
const results: boolean[] = [];
|
const results: boolean[] = [];
|
||||||
|
|
||||||
for (const condition of conditions) {
|
for (const condition of conditions) {
|
||||||
|
|
@ -2953,9 +2964,14 @@ export class NodeFlowExecutionService {
|
||||||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 연산자 처리
|
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
|
||||||
let compareValue = condition.value;
|
let compareValue = condition.value;
|
||||||
if (condition.valueType === "field") {
|
if (condition.valueType === "target" && targetRow) {
|
||||||
|
compareValue = targetRow[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else if (condition.valueType === "field") {
|
||||||
compareValue = inputData[condition.value];
|
compareValue = inputData[condition.value];
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
|
@ -2990,6 +3006,63 @@ export class NodeFlowExecutionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용)
|
||||||
|
* targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회
|
||||||
|
*/
|
||||||
|
private static async lookupTargetRow(
|
||||||
|
targetLookup: any,
|
||||||
|
sourceRow: any,
|
||||||
|
context: ExecutionContext
|
||||||
|
): Promise<any | null> {
|
||||||
|
if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const whereConditions = targetLookup.lookupKeys
|
||||||
|
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
|
||||||
|
.join(" AND ");
|
||||||
|
|
||||||
|
const lookupValues = targetLookup.lookupKeys.map(
|
||||||
|
(key: any) => sourceRow[key.sourceField]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 키값이 비어있으면 조회 불필요
|
||||||
|
if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) {
|
||||||
|
logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// company_code 필터링 (멀티테넌시)
|
||||||
|
const companyCode = context.buttonContext?.companyCode || sourceRow.company_code;
|
||||||
|
let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`;
|
||||||
|
const params = [...lookupValues];
|
||||||
|
|
||||||
|
if (companyCode && companyCode !== "*") {
|
||||||
|
sql += ` AND company_code = $${params.length + 1}`;
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " LIMIT 1";
|
||||||
|
|
||||||
|
logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`);
|
||||||
|
|
||||||
|
const targetRow = await queryOne(sql, params);
|
||||||
|
|
||||||
|
if (targetRow) {
|
||||||
|
logger.info(`🎯 타겟 데이터 조회 성공`);
|
||||||
|
} else {
|
||||||
|
logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetRow;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||||
* 다른 테이블에 값이 존재하는지 확인
|
* 다른 테이블에 값이 존재하는지 확인
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||||
|
|
||||||
|
// 타겟 조회 설정 (DB 기존값 비교용)
|
||||||
|
const [targetLookup, setTargetLookup] = useState<{
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string;
|
||||||
|
lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>;
|
||||||
|
} | undefined>(data.targetLookup);
|
||||||
|
const [targetLookupColumns, setTargetLookupColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
|
||||||
// EXISTS 연산자용 상태
|
// EXISTS 연산자용 상태
|
||||||
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||||
|
|
@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
setDisplayName(data.displayName || "조건 분기");
|
setDisplayName(data.displayName || "조건 분기");
|
||||||
setConditions(data.conditions || []);
|
setConditions(data.conditions || []);
|
||||||
setLogic(data.logic || "AND");
|
setLogic(data.logic || "AND");
|
||||||
|
setTargetLookup(data.targetLookup);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// targetLookup 테이블 변경 시 컬럼 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetLookup?.tableName) {
|
||||||
|
loadTableColumns(targetLookup.tableName).then((cols) => {
|
||||||
|
setTargetLookupColumns(cols);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTargetLookupColumns([]);
|
||||||
|
}
|
||||||
|
}, [targetLookup?.tableName]);
|
||||||
|
|
||||||
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadAllTables = async () => {
|
const loadAllTables = async () => {
|
||||||
|
|
@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 타겟 조회 테이블 변경
|
||||||
|
const handleTargetLookupTableChange = async (tableName: string) => {
|
||||||
|
await ensureTablesLoaded();
|
||||||
|
const tableInfo = allTables.find((t) => t.tableName === tableName);
|
||||||
|
const newLookup = {
|
||||||
|
tableName,
|
||||||
|
tableLabel: tableInfo?.tableLabel || tableName,
|
||||||
|
lookupKeys: targetLookup?.lookupKeys || [],
|
||||||
|
};
|
||||||
|
setTargetLookup(newLookup);
|
||||||
|
updateNode(nodeId, { targetLookup: newLookup });
|
||||||
|
|
||||||
|
// 컬럼 로드
|
||||||
|
const cols = await loadTableColumns(tableName);
|
||||||
|
setTargetLookupColumns(cols);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 조회 키 필드 변경
|
||||||
|
const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => {
|
||||||
|
if (!targetLookup) return;
|
||||||
|
const sourceFieldInfo = availableFields.find((f) => f.name === sourceField);
|
||||||
|
const newLookup = {
|
||||||
|
...targetLookup,
|
||||||
|
lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }],
|
||||||
|
};
|
||||||
|
setTargetLookup(newLookup);
|
||||||
|
updateNode(nodeId, { targetLookup: newLookup });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 조회 제거
|
||||||
|
const handleRemoveTargetLookup = () => {
|
||||||
|
setTargetLookup(undefined);
|
||||||
|
updateNode(nodeId, { targetLookup: undefined });
|
||||||
|
// target 타입 조건들을 field로 변경
|
||||||
|
const newConditions = conditions.map((c) =>
|
||||||
|
(c as any).valueType === "target" ? { ...c, valueType: "field" } : c
|
||||||
|
);
|
||||||
|
setConditions(newConditions);
|
||||||
|
updateNode(nodeId, { conditions: newConditions });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-4 p-4 pb-8">
|
<div className="space-y-4 p-4 pb-8">
|
||||||
|
|
@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 타겟 조회 (DB 기존값 비교) */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
<Database className="mr-1 inline h-3.5 w-3.5" />
|
||||||
|
타겟 조회 (DB 기존값)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!targetLookup ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="rounded border border-dashed p-3 text-center text-xs text-gray-400">
|
||||||
|
DB의 기존값과 비교하려면 타겟 테이블을 설정하세요.
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
await ensureTablesLoaded();
|
||||||
|
setTargetLookup({ tableName: "", lookupKeys: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Database className="mr-1 h-3 w-3" />
|
||||||
|
타겟 조회 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 rounded border bg-orange-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-orange-700">타겟 테이블</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleRemoveTargetLookup}
|
||||||
|
className="h-5 px-1 text-xs text-orange-500 hover:text-orange-700"
|
||||||
|
>
|
||||||
|
제거
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 선택 */}
|
||||||
|
{allTables.length > 0 ? (
|
||||||
|
<TableCombobox
|
||||||
|
tables={allTables}
|
||||||
|
value={targetLookup.tableName}
|
||||||
|
onSelect={handleTargetLookupTableChange}
|
||||||
|
placeholder="비교할 테이블 검색..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
테이블 로딩 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 키 필드 매핑 */}
|
||||||
|
{targetLookup.tableName && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-orange-600">조회 키 (소스 → 타겟)</Label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Select
|
||||||
|
value={targetLookup.lookupKeys?.[0]?.sourceField || ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const targetField = targetLookup.lookupKeys?.[0]?.targetField || "";
|
||||||
|
handleTargetLookupKeyChange(val, targetField);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="소스 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableFields.map((f) => (
|
||||||
|
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||||
|
{f.label || f.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-gray-400">=</span>
|
||||||
|
{targetLookupColumns.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={targetLookup.lookupKeys?.[0]?.targetField || ""}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const sourceField = targetLookup.lookupKeys?.[0]?.sourceField || "";
|
||||||
|
handleTargetLookupKeyChange(sourceField, val);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="타겟 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetLookupColumns.map((c) => (
|
||||||
|
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">
|
||||||
|
{c.columnLabel || c.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 rounded border border-dashed bg-gray-50 p-1 text-center text-[10px] text-gray-400">
|
||||||
|
컬럼 로딩 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-orange-100 p-1.5 text-[10px] text-orange-600">
|
||||||
|
비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 조건식 */}
|
{/* 조건식 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
|
@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
<SelectItem value="field">필드 참조</SelectItem>
|
<SelectItem value="field">필드 참조</SelectItem>
|
||||||
|
{targetLookup?.tableName && (
|
||||||
|
<SelectItem value="target">타겟 필드 (DB 기존값)</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">
|
<Label className="text-xs text-gray-600">
|
||||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
{(condition as any).valueType === "target"
|
||||||
|
? "타겟 필드 (DB 기존값)"
|
||||||
|
: (condition as any).valueType === "field"
|
||||||
|
? "비교 필드"
|
||||||
|
: "비교 값"}
|
||||||
</Label>
|
</Label>
|
||||||
{(condition as any).valueType === "field" ? (
|
{(condition as any).valueType === "target" ? (
|
||||||
|
// 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택
|
||||||
|
targetLookupColumns.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={condition.value as string}
|
||||||
|
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="DB 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targetLookupColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
<span className="ml-2 text-xs text-gray-400">({col.dataType})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
타겟 조회를 먼저 설정하세요
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (condition as any).valueType === "field" ? (
|
||||||
// 필드 참조: 드롭다운으로 선택
|
// 필드 참조: 드롭다운으로 선택
|
||||||
availableFields.length > 0 ? (
|
availableFields.length > 0 ? (
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null);
|
const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null);
|
||||||
const [deleteModalItem, setDeleteModalItem] = useState<any>(null);
|
const [deleteModalItem, setDeleteModalItem] = useState<any>(null);
|
||||||
|
const [deleteModalTableName, setDeleteModalTableName] = useState<string | null>(null); // 추가 탭 삭제 시 테이블명
|
||||||
|
|
||||||
// 리사이저 드래그 상태
|
// 리사이저 드래그 상태
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
@ -1102,7 +1103,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
searchValues,
|
searchValues,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 우측 데이터 로드
|
// 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드)
|
||||||
const loadRightData = useCallback(
|
const loadRightData = useCallback(
|
||||||
async (leftItem: any) => {
|
async (leftItem: any) => {
|
||||||
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
|
|
@ -1110,10 +1111,84 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
if (!rightTableName || isDesignMode) return;
|
if (!rightTableName || isDesignMode) return;
|
||||||
|
|
||||||
|
// 좌측 미선택 시: 전체 데이터 로드 (dataFilter 적용)
|
||||||
|
if (!leftItem && relationshipType === "join") {
|
||||||
|
setIsLoadingRight(true);
|
||||||
|
try {
|
||||||
|
const rightJoinColumns = extractAdditionalJoinColumns(
|
||||||
|
componentConfig.rightPanel?.columns,
|
||||||
|
rightTableName,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: rightJoinColumns,
|
||||||
|
dataFilter: componentConfig.rightPanel?.dataFilter,
|
||||||
|
});
|
||||||
|
|
||||||
|
// dataFilter 적용
|
||||||
|
let filteredData = result.data || [];
|
||||||
|
const dataFilter = componentConfig.rightPanel?.dataFilter;
|
||||||
|
if (dataFilter?.enabled && dataFilter.filters?.length > 0) {
|
||||||
|
filteredData = filteredData.filter((item: any) => {
|
||||||
|
return dataFilter.filters.every((cond: any) => {
|
||||||
|
const value = item[cond.columnName];
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals":
|
||||||
|
return value === cond.value;
|
||||||
|
case "notEquals":
|
||||||
|
return value !== cond.value;
|
||||||
|
case "contains":
|
||||||
|
return String(value || "").includes(String(cond.value));
|
||||||
|
case "is_null":
|
||||||
|
return value === null || value === undefined || value === "";
|
||||||
|
case "is_not_null":
|
||||||
|
return value !== null && value !== undefined && value !== "";
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// conditions 형식 dataFilter도 지원 (하위 호환성)
|
||||||
|
const dataFilterConditions = componentConfig.rightPanel?.dataFilter;
|
||||||
|
if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) {
|
||||||
|
filteredData = filteredData.filter((item: any) => {
|
||||||
|
return dataFilterConditions.conditions.every((cond: any) => {
|
||||||
|
const value = item[cond.column];
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals":
|
||||||
|
return value === cond.value;
|
||||||
|
case "notEquals":
|
||||||
|
return value !== cond.value;
|
||||||
|
case "contains":
|
||||||
|
return String(value || "").includes(String(cond.value));
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRightData(filteredData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("우측 전체 데이터 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRight(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
|
||||||
|
if (!leftItem) return;
|
||||||
|
|
||||||
setIsLoadingRight(true);
|
setIsLoadingRight(true);
|
||||||
try {
|
try {
|
||||||
if (relationshipType === "detail") {
|
if (relationshipType === "detail") {
|
||||||
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
|
// 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화)
|
||||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
||||||
|
|
||||||
// 🆕 엔티티 조인 API 사용
|
// 🆕 엔티티 조인 API 사용
|
||||||
|
|
@ -1342,11 +1417,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 추가 탭 데이터 로딩 함수
|
// 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드)
|
||||||
const loadTabData = useCallback(
|
const loadTabData = useCallback(
|
||||||
async (tabIndex: number, leftItem: any) => {
|
async (tabIndex: number, leftItem: any) => {
|
||||||
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||||
if (!tabConfig || !leftItem || isDesignMode) return;
|
if (!tabConfig || isDesignMode) return;
|
||||||
|
|
||||||
const tabTableName = tabConfig.tableName;
|
const tabTableName = tabConfig.tableName;
|
||||||
if (!tabTableName) return;
|
if (!tabTableName) return;
|
||||||
|
|
@ -1357,7 +1432,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||||
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||||
|
|
||||||
// 🆕 탭 config의 Entity 조인 컬럼 추출
|
// 탭 config의 Entity 조인 컬럼 추출
|
||||||
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
|
const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName);
|
||||||
if (tabJoinColumns) {
|
if (tabJoinColumns) {
|
||||||
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
|
console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns);
|
||||||
|
|
@ -1365,7 +1440,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
let resultData: any[] = [];
|
let resultData: any[] = [];
|
||||||
|
|
||||||
if (leftColumn && rightColumn) {
|
// 탭의 dataFilter (API 전달용)
|
||||||
|
const tabDataFilterForApi = (tabConfig as any).dataFilter;
|
||||||
|
|
||||||
|
if (!leftItem) {
|
||||||
|
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달)
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: tabJoinColumns,
|
||||||
|
dataFilter: tabDataFilterForApi,
|
||||||
|
});
|
||||||
|
resultData = result.data || [];
|
||||||
|
} else if (leftColumn && rightColumn) {
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
||||||
if (keys && keys.length > 0) {
|
if (keys && keys.length > 0) {
|
||||||
|
|
@ -1391,18 +1479,46 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
search: searchConditions,
|
search: searchConditions,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: tabJoinColumns,
|
||||||
|
dataFilter: tabDataFilterForApi,
|
||||||
});
|
});
|
||||||
resultData = result.data || [];
|
resultData = result.data || [];
|
||||||
} else {
|
} else {
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
additionalJoinColumns: tabJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
companyCodeOverride: companyCode,
|
||||||
|
additionalJoinColumns: tabJoinColumns,
|
||||||
|
dataFilter: tabDataFilterForApi,
|
||||||
});
|
});
|
||||||
resultData = result.data || [];
|
resultData = result.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 탭별 dataFilter 적용
|
||||||
|
const tabDataFilter = (tabConfig as any).dataFilter;
|
||||||
|
if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) {
|
||||||
|
resultData = resultData.filter((item: any) => {
|
||||||
|
return tabDataFilter.filters.every((cond: any) => {
|
||||||
|
const value = item[cond.columnName];
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals":
|
||||||
|
return value === cond.value;
|
||||||
|
case "notEquals":
|
||||||
|
return value !== cond.value;
|
||||||
|
case "contains":
|
||||||
|
return String(value || "").includes(String(cond.value));
|
||||||
|
case "is_null":
|
||||||
|
return value === null || value === undefined || value === "";
|
||||||
|
case "is_not_null":
|
||||||
|
return value !== null && value !== undefined && value !== "";
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
|
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
|
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
|
||||||
|
|
@ -1418,29 +1534,55 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 탭 변경 핸들러
|
// 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드)
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(newTabIndex: number) => {
|
(newTabIndex: number) => {
|
||||||
setActiveTabIndex(newTabIndex);
|
setActiveTabIndex(newTabIndex);
|
||||||
|
|
||||||
if (selectedLeftItem) {
|
if (newTabIndex === 0) {
|
||||||
if (newTabIndex === 0) {
|
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
||||||
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
loadRightData(selectedLeftItem);
|
||||||
loadRightData(selectedLeftItem);
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
if (!tabsData[newTabIndex]) {
|
||||||
if (!tabsData[newTabIndex]) {
|
loadTabData(newTabIndex, selectedLeftItem);
|
||||||
loadTabData(newTabIndex, selectedLeftItem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러
|
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
|
||||||
const handleLeftItemSelect = useCallback(
|
const handleLeftItemSelect = useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
|
// 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀)
|
||||||
|
const leftPk = componentConfig.rightPanel?.relation?.leftColumn ||
|
||||||
|
componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn;
|
||||||
|
const isSameItem = selectedLeftItem && leftPk &&
|
||||||
|
selectedLeftItem[leftPk] === item[leftPk];
|
||||||
|
|
||||||
|
if (isSameItem) {
|
||||||
|
// 선택 해제 → 전체 데이터 로드
|
||||||
|
setSelectedLeftItem(null);
|
||||||
|
setExpandedRightItems(new Set());
|
||||||
|
setTabsData({});
|
||||||
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(null);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, null);
|
||||||
|
}
|
||||||
|
// 추가 탭들도 전체 데이터 로드
|
||||||
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
|
if (tabs && tabs.length > 0) {
|
||||||
|
tabs.forEach((_: any, idx: number) => {
|
||||||
|
if (idx + 1 !== activeTabIndex) {
|
||||||
|
loadTabData(idx + 1, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||||
setTabsData({}); // 모든 탭 데이터 초기화
|
setTabsData({}); // 모든 탭 데이터 초기화
|
||||||
|
|
@ -1461,7 +1603,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
|
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우측 항목 확장/축소 토글
|
// 우측 항목 확장/축소 토글
|
||||||
|
|
@ -2037,10 +2179,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (editModalPanel === "left") {
|
if (editModalPanel === "left") {
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
// 우측 패널도 새로고침 (FK가 변경되었을 수 있음)
|
// 우측 패널도 새로고침 (FK가 변경되었을 수 있음)
|
||||||
if (selectedLeftItem) {
|
loadRightData(selectedLeftItem);
|
||||||
loadRightData(selectedLeftItem);
|
} else if (editModalPanel === "right") {
|
||||||
}
|
|
||||||
} else if (editModalPanel === "right" && selectedLeftItem) {
|
|
||||||
loadRightData(selectedLeftItem);
|
loadRightData(selectedLeftItem);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2069,32 +2209,39 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
loadRightData,
|
loadRightData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 삭제 버튼 핸들러
|
// 삭제 버튼 핸들러 (tableName: 추가 탭 등 특정 테이블 지정 시 사용)
|
||||||
const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => {
|
const handleDeleteClick = useCallback((panel: "left" | "right", item: any, tableName?: string) => {
|
||||||
setDeleteModalPanel(panel);
|
setDeleteModalPanel(panel);
|
||||||
setDeleteModalItem(item);
|
setDeleteModalItem(item);
|
||||||
|
setDeleteModalTableName(tableName || null);
|
||||||
setShowDeleteModal(true);
|
setShowDeleteModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 삭제 확인
|
// 삭제 확인
|
||||||
const handleDeleteConfirm = useCallback(async () => {
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
// 우측 패널 삭제 시 중계 테이블 확인
|
// 1. 테이블명 결정: deleteModalTableName이 있으면 우선 사용 (추가 탭 등)
|
||||||
let tableName =
|
let tableName = deleteModalTableName;
|
||||||
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
|
|
||||||
|
|
||||||
// 우측 패널 + 중계 테이블 모드인 경우
|
if (!tableName) {
|
||||||
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
|
tableName =
|
||||||
tableName = componentConfig.rightPanel.addConfig.targetTable;
|
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
|
||||||
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
|
||||||
|
// 우측 패널 + 중계 테이블 모드인 경우
|
||||||
|
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
|
||||||
|
tableName = componentConfig.rightPanel.addConfig.targetTable;
|
||||||
|
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
// 2. Primary Key 추출: id 필드를 우선 사용, 없으면 전체 객체 전달 (복합키)
|
||||||
let primaryKey: any = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID;
|
let primaryKey: any = deleteModalItem?.id || deleteModalItem?.ID;
|
||||||
|
|
||||||
// 복합키 처리: deleteModalItem 전체를 전달 (백엔드에서 복합키 자동 처리)
|
if (!primaryKey && deleteModalItem && typeof deleteModalItem === "object") {
|
||||||
if (deleteModalItem && typeof deleteModalItem === "object") {
|
// id가 없는 경우에만 전체 객체 전달 (복합키 테이블)
|
||||||
primaryKey = deleteModalItem;
|
primaryKey = deleteModalItem;
|
||||||
console.log("🔑 복합키 가능성: 전체 객체 전달", primaryKey);
|
console.log("🔑 복합키: 전체 객체 전달", Object.keys(primaryKey));
|
||||||
|
} else {
|
||||||
|
console.log("🔑 단일키 삭제: id =", primaryKey, "테이블 =", tableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tableName || !primaryKey) {
|
if (!tableName || !primaryKey) {
|
||||||
|
|
@ -2162,6 +2309,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 모달 닫기
|
// 모달 닫기
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
setDeleteModalItem(null);
|
setDeleteModalItem(null);
|
||||||
|
setDeleteModalTableName(null);
|
||||||
|
|
||||||
// 데이터 새로고침
|
// 데이터 새로고침
|
||||||
if (deleteModalPanel === "left") {
|
if (deleteModalPanel === "left") {
|
||||||
|
|
@ -2171,8 +2319,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setSelectedLeftItem(null);
|
setSelectedLeftItem(null);
|
||||||
setRightData(null);
|
setRightData(null);
|
||||||
}
|
}
|
||||||
} else if (deleteModalPanel === "right" && selectedLeftItem) {
|
} else if (deleteModalPanel === "right") {
|
||||||
loadRightData(selectedLeftItem);
|
// 추가 탭에서 삭제한 경우 해당 탭 데이터 리로드
|
||||||
|
if (deleteModalTableName && activeTabIndex > 0) {
|
||||||
|
loadTabData(activeTabIndex, selectedLeftItem);
|
||||||
|
} else {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -2196,7 +2349,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
}, [deleteModalPanel, deleteModalTableName, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, loadTabData, activeTabIndex]);
|
||||||
|
|
||||||
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
||||||
const handleItemAddClick = useCallback(
|
const handleItemAddClick = useCallback(
|
||||||
|
|
@ -2328,7 +2481,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (addModalPanel === "left" || addModalPanel === "left-item") {
|
if (addModalPanel === "left" || addModalPanel === "left-item") {
|
||||||
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
|
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
} else if (addModalPanel === "right" && selectedLeftItem) {
|
} else if (addModalPanel === "right") {
|
||||||
// 우측 패널 데이터 새로고침
|
// 우측 패널 데이터 새로고침
|
||||||
loadRightData(selectedLeftItem);
|
loadRightData(selectedLeftItem);
|
||||||
}
|
}
|
||||||
|
|
@ -2416,10 +2569,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
}, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]);
|
}, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]);
|
||||||
|
|
||||||
// 초기 데이터 로드
|
// 초기 데이터 로드 (좌측 + 우측 전체 데이터)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
|
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
|
||||||
|
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
|
if (relationshipType === "join") {
|
||||||
|
loadRightData(null);
|
||||||
|
// 추가 탭도 전체 데이터 로드
|
||||||
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
|
if (tabs && tabs.length > 0) {
|
||||||
|
tabs.forEach((_: any, idx: number) => {
|
||||||
|
loadTabData(idx + 1, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isDesignMode, componentConfig.autoLoad]);
|
}, [isDesignMode, componentConfig.autoLoad]);
|
||||||
|
|
@ -2432,19 +2597,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [leftFilters]);
|
}, [leftFilters]);
|
||||||
|
|
||||||
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
// 전역 테이블 새로고침 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRefreshTable = () => {
|
const handleRefreshTable = () => {
|
||||||
if (!isDesignMode) {
|
if (!isDesignMode) {
|
||||||
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
|
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
// 선택된 항목이 있으면 현재 활성 탭 데이터 새로고침
|
// 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드)
|
||||||
if (selectedLeftItem) {
|
if (activeTabIndex === 0) {
|
||||||
if (activeTabIndex === 0) {
|
loadRightData(selectedLeftItem);
|
||||||
loadRightData(selectedLeftItem);
|
} else {
|
||||||
} else {
|
loadTabData(activeTabIndex, selectedLeftItem);
|
||||||
loadTabData(activeTabIndex, selectedLeftItem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -3359,15 +3522,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedLeftItem) {
|
if (currentTabData.length === 0 && !isTabLoading) {
|
||||||
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 (
|
return (
|
||||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
|
||||||
<p>관련 데이터가 없습니다.</p>
|
<p>관련 데이터가 없습니다.</p>
|
||||||
|
|
@ -3420,7 +3575,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
)}
|
)}
|
||||||
{currentTabConfig?.showDelete && (
|
{currentTabConfig?.showDelete && (
|
||||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
onClick={() => handleDeleteClick("right", item)}
|
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -3464,7 +3619,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
)}
|
)}
|
||||||
{currentTabConfig?.showDelete && (
|
{currentTabConfig?.showDelete && (
|
||||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
onClick={() => handleDeleteClick("right", item)}
|
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -4136,11 +4291,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 선택 없음
|
// 데이터 없음 또는 초기 로딩 대기
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-muted-foreground text-center text-sm">
|
<div className="text-muted-foreground text-center text-sm">
|
||||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
{componentConfig.rightPanel?.relation?.type === "join" ? (
|
||||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
<>
|
||||||
|
<Loader2 className="text-muted-foreground mx-auto h-6 w-6 animate-spin" />
|
||||||
|
<p className="mt-2">데이터를 불러오는 중...</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||||
|
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
key={tab.tabId}
|
key={tab.tabId}
|
||||||
value={tab.tabId}
|
value={tab.tabId}
|
||||||
className="rounded-lg border bg-gray-50"
|
className="rounded-lg border bg-card"
|
||||||
>
|
>
|
||||||
<AccordionTrigger className="px-3 py-2 hover:no-underline">
|
<AccordionTrigger className="px-3 py-2 hover:no-underline">
|
||||||
<div className="flex flex-1 items-center gap-2">
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
|
@ -341,11 +341,11 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-3 pb-3">
|
<AccordionContent className="space-y-4 px-3 pb-3">
|
||||||
<div className="space-y-4">
|
|
||||||
{/* ===== 1. 기본 정보 ===== */}
|
{/* ===== 1. 기본 정보 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-600">기본 정보</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">기본 정보</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">탭 라벨</Label>
|
<Label className="text-xs">탭 라벨</Label>
|
||||||
|
|
@ -366,123 +366,120 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-xs">패널 헤더 높이</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={tab.panelHeaderHeight ?? 48}
|
|
||||||
onChange={(e) => updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })}
|
|
||||||
placeholder="48"
|
|
||||||
className="h-8 w-24 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 2. 테이블 선택 ===== */}
|
{/* ===== 2. 테이블 선택 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-600">테이블 설정</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">테이블 설정</h3>
|
||||||
<div className="space-y-1">
|
<Popover>
|
||||||
<Label className="text-xs">테이블 선택</Label>
|
<PopoverTrigger asChild>
|
||||||
<Popover>
|
<Button
|
||||||
<PopoverTrigger asChild>
|
variant="outline"
|
||||||
<Button
|
role="combobox"
|
||||||
variant="outline"
|
className="h-8 w-full justify-between text-xs"
|
||||||
role="combobox"
|
>
|
||||||
className="h-8 w-full justify-between text-xs"
|
{tab.tableName || "테이블을 선택하세요"}
|
||||||
>
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
{tab.tableName || "테이블을 선택하세요"}
|
</Button>
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
</PopoverTrigger>
|
||||||
</Button>
|
<PopoverContent className="w-full p-0">
|
||||||
</PopoverTrigger>
|
<Command>
|
||||||
<PopoverContent className="w-full p-0">
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
<Command>
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
{availableRightTables.map((table) => (
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<CommandItem
|
||||||
{availableRightTables.map((table) => (
|
key={table.tableName}
|
||||||
<CommandItem
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
key={table.tableName}
|
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
|
||||||
value={`${table.displayName || ""} ${table.tableName}`}
|
>
|
||||||
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
|
<Check
|
||||||
>
|
className={cn(
|
||||||
<Check
|
"mr-2 h-4 w-4",
|
||||||
className={cn(
|
tab.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||||
"mr-2 h-4 w-4",
|
)}
|
||||||
tab.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
/>
|
||||||
)}
|
{table.displayName || table.tableName}
|
||||||
/>
|
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
|
||||||
{table.displayName || table.tableName}
|
</CommandItem>
|
||||||
</CommandItem>
|
))}
|
||||||
))}
|
</CommandGroup>
|
||||||
</CommandGroup>
|
</Command>
|
||||||
</Command>
|
</PopoverContent>
|
||||||
</PopoverContent>
|
</Popover>
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 3. 표시 모드 ===== */}
|
{/* ===== 3. 표시 모드 + 요약 설정 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-600">표시 설정</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">표시 설정</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">표시 모드</Label>
|
<Label className="text-xs">표시 모드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={tab.displayMode || "list"}
|
value={tab.displayMode || "list"}
|
||||||
onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })}
|
onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 bg-white text-xs">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{(tab.displayMode || "list") === "list" ? "목록 (LIST)" : "테이블 (TABLE)"}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="list">목록 (카드)</SelectItem>
|
<SelectItem value="list">
|
||||||
<SelectItem value="table">테이블</SelectItem>
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||||
|
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||||
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 요약 설정 (목록 모드) */}
|
{/* 요약 설정 (목록 모드) */}
|
||||||
{tab.displayMode === "list" && (
|
{(tab.displayMode || "list") === "list" && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
<div className="space-y-1">
|
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||||
<Label className="text-xs">요약 컬럼 수</Label>
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">표시할 컬럼 개수</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number" min="1" max="10"
|
||||||
value={tab.summaryColumnCount ?? 3}
|
value={tab.summaryColumnCount ?? 3}
|
||||||
onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })}
|
onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })}
|
||||||
min={1}
|
className="bg-white"
|
||||||
max={10}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-gray-500">접기 전에 표시할 컬럼 개수 (기본: 3개)</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-5">
|
<div className="flex items-center justify-between space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">라벨 표시</Label>
|
||||||
|
<p className="text-xs text-gray-500">컬럼명 표시 여부</p>
|
||||||
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`tab-${tabIndex}-summary-label`}
|
|
||||||
checked={tab.summaryShowLabel ?? true}
|
checked={tab.summaryShowLabel ?? true}
|
||||||
onCheckedChange={(checked) => updateTab({ summaryShowLabel: !!checked })}
|
onCheckedChange={(checked) => updateTab({ summaryShowLabel: checked as boolean })}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`tab-${tabIndex}-summary-label`} className="text-xs">라벨 표시</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 4. 컬럼 매핑 (연결 키) ===== */}
|
{/* ===== 4. 컬럼 매핑 (연결 키) ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-600">컬럼 매핑 (연결 키)</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">컬럼 매핑 (연결 키)</h3>
|
||||||
<p className="text-[10px] text-gray-500">
|
<p className="text-muted-foreground text-[10px]">좌측 패널 선택 시 관련 데이터만 표시합니다</p>
|
||||||
좌측 패널 선택 시 관련 데이터만 표시합니다
|
<div className="grid grid-cols-2 gap-2">
|
||||||
</p>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">좌측 컬럼</Label>
|
<Label className="text-[10px]">좌측 컬럼</Label>
|
||||||
<Select
|
<Select
|
||||||
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"}
|
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || "__none__"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === "__none__") {
|
if (value === "__none__") {
|
||||||
// 선택 안 함 - 조인 키 제거
|
updateTab({ relation: undefined });
|
||||||
updateTab({
|
|
||||||
relation: undefined,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
updateTab({
|
updateTab({
|
||||||
relation: {
|
relation: {
|
||||||
|
|
@ -494,17 +491,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">
|
<SelectItem value="__none__"><span className="text-muted-foreground">선택 안 함 (전체 데이터)</span></SelectItem>
|
||||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터)</span>
|
|
||||||
</SelectItem>
|
|
||||||
{leftTableColumns.map((col) => (
|
{leftTableColumns.map((col) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
|
||||||
{col.columnLabel || col.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -515,10 +508,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"}
|
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || "__none__"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === "__none__") {
|
if (value === "__none__") {
|
||||||
// 선택 안 함 - 조인 키 제거
|
updateTab({ relation: undefined });
|
||||||
updateTab({
|
|
||||||
relation: undefined,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
updateTab({
|
updateTab({
|
||||||
relation: {
|
relation: {
|
||||||
|
|
@ -530,17 +520,13 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">
|
<SelectItem value="__none__"><span className="text-muted-foreground">선택 안 함 (전체 데이터)</span></SelectItem>
|
||||||
<span className="text-muted-foreground">선택 안 함 (전체 데이터)</span>
|
|
||||||
</SelectItem>
|
|
||||||
{tabColumns.map((col) => (
|
{tabColumns.map((col) => (
|
||||||
<SelectItem key={col.columnName} value={col.columnName}>
|
<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
|
||||||
{col.columnLabel || col.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -549,215 +535,202 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 5. 기능 버튼 ===== */}
|
{/* ===== 5. 기능 버튼 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-600">기능 버튼</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">기능 버튼</h3>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Checkbox
|
<Checkbox id={`tab-${tabIndex}-search`} checked={tab.showSearch} onCheckedChange={(checked) => updateTab({ showSearch: !!checked })} />
|
||||||
id={`tab-${tabIndex}-search`}
|
|
||||||
checked={tab.showSearch}
|
|
||||||
onCheckedChange={(checked) => updateTab({ showSearch: !!checked })}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`tab-${tabIndex}-search`} className="text-xs">검색</label>
|
<label htmlFor={`tab-${tabIndex}-search`} className="text-xs">검색</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Checkbox
|
<Checkbox id={`tab-${tabIndex}-add`} checked={tab.showAdd} onCheckedChange={(checked) => updateTab({ showAdd: !!checked })} />
|
||||||
id={`tab-${tabIndex}-add`}
|
|
||||||
checked={tab.showAdd}
|
|
||||||
onCheckedChange={(checked) => updateTab({ showAdd: !!checked })}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`tab-${tabIndex}-add`} className="text-xs">추가</label>
|
<label htmlFor={`tab-${tabIndex}-add`} className="text-xs">추가</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Checkbox
|
<Checkbox id={`tab-${tabIndex}-edit`} checked={tab.showEdit} onCheckedChange={(checked) => updateTab({ showEdit: !!checked })} />
|
||||||
id={`tab-${tabIndex}-edit`}
|
|
||||||
checked={tab.showEdit}
|
|
||||||
onCheckedChange={(checked) => updateTab({ showEdit: !!checked })}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`tab-${tabIndex}-edit`} className="text-xs">수정</label>
|
<label htmlFor={`tab-${tabIndex}-edit`} className="text-xs">수정</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Checkbox
|
<Checkbox id={`tab-${tabIndex}-delete`} checked={tab.showDelete} onCheckedChange={(checked) => updateTab({ showDelete: !!checked })} />
|
||||||
id={`tab-${tabIndex}-delete`}
|
|
||||||
checked={tab.showDelete}
|
|
||||||
onCheckedChange={(checked) => updateTab({ showDelete: !!checked })}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`tab-${tabIndex}-delete`} className="text-xs">삭제</label>
|
<label htmlFor={`tab-${tabIndex}-delete`} className="text-xs">삭제</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 6. 표시 컬럼 설정 ===== */}
|
{/* ===== 6. 표시할 컬럼 - DnD + Entity 조인 통합 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
{(() => {
|
||||||
<div className="flex items-center justify-between">
|
const selectedColumns = tab.columns || [];
|
||||||
<Label className="text-xs font-semibold text-green-700">표시할 컬럼 선택</Label>
|
const filteredTabCols = tabColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||||
<Button
|
const unselectedCols = filteredTabCols.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||||
size="sm"
|
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||||
variant="outline"
|
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||||
onClick={() => {
|
|
||||||
const currentColumns = tab.columns || [];
|
|
||||||
const newColumns = [...currentColumns, { name: "", label: "", width: 100 }];
|
|
||||||
updateTab({ columns: newColumns });
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
disabled={!tab.tableName || loadingTabColumns}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
컬럼 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-600">
|
|
||||||
표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 테이블 미선택 상태 */}
|
const handleTabDragEnd = (event: DragEndEvent) => {
|
||||||
{!tab.tableName && (
|
const { active, over } = event;
|
||||||
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
if (over && active.id !== over.id) {
|
||||||
<p className="text-xs text-gray-500">먼저 테이블을 선택하세요</p>
|
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||||
</div>
|
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||||
)}
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
updateTab({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
{/* 테이블 선택됨 - 컬럼 목록 */}
|
return (
|
||||||
{tab.tableName && (
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<div className="space-y-2">
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">표시할 컬럼 ({selectedColumns.length}개 선택)</h3>
|
||||||
{/* 로딩 상태 */}
|
<div className="max-h-[400px] overflow-y-auto rounded-md border bg-white p-2">
|
||||||
{loadingTabColumns && (
|
{!tab.tableName ? (
|
||||||
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
<p className="text-muted-foreground py-2 text-center text-xs">테이블을 선택해주세요</p>
|
||||||
<p className="text-xs text-gray-500">컬럼을 불러오는 중...</p>
|
) : loadingTabColumns ? (
|
||||||
</div>
|
<p className="text-muted-foreground py-2 text-center text-xs">컬럼을 불러오는 중...</p>
|
||||||
)}
|
) : (
|
||||||
|
<>
|
||||||
|
{selectedColumns.length > 0 && (
|
||||||
|
<DndContext collisionDetection={closestCenter} onDragEnd={handleTabDragEnd}>
|
||||||
|
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedColumns.map((col, index) => {
|
||||||
|
const colInfo = tabColumns.find((c) => c.columnName === col.name);
|
||||||
|
const isNumeric = colInfo && (
|
||||||
|
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||||
|
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||||
|
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SortableColumnRow
|
||||||
|
key={col.name}
|
||||||
|
id={col.name}
|
||||||
|
col={col}
|
||||||
|
index={index}
|
||||||
|
isNumeric={!!isNumeric}
|
||||||
|
isEntityJoin={!!(col as any).isEntityJoin}
|
||||||
|
onLabelChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], label: value };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onWidthChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], width: value };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onFormatChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 설정된 컬럼이 없을 때 */}
|
{selectedColumns.length > 0 && unselectedCols.length > 0 && (
|
||||||
{!loadingTabColumns && (tab.columns || []).length === 0 && (
|
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||||
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||||
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-gray-400">컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다</p>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 설정된 컬럼 목록 */}
|
<div className="space-y-0.5">
|
||||||
{!loadingTabColumns && (tab.columns || []).length > 0 && (
|
{unselectedCols.map((column) => (
|
||||||
(tab.columns || []).map((col, colIndex) => (
|
<div
|
||||||
<div key={colIndex} className="space-y-2 rounded-md border bg-white p-3">
|
key={column.columnName}
|
||||||
{/* 상단: 순서 변경 + 삭제 버튼 */}
|
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (colIndex === 0) return;
|
updateTab({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||||
const newColumns = [...(tab.columns || [])];
|
|
||||||
[newColumns[colIndex - 1], newColumns[colIndex]] = [newColumns[colIndex], newColumns[colIndex - 1]];
|
|
||||||
updateTab({ columns: newColumns });
|
|
||||||
}}
|
}}
|
||||||
disabled={colIndex === 0}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-3 w-3" />
|
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||||
</Button>
|
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
))}
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
const columns = tab.columns || [];
|
|
||||||
if (colIndex === columns.length - 1) return;
|
|
||||||
const newColumns = [...columns];
|
|
||||||
[newColumns[colIndex], newColumns[colIndex + 1]] = [newColumns[colIndex + 1], newColumns[colIndex]];
|
|
||||||
updateTab({ columns: newColumns });
|
|
||||||
}}
|
|
||||||
disabled={colIndex === (tab.columns || []).length - 1}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<ArrowDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<span className="text-[10px] text-gray-400">#{colIndex + 1}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
const newColumns = (tab.columns || []).filter((_, i) => i !== colIndex);
|
|
||||||
updateTab({ columns: newColumns });
|
|
||||||
}}
|
|
||||||
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 선택 */}
|
{/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */}
|
||||||
<div className="space-y-1">
|
{(() => {
|
||||||
<Label className="text-[10px] text-gray-500">컬럼</Label>
|
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
|
||||||
<Select
|
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||||
value={col.name}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const selectedCol = tabColumns.find((c) => c.columnName === value);
|
|
||||||
const newColumns = [...(tab.columns || [])];
|
|
||||||
newColumns[colIndex] = {
|
|
||||||
...col,
|
|
||||||
name: value,
|
|
||||||
label: selectedCol?.columnLabel || value,
|
|
||||||
};
|
|
||||||
updateTab({ columns: newColumns });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="컬럼을 선택하세요" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{tabColumns.map((column) => (
|
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
|
||||||
{column.columnLabel || column.columnName}
|
|
||||||
<span className="ml-1 text-[10px] text-gray-400">({column.columnName})</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 라벨 + 너비 */}
|
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||||
<div className="grid grid-cols-2 gap-2">
|
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||||
<div className="space-y-1">
|
const matchingJoinColumn = joinData.availableColumns.find(
|
||||||
<Label className="text-[10px] text-gray-500">라벨</Label>
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
<Input
|
);
|
||||||
value={col.label}
|
if (!matchingJoinColumn) return false;
|
||||||
onChange={(e) => {
|
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||||
const newColumns = [...(tab.columns || [])];
|
});
|
||||||
newColumns[colIndex] = { ...col, label: e.target.value };
|
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||||
updateTab({ columns: newColumns });
|
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||||
}}
|
|
||||||
placeholder="표시 라벨"
|
return (
|
||||||
className="h-8 text-xs"
|
<details key={`tab-join-${tableIndex}`} className="group">
|
||||||
/>
|
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||||
</div>
|
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||||
<div className="space-y-1">
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
<Label className="text-[10px] text-gray-500">너비 (px)</Label>
|
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||||
<Input
|
{addedCount > 0 && (
|
||||||
type="number"
|
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||||
value={col.width || 100}
|
)}
|
||||||
onChange={(e) => {
|
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||||
const newColumns = [...(tab.columns || [])];
|
</summary>
|
||||||
newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 };
|
<div className="space-y-0.5 pt-1">
|
||||||
updateTab({ columns: newColumns });
|
{joinColumnsToShow.map((column, colIndex) => {
|
||||||
}}
|
const matchingJoinColumn = joinData.availableColumns.find(
|
||||||
placeholder="100"
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
className="h-8 text-xs"
|
);
|
||||||
/>
|
if (!matchingJoinColumn) return null;
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
</div>
|
<div
|
||||||
))
|
key={colIndex}
|
||||||
)}
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||||
|
onClick={() => {
|
||||||
|
updateTab({
|
||||||
|
columns: [...selectedColumns, {
|
||||||
|
name: matchingJoinColumn.joinAlias,
|
||||||
|
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||||
|
width: 100,
|
||||||
|
isEntityJoin: true,
|
||||||
|
joinInfo: {
|
||||||
|
sourceTable: tab.tableName!,
|
||||||
|
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||||
|
referenceTable: matchingJoinColumn.tableName,
|
||||||
|
joinAlias: matchingJoinColumn.joinAlias,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{joinColumnsToShow.length === 0 && (
|
||||||
|
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</div>
|
})()}
|
||||||
|
|
||||||
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
|
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
|
||||||
{tab.showAdd && (
|
{tab.showAdd && (
|
||||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-semibold text-purple-700">추가 모달 컬럼 설정</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">추가 모달 컬럼 설정</h3>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -845,76 +818,11 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===== 7.5 Entity 조인 컬럼 ===== */}
|
{/* Entity 조인 컬럼은 표시 컬럼 목록에 통합됨 */}
|
||||||
{(() => {
|
|
||||||
const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null;
|
|
||||||
if (!joinData || joinData.joinTables.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 rounded-lg border bg-white p-3">
|
|
||||||
<Label className="text-xs font-semibold text-blue-600">Entity 조인 컬럼</Label>
|
|
||||||
<p className="text-muted-foreground text-[10px]">연관 테이블의 컬럼을 추가합니다</p>
|
|
||||||
{joinData.joinTables.map((joinTable, tableIndex) => (
|
|
||||||
<div key={tableIndex} className="space-y-1">
|
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
|
||||||
<Link2 className="h-3 w-3" />
|
|
||||||
<span>{joinTable.tableName}</span>
|
|
||||||
<Badge variant="outline" className="text-[10px]">{joinTable.currentDisplayColumn}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-32 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
|
||||||
{joinTable.availableColumns.map((column, colIndex) => {
|
|
||||||
const matchingJoinColumn = joinData.availableColumns.find(
|
|
||||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
|
||||||
);
|
|
||||||
if (!matchingJoinColumn) return null;
|
|
||||||
const tabColumns2 = tab.columns || [];
|
|
||||||
const isAdded = tabColumns2.some((c) => c.name === matchingJoinColumn.joinAlias);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={colIndex}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-50",
|
|
||||||
isAdded && "bg-blue-50",
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
if (isAdded) {
|
|
||||||
updateTab({ columns: tabColumns2.filter((c) => c.name !== matchingJoinColumn.joinAlias) });
|
|
||||||
} else {
|
|
||||||
updateTab({
|
|
||||||
columns: [...tabColumns2, {
|
|
||||||
name: matchingJoinColumn.joinAlias,
|
|
||||||
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
|
||||||
width: 100,
|
|
||||||
isEntityJoin: true,
|
|
||||||
joinInfo: {
|
|
||||||
sourceTable: tab.tableName!,
|
|
||||||
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
|
||||||
referenceTable: matchingJoinColumn.tableName,
|
|
||||||
joinAlias: matchingJoinColumn.joinAlias,
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox checked={isAdded} className="pointer-events-none h-3.5 w-3.5" />
|
|
||||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
|
||||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
|
||||||
<span className="ml-auto text-[10px] text-blue-400">{column.dataType}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* ===== 8. 데이터 필터링 ===== */}
|
{/* ===== 8. 데이터 필터링 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-600">데이터 필터링</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">데이터 필터링</h3>
|
||||||
<DataFilterConfigPanel
|
<DataFilterConfigPanel
|
||||||
tableName={tab.tableName}
|
tableName={tab.tableName}
|
||||||
columns={tabColumns}
|
columns={tabColumns}
|
||||||
|
|
@ -925,9 +833,9 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ===== 9. 중복 데이터 제거 ===== */}
|
{/* ===== 9. 중복 데이터 제거 ===== */}
|
||||||
<div className="space-y-3 rounded-lg border bg-white p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-semibold text-blue-600">중복 데이터 제거</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">중복 데이터 제거</h3>
|
||||||
<Switch
|
<Switch
|
||||||
checked={tab.deduplication?.enabled ?? false}
|
checked={tab.deduplication?.enabled ?? false}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -1019,8 +927,8 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
|
|
||||||
{/* ===== 10. 수정 버튼 설정 ===== */}
|
{/* ===== 10. 수정 버튼 설정 ===== */}
|
||||||
{tab.showEdit && (
|
{tab.showEdit && (
|
||||||
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-blue-700">수정 버튼 설정</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">수정 버튼 설정</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">수정 모드</Label>
|
<Label className="text-[10px]">수정 모드</Label>
|
||||||
|
|
@ -1125,8 +1033,8 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
|
|
||||||
{/* ===== 11. 삭제 버튼 설정 ===== */}
|
{/* ===== 11. 삭제 버튼 설정 ===== */}
|
||||||
{tab.showDelete && (
|
{tab.showDelete && (
|
||||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
<Label className="text-xs font-semibold text-red-700">삭제 버튼 설정</Label>
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">삭제 버튼 설정</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -1196,7 +1104,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
탭 삭제
|
탭 삭제
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export interface ConditionNodeData {
|
||||||
field: string;
|
field: string;
|
||||||
operator: ConditionOperator;
|
operator: ConditionOperator;
|
||||||
value: any;
|
value: any;
|
||||||
valueType?: "static" | "field"; // 비교 값 타입
|
valueType?: "static" | "field" | "target"; // 비교 값 타입 (target: DB 기존값 비교)
|
||||||
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
|
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
|
||||||
lookupTable?: string; // 조회할 테이블명
|
lookupTable?: string; // 조회할 테이블명
|
||||||
lookupTableLabel?: string; // 조회할 테이블 라벨
|
lookupTableLabel?: string; // 조회할 테이블 라벨
|
||||||
|
|
@ -127,6 +127,16 @@ export interface ConditionNodeData {
|
||||||
}>;
|
}>;
|
||||||
logic: "AND" | "OR";
|
logic: "AND" | "OR";
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
// 타겟 테이블 조회 (DB 기존값과 비교할 때 사용)
|
||||||
|
targetLookup?: {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel?: string;
|
||||||
|
lookupKeys: Array<{
|
||||||
|
sourceField: string; // 소스(폼) 데이터의 키 필드
|
||||||
|
targetField: string; // 타겟(DB) 테이블의 키 필드
|
||||||
|
sourceFieldLabel?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 필드 매핑 노드
|
// 필드 매핑 노드
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue