Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-02-23 10:53:55 +09:00
commit 9cc93b88ff
14 changed files with 573 additions and 346 deletions

View File

@ -18,45 +18,6 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
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 {
tableName: string;
limit?: number;
@ -661,14 +622,14 @@ class DataService {
return {
success: true,
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
data: normalizedGroupRows, // 🔧 배열로 반환!
};
}
}
return {
success: true,
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
};
}
}
@ -687,7 +648,7 @@ class DataService {
return {
success: true,
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
data: result[0],
};
} catch (error) {
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);

View File

@ -2,7 +2,6 @@ import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
import tableCategoryValueService from "./tableCategoryValueService";
import { PasswordUtils } from "../utils/passwordUtils";
export interface FormDataResult {
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) {
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");

View File

@ -554,6 +554,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면 관리에서 설정한 해상도 사용 (우선순위)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
console.log("🔍 [ScreenModal] 해상도 디버그:", {
screenId,
v2ScreenResolution: v2LayoutData?.screenResolution,
layoutScreenResolution: (layoutData as any).screenResolution,
screenInfoResolution: (screenInfo as any).screenResolution,
finalScreenResolution: screenResolution,
hasWidth: screenResolution?.width,
hasHeight: screenResolution?.height,
});
let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) {
// 화면 관리에서 설정한 해상도 사용
@ -563,9 +573,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
offsetX: 0,
offsetY: 0,
};
console.log("✅ [ScreenModal] 화면관리 해상도 적용:", dimensions);
} else {
// 해상도 정보가 없으면 자동 계산
dimensions = calculateScreenDimensions(components);
console.log("⚠️ [ScreenModal] 해상도 없음 - 자동 계산:", dimensions);
}
setScreenDimensions(dimensions);
@ -869,16 +881,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
const getModalStyle = () => {
if (!screenDimensions) {
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
return {
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
};
}
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
screenDimensions,
finalWidth: `${finalWidth}px`,
viewportWidth: window.innerWidth,
});
return {
className: "overflow-hidden",
style: {
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
width: `${finalWidth}px`,
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
maxHeight: "calc(100dvh - 8px)",
maxWidth: "98vw",

View File

@ -565,12 +565,32 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return newActiveIds;
}, [formData, groupData, conditionalLayers, screenData?.components]);
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기
// 활성화된 조건부 레이어의 컴포넌트 가져오기 (Zone 오프셋 적용)
const activeConditionalComponents = useMemo(() => {
return conditionalLayers
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []);
}, [conditionalLayers, activeConditionalLayerIds]);
.flatMap((layer) => {
const layerWithComps = layer as LayerDefinition & { components: ComponentData[] };
const comps = layerWithComps.components || [];
// Zone 오프셋 적용: 조건부 레이어 컴포넌트는 Zone 내부 상대 좌표로 저장되므로
// Zone의 절대 좌표를 더해줘야 EditModal에서 올바른 위치에 렌더링됨
const associatedZone = zones.find((z) => z.zone_id === (layer as any).zoneId);
if (!associatedZone) return comps;
const zoneOffsetX = associatedZone.x || 0;
const zoneOffsetY = associatedZone.y || 0;
return comps.map((comp) => ({
...comp,
position: {
...comp.position,
x: parseFloat(comp.position?.x?.toString() || "0") + zoneOffsetX,
y: parseFloat(comp.position?.y?.toString() || "0") + zoneOffsetY,
},
}));
});
}, [conditionalLayers, activeConditionalLayerIds, zones]);
const handleClose = () => {
setModalState({
@ -881,14 +901,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
}
// V2Repeater 저장 이벤트 발생 (디테일 테이블 데이터 저장)
const hasRepeaterInstances = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterInstances) {
const masterRecordId = groupData[0]?.id || formData.id;
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] 그룹 저장 후 repeaterSave 이벤트 발생:", { masterRecordId });
}
// 결과 메시지
const messages: string[] = [];
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
if (messages.length > 0) {
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
if (messages.length > 0 || hasRepeaterInstances) {
toast.success(messages.length > 0 ? `품목이 저장되었습니다 (${messages.join(", ")})` : "저장되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {

View File

@ -2231,11 +2231,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
: component;
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
// 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
const allLayerComponents = useMemo(() => {
return layers.flatMap((layer) => layer.components);
}, [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) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
@ -2272,7 +2281,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2344,7 +2353,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2387,7 +2396,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2423,7 +2432,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2433,7 +2442,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
})}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
return (
<SplitPanelProvider>
@ -2485,7 +2494,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogContent
className="max-w-none w-auto max-h-[90vh] overflow-hidden p-0"
style={popupScreenResolution ? {
width: `${Math.min(popupScreenResolution.width + 48, window.innerWidth * 0.98)}px`,
maxWidth: "98vw",
} : { maxWidth: "56rem" }}
>
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>

View File

@ -5556,8 +5556,12 @@ export default function ScreenDesigner({
return false;
}
// 6. 삭제 (단일/다중 선택 지원)
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
// 6. 삭제 (단일/다중 선택 지원) - Delete 또는 Backspace(Mac)
const isInputFocused = document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement ||
document.activeElement instanceof HTMLSelectElement ||
(document.activeElement as HTMLElement)?.isContentEditable;
if ((e.key === "Delete" || (e.key === "Backspace" && !isInputFocused)) && (selectedComponent || groupState.selectedComponents.length > 0)) {
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
e.preventDefault();
e.stopPropagation();
@ -7419,7 +7423,7 @@ export default function ScreenDesigner({
</p>
<p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete()
Ctrl+Z(), Delete/Backspace()
</p>
<p className="text-warning flex items-center justify-center gap-2">
<span></span>

View File

@ -43,6 +43,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
onDataChange,
onRowClick,
className,
formData: parentFormData,
}) => {
// 설정 병합
const config: V2RepeaterConfig = useMemo(
@ -153,21 +154,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) {
// 커스텀 테이블: 리피터 데이터만 저장
mergedData = { ...cleanRow };
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
if (config.foreignKeyColumn) {
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
// 없으면 마스터 레코드 ID 사용 (기존 동작)
const sourceColumn = config.foreignKeySourceColumn;
let fkValue: any;
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
// mainFormData에서 참조 컬럼 값 가져오기
fkValue = mainFormData[sourceColumn];
} else {
// 기본: 마스터 레코드 ID 사용
fkValue = masterRecordId;
}
@ -176,7 +171,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}
}
} else {
// 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = {
...mainFormDataWithoutId,
@ -192,7 +186,19 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}
}
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
const rowId = row.id;
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: rowId },
updatedData: updateFields,
});
} else {
// 새 행 → INSERT
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
}
} catch (error) {
console.error("❌ V2Repeater 저장 실패:", error);
@ -228,6 +234,108 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
parentId,
]);
// 수정 모드: useCustomTable + FK 기반으로 기존 디테일 데이터 자동 로드
const dataLoadedRef = useRef(false);
useEffect(() => {
if (dataLoadedRef.current) return;
if (!config.useCustomTable || !config.mainTableName || !config.foreignKeyColumn) return;
if (!parentFormData) return;
const fkSourceColumn = config.foreignKeySourceColumn || config.foreignKeyColumn;
const fkValue = parentFormData[fkSourceColumn];
if (!fkValue) return;
// 이미 데이터가 있으면 로드하지 않음
if (data.length > 0) return;
const loadExistingData = async () => {
try {
console.log("📥 [V2Repeater] 수정 모드 데이터 로드:", {
tableName: config.mainTableName,
fkColumn: config.foreignKeyColumn,
fkValue,
});
const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`,
{
page: 1,
size: 1000,
search: { [config.foreignKeyColumn]: fkValue },
autoFilter: true,
}
);
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
try {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
const uniqueValues = [...new Set(fkValues)];
if (uniqueValues.length > 0) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
const sourcePromises = uniqueValues.map((val) =>
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
// 각 행에 소스 테이블의 표시 데이터 병합
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
}
}
setData(rows);
dataLoadedRef.current = true;
if (onDataChange) onDataChange(rows);
}
} catch (error) {
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
}
};
loadExistingData();
}, [
config.useCustomTable,
config.mainTableName,
config.foreignKeyColumn,
config.foreignKeySourceColumn,
parentFormData,
data.length,
onDataChange,
]);
// 현재 테이블 컬럼 정보 로드
useEffect(() => {
const loadCurrentTableColumnInfo = async () => {
@ -451,58 +559,71 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
loadCategoryLabels();
}, [data, sourceCategoryColumns]);
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
const applyCalculationRules = useCallback(
(row: any): any => {
const rules = config.calculationRules;
if (!rules || rules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of rules) {
if (!rule.targetColumn || !rule.formula) continue;
try {
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
for (const field of fieldMatches) {
if (field === rule.targetColumn) continue;
// 직접 필드 → _display_* 필드 순으로 값 탐색
const raw = updatedRow[field] ?? updatedRow[`_display_${field}`];
const value = parseFloat(raw) || 0;
formula = formula.replace(new RegExp(`\\b${field}\\b`, "g"), value.toString());
}
updatedRow[rule.targetColumn] = new Function(`return ${formula}`)();
} catch {
updatedRow[rule.targetColumn] = 0;
}
}
return updatedRow;
},
[config.calculationRules],
);
// _targetTable 메타데이터 포함하여 onDataChange 호출
const notifyDataChange = useCallback(
(newData: any[]) => {
if (!onDataChange) return;
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
onDataChange(newData.map((row) => ({ ...row, _targetTable: targetTable })));
} else {
onDataChange(newData);
}
},
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
);
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
setData(newData);
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
if (onDataChange) {
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
// 각 행에 _targetTable 추가
const dataWithTarget = newData.map((row) => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
const calculated = newData.map(applyCalculationRules);
setData(calculated);
notifyDataChange(calculated);
setAutoWidthTrigger((prev) => prev + 1);
},
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
[applyCalculationRules, notifyDataChange],
);
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const calculated = applyCalculationRules(newRow);
const newData = [...data];
newData[index] = newRow;
newData[index] = calculated;
setData(newData);
// 🆕 _targetTable 메타데이터 포함
if (onDataChange) {
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
const dataWithTarget = newData.map((row) => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
notifyDataChange(newData);
},
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
[data, applyCalculationRules, notifyDataChange],
);
// 행 삭제 핸들러

View File

@ -189,13 +189,11 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
// value는 CommandItem의 value (라벨)
// search는 검색어
filter={(itemValue, search) => {
if (!search) return 1;
const normalizedValue = value.toLowerCase();
const normalizedSearch = search.toLowerCase();
if (normalizedValue.includes(normalizedSearch)) return 1;
const option = options.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
@ -208,7 +206,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
return (
<CommandItem
key={option.value}
value={displayLabel}
value={option.value}
onSelect={() => handleSelect(option.value)}
>
<Check

View File

@ -136,7 +136,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>([]);
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>(
config.calculationRules || []
);
const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
@ -553,26 +555,56 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
updateConfig({ columns: newColumns });
};
// 계산 규칙을 config에 반영하는 헬퍼
const syncCalculationRules = (rules: CalculationRule[]) => {
setCalculationRules(rules);
updateConfig({ calculationRules: rules });
};
// 계산 규칙 추가
const addCalculationRule = () => {
setCalculationRules(prev => [
...prev,
const newRules = [
...calculationRules,
{ id: `calc_${Date.now()}`, targetColumn: "", formula: "" }
]);
];
syncCalculationRules(newRules);
};
// 계산 규칙 삭제
const removeCalculationRule = (id: string) => {
setCalculationRules(prev => prev.filter(r => r.id !== id));
syncCalculationRules(calculationRules.filter(r => r.id !== id));
};
// 계산 규칙 업데이트
const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => {
setCalculationRules(prev =>
prev.map(r => r.id === id ? { ...r, [field]: value } : r)
syncCalculationRules(
calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r)
);
};
// 수식 입력 필드에 컬럼명 삽입
const insertColumnToFormula = (ruleId: string, columnKey: string) => {
const rule = calculationRules.find(r => r.id === ruleId);
if (!rule) return;
const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey;
updateCalculationRule(ruleId, "formula", newFormula);
};
// 수식의 영어 컬럼명을 한글 제목으로 변환
const formulaToKorean = (formula: string): string => {
if (!formula) return "";
let result = formula;
const allCols = config.columns || [];
// 긴 컬럼명부터 치환 (부분 매칭 방지)
const sorted = [...allCols].sort((a, b) => b.key.length - a.key.length);
for (const col of sorted) {
if (col.title && col.key) {
result = result.replace(new RegExp(`\\b${col.key}\\b`, "g"), col.title);
}
}
return result;
};
// 엔티티 컬럼 선택 시 소스 테이블 자동 설정
const handleEntityColumnSelect = (columnName: string) => {
const selectedEntity = entityColumns.find(c => c.columnName === columnName);
@ -1374,7 +1406,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
{(isModalMode || isInlineMode) && config.columns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs">
@ -1382,52 +1414,100 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
: 금액 = *
</p>
<div className="max-h-32 space-y-2 overflow-y-auto">
<div className="space-y-2">
{calculationRules.map((rule) => (
<div key={rule.id} className="flex items-center gap-2 rounded border p-2">
<Select
value={rule.targetColumn}
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue placeholder="결과" />
</SelectTrigger>
<SelectContent>
{config.columns.map((col) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs">=</span>
<Input
value={rule.formula}
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
placeholder="quantity * unit_price"
className="h-7 flex-1 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeCalculationRule(rule.id)}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<div key={rule.id} className="space-y-1 rounded border p-1.5">
<div className="flex items-center gap-1">
<Select
value={rule.targetColumn}
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
>
<SelectTrigger className="h-6 w-20 text-[10px]">
<SelectValue placeholder="결과" />
</SelectTrigger>
<SelectContent>
{config.columns.filter(col => !col.isSourceDisplay).map((col) => (
<SelectItem key={col.key} value={col.key} className="text-xs">
{col.title || col.key}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px]">=</span>
<Input
value={rule.formula}
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
placeholder="컬럼 클릭 또는 직접 입력"
className="h-6 flex-1 font-mono text-[10px]"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeCalculationRule(rule.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 한글 수식 미리보기 */}
{rule.formula && (
<p className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)}
</p>
)}
{/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */}
<div className="flex flex-wrap gap-0.5">
{config.columns
.filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay)
.map((col) => (
<Button
key={col.key}
type="button"
variant="secondary"
size="sm"
onClick={() => insertColumnToFormula(rule.id, col.key)}
className="h-4 px-1 text-[9px]"
>
{col.title || col.key}
</Button>
))}
{config.columns
.filter(col => col.isSourceDisplay)
.map((col) => (
<Button
key={col.key}
type="button"
variant="outline"
size="sm"
onClick={() => insertColumnToFormula(rule.id, col.key)}
className="h-4 border-dashed px-1 text-[9px] text-blue-600"
title="품목 정보 컬럼"
>
{col.title || col.key}
</Button>
))}
{["+", "-", "*", "/", "(", ")"].map((op) => (
<Button
key={op}
type="button"
variant="outline"
size="sm"
onClick={() => insertColumnToFormula(rule.id, op)}
className="h-4 w-4 p-0 font-mono text-[9px]"
>
{op}
</Button>
))}
</div>
</div>
))}
{calculationRules.length === 0 && (
<p className="text-muted-foreground py-2 text-center text-xs">
<p className="text-muted-foreground py-1 text-center text-[10px]">
</p>
)}

View File

@ -67,6 +67,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[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
const dataSourceId = useMemo(
() => 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 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 {
const { dataApi } = await import("@/lib/api/data");
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
@ -238,10 +256,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
for (const table of allTables) {
const response = await dataApi.getTableData(table, {
filters: {
customer_id: firstRecord.customer_id,
item_id: firstRecord.item_id,
},
filters: editFilters,
sortBy: "created_date",
sortOrder: "desc",
});
@ -350,8 +365,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
const newItem: ItemData = {
// 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음)
id: String(firstRecord.item_id || firstRecord.id || "edit"),
// 수정 모드: sourceKeyField를 우선 사용 (id는 가격레코드의 PK일 수 있음)
id: String(firstRecord[sourceKeyField] || firstRecord.id || "edit"),
originalData: firstRecord,
fieldGroups: mainFieldGroups,
};
@ -635,39 +650,39 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const mainGroups = groupsByTable.get(mainTable) || [];
for (const item of items) {
// item_id 추출: originalData.item_id를 최우선 사용
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
let itemId: string | null = null;
let sourceKeyValue: string | null = null;
// 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값)
if (item.originalData && item.originalData.item_id) {
itemId = item.originalData.item_id;
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
if (item.originalData && item.originalData[sourceKeyField]) {
sourceKeyValue = item.originalData[sourceKeyField];
}
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
if (!itemId) {
if (!sourceKeyValue) {
mainGroups.forEach((group) => {
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
groupFields.forEach((field) => {
if (field.name === "item_id" && field.autoFillFrom && item.originalData) {
itemId = item.originalData[field.autoFillFrom] || null;
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
}
});
});
}
// 3순위: fallback (최후의 수단)
if (!itemId && item.originalData) {
itemId = item.originalData.id || null;
if (!sourceKeyValue && item.originalData) {
sourceKeyValue = item.originalData.id || null;
}
if (!itemId) {
console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item);
if (!sourceKeyValue) {
console.error(`❌ [2단계 저장] ${sourceKeyField}를 찾을 수 없음:`, item);
continue;
}
// upsert 공통 parentKeys: customer_id + item_id (정확한 매칭)
const itemParentKeys = { ...parentKeys, item_id: itemId };
// upsert 공통 parentKeys: parentMapping 키 + sourceKeyField (정확한 매칭)
const itemParentKeys = { ...parentKeys, [sourceKeyField]: sourceKeyValue };
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
@ -688,11 +703,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (entry._dbRecordId) {
record.id = entry._dbRecordId;
}
// item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
record.item_id = itemId;
// sourceKeyField는 정확한 sourceKeyValue 변수 사용 (autoFillFrom:"id" 오작동 방지)
record[sourceKeyField] = sourceKeyValue;
// 나머지 autoFillFrom 필드 처리
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];
if (value !== undefined && value !== null && !record[field.name]) {
record[field.name] = value;
@ -1700,7 +1715,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 디자인 모드: 샘플 데이터로 미리보기 표시
if (isDesignMode) {
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 gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";

View File

@ -20,6 +20,7 @@ interface V2RepeaterRendererProps {
onRowClick?: (row: any) => void;
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
parentId?: string | number;
formData?: Record<string, any>;
}
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
@ -31,6 +32,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onRowClick,
onButtonClick,
parentId,
formData,
}) => {
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
const config: V2RepeaterConfig = React.useMemo(() => {
@ -101,6 +103,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onRowClick={onRowClick}
onButtonClick={onButtonClick}
className={component?.className}
formData={formData}
/>
);
};

View File

@ -1183,31 +1183,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
// detail 모드: 선택 안 하면 아무것도 안 뜸, 선택하면 필터링
// join 모드: 선택 안 하면 전체, 선택하면 필터링
if (!leftItem) return;
setIsLoadingRight(true);
try {
if (relationshipType === "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") {
// detail / join 모두 동일한 필터링 로직 사용
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
{
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
const keys = componentConfig.rightPanel?.relation?.keys;
const leftTable = componentConfig.leftPanel?.tableName;
@ -1443,16 +1427,24 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 탭의 dataFilter (API 전달용)
const tabDataFilterForApi = (tabConfig as any).dataFilter;
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
const tabRelationType = tabConfig.relation?.type || "join";
if (!leftItem) {
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: tabJoinColumns,
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
if (tabRelationType === "detail") {
// detail 모드: 선택 안 하면 아무것도 안 뜸
resultData = [];
} else {
// join 모드: 좌측 미선택 시 전체 데이터 로드 (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> = {};
@ -1534,22 +1526,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
);
// 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드)
// 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
setActiveTabIndex(newTabIndex);
// 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 데이터 로드하지 않음
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
const requireSelection = mainRelationType === "detail";
if (newTabIndex === 0) {
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
loadRightData(selectedLeftItem);
if (!requireSelection || selectedLeftItem) {
loadRightData(selectedLeftItem);
}
}
} else {
if (!tabsData[newTabIndex]) {
loadTabData(newTabIndex, selectedLeftItem);
if (!requireSelection || selectedLeftItem) {
loadTabData(newTabIndex, selectedLeftItem);
}
}
}
},
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, componentConfig.rightPanel?.relation?.type],
);
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
@ -1562,24 +1562,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
selectedLeftItem[leftPk] === item[leftPk];
if (isSameItem) {
// 선택 해제 → 전체 데이터 로드
// 선택 해제
setSelectedLeftItem(null);
setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화
setCustomLeftSelectedData({});
setExpandedRightItems(new Set());
setTabsData({});
if (activeTabIndex === 0) {
loadRightData(null);
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail") {
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
setRightData(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);
}
});
// "연관 목록" 모드: 선택 해제 시 전체 데이터 로드
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;
}
@ -2781,14 +2788,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
// join 모드: 초기 전체 로드 / detail 모드: 초기 로드 안 함
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
if (relationshipType === "join") {
loadRightData(null);
// 추가 탭도 전체 데이터 로드
}
// 추가 탭: 메인 패널이 "detail"(선택 시 표시)이면 추가 탭도 초기 로드하지 않음
if (relationshipType !== "detail") {
const tabs = componentConfig.rightPanel?.additionalTabs;
if (tabs && tabs.length > 0) {
tabs.forEach((_: any, idx: number) => {
loadTabData(idx + 1, null);
tabs.forEach((tab: any, idx: number) => {
const tabRelType = tab.relation?.type || "join";
if (tabRelType === "join") {
loadTabData(idx + 1, null);
}
});
}
}
@ -3738,6 +3751,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex];
// 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 안내 메시지
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail" && !selectedLeftItem && !isDesignMode) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> </p>
<p className="text-xs"> </p>
</div>
);
}
if (isTabLoading) {
return (
<div className="flex h-32 items-center justify-center">
@ -4645,7 +4669,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) : (
<>
<p className="mb-2"> </p>
<p className="text-xs"> </p>
<p className="text-xs"> </p>
</>
)}
</div>

View File

@ -1542,13 +1542,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
}, [loadedTableColumns, leftTableName]);
// 우측 테이블명 (상세 모드에서는 좌측과 동일)
// 우측 테이블명
const rightTableName = useMemo(() => {
if (relationshipType === "detail") {
return leftTableName; // 상세 모드에서는 좌측과 동일
}
return config.rightPanel?.tableName || "";
}, [relationshipType, leftTableName, config.rightPanel?.tableName]);
}, [config.rightPanel?.tableName]);
// 우측 테이블 컬럼 (로드된 컬럼 사용)
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(" - relationshipType:", relationshipType);
@ -1584,7 +1581,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{
id: "basic",
title: "기본 설정",
desc: `${relationshipType === "detail" ? "1건 상세보기" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
desc: `${relationshipType === "detail" ? "선택 시 표시" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
icon: Settings2,
},
{
@ -1638,35 +1635,27 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<Select
value={relationshipType}
onValueChange={(value: "join" | "detail") => {
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
if (value === "detail" && screenTableName) {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
tableName: screenTableName,
});
} else {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
});
}
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
});
}}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 방식 선택">
{relationshipType === "detail" ? "1건 상세보기" : "연관 목록"}
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="detail">
<div className="flex flex-col py-1">
<span className="text-sm font-medium">1 </span>
<span className="text-xs text-gray-500"> ( )</span>
<span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> / </span>
</div>
</SelectItem>
<SelectItem value="join">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> / </span>
<span className="text-xs text-gray-500"> / </span>
</div>
</SelectItem>
</SelectContent>
@ -2305,7 +2294,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-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">
<Label> </Label>
@ -2338,63 +2327,49 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> */}
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
{relationshipType === "detail" ? (
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
<div className="space-y-2">
<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}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightTableOpen}
className="w-full justify-between"
>
{config.rightPanel?.tableName || "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableRightTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateRightPanel({ tableName: table.tableName });
setRightTableOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightTableOpen}
className="w-full justify-between"
>
{config.rightPanel?.tableName || "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{availableRightTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateRightPanel({ tableName: table.tableName });
setRightTableOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label> </Label>

View File

@ -180,7 +180,9 @@ export interface V2RepeaterProps {
data?: any[]; // 초기 데이터 (없으면 API로 로드)
onDataChange?: (data: any[]) => void;
onRowClick?: (row: any) => void;
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
className?: string;
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
}
// 기본 설정값