Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

; Conflicts:
;	frontend/components/screen/ScreenDesigner.tsx
This commit is contained in:
DDD1542
2026-02-09 15:07:16 +09:00
8 changed files with 396 additions and 58 deletions

View File

@@ -81,16 +81,18 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
const isTriggerComponent = (comp: ComponentData): boolean => {
const componentType = (comp.componentType || "").toLowerCase();
const widgetType = ((comp as any).widgetType || "").toLowerCase();
const webType = ((comp as any).webType || "").toLowerCase();
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase();
const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase();
const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase();
// 셀렉트, 라디오, 코드 타입 컴포넌트 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
// 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"];
return triggerTypes.some((type) =>
componentType.includes(type) ||
widgetType.includes(type) ||
webType.includes(type) ||
inputType.includes(type)
inputType.includes(type) ||
source.includes(type)
);
};
@@ -112,9 +114,21 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
}, [components, baseLayerComponents]);
// 선택된 컴포넌트 정보
// 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용)
const allAvailableComponents = useMemo(() => {
const merged = [...(baseLayerComponents || []), ...components];
// 중복 제거 (id 기준)
const seen = new Set<string>();
return merged.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
});
}, [components, baseLayerComponents]);
const selectedComponent = useMemo(() => {
return components.find((c) => c.id === targetComponentId);
}, [components, targetComponentId]);
return allAvailableComponents.find((c) => c.id === targetComponentId);
}, [allAvailableComponents, targetComponentId]);
// 선택된 컴포넌트의 데이터 소스 정보 추출
const dataSourceInfo = useMemo<{
@@ -136,8 +150,17 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
const config = comp.componentConfig || comp.webTypeConfig || {};
const detailSettings = comp.detailSettings || {};
// V2 컴포넌트: config.source 확인
const source = config.source;
// V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크)
const source = config.source || comp.source;
const inputType = config.inputType || comp.inputType;
const webType = config.webType || comp.webType;
// inputType/webType이 category면 카테고리로 판단
if (inputType === "category" || webType === "category") {
const categoryTable = config.categoryTable || comp.tableName || config.tableName;
const categoryColumn = config.categoryColumn || comp.columnName || config.columnName;
return { type: "category", categoryTable, categoryColumn };
}
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
if (source === "category") {
@@ -188,8 +211,17 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
return { type: "none" };
}, [selectedComponent]);
// 의존성 안정화를 위한 직렬화 키
const dataSourceKey = useMemo(() => {
const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
}, [dataSourceInfo]);
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
useEffect(() => {
// race condition 방지
let cancelled = false;
if (dataSourceInfo.type === "none") {
setOptions([]);
return;
@@ -212,10 +244,13 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
try {
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
// 카테고리 값에서 옵션 로드 (category_values 테이블)
console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn);
const response = await apiClient.get(
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
);
if (cancelled) return;
const data = response.data;
console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0);
if (data.success && data.data) {
// 트리 구조를 평탄화
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
@@ -232,22 +267,22 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
}
return result;
};
setOptions(flattenTree(Array.isArray(data.data) ? data.data : []));
const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []);
console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개");
setOptions(loadedOptions);
} else {
setOptions([]);
}
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
// 코드 카테고리에서 옵션 로드
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
if (cancelled) return;
setOptions(codes.map((code) => ({
value: code.code,
label: code.name,
})));
} else if (dataSourceInfo.type === "entity") {
// 엔티티 참조에서 옵션 로드
// 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출
// (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑)
// 방법 2: 직접 참조 테이블로 폴백
let entityLoaded = false;
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
@@ -257,13 +292,13 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
dataSourceInfo.originColumn,
{ limit: 100 }
);
if (cancelled) return;
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
})));
entityLoaded = true;
} catch {
// 원본 테이블.컬럼으로 실패 시 폴백
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
}
}
@@ -277,6 +312,7 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
refColumn,
{ limit: 100 }
);
if (cancelled) return;
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
@@ -287,25 +323,32 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
}
}
// 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음
if (!entityLoaded) {
// 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환
if (!entityLoaded && !cancelled) {
setOptions([]);
}
} else {
setOptions([]);
if (!cancelled) setOptions([]);
}
} catch (error: any) {
console.error("옵션 목록 로드 실패:", error);
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
setOptions([]);
if (!cancelled) {
console.error("옵션 목록 로드 실패:", error);
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
setOptions([]);
}
} finally {
setIsLoadingOptions(false);
if (!cancelled) {
setIsLoadingOptions(false);
}
}
};
loadOptions();
}, [dataSourceInfo]);
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceKey]);
// 조건 저장
const handleSave = useCallback(() => {
@@ -574,11 +617,11 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
)}
{/* 현재 조건 요약 */}
{targetComponentId && (value || multiValues.length > 0) && (
{targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
<div className="p-2 bg-muted rounded-md text-xs">
<span className="font-medium">: </span>
<span className="text-muted-foreground">
"{getComponentLabel(selectedComponent!)}" {" "}
"{getComponentLabel(selectedComponent)}" {" "}
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}

View File

@@ -134,11 +134,25 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
}
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
// 조건 업데이트
// 조건 업데이트 (기존 condition_config의 displayRegion 보존)
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
if (!screenId) return;
try {
await screenApi.updateLayerCondition(screenId, layerId, condition || null);
// 기존 condition_config를 가져와서 displayRegion 보존
const layerData = await screenApi.getLayerLayout(screenId, layerId);
const existingCondition = layerData?.conditionConfig || {};
const displayRegion = existingCondition.displayRegion;
let mergedCondition: any;
if (condition) {
// 조건 설정: 새 조건 + 기존 displayRegion 보존
mergedCondition = { ...condition, ...(displayRegion ? { displayRegion } : {}) };
} else {
// 조건 삭제: displayRegion만 남기거나, 없으면 null
mergedCondition = displayRegion ? { displayRegion } : null;
}
await screenApi.updateLayerCondition(screenId, layerId, mergedCondition);
toast.success("조건이 저장되었습니다.");
await loadLayers();
} catch (error) {

View File

@@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
zIndex: position?.z || 1,
// right 속성 강제 제거
right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
// 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지)
transition: "none",
};
// 선택된 컴포넌트 스타일
@@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return (
<div
id={`component-${id}`}
className="absolute cursor-pointer"
className="absolute cursor-pointer !transition-none"
style={{ ...componentStyle, ...selectionStyle }}
onClick={handleClick}
draggable

View File

@@ -3006,9 +3006,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
})
: null;
// 캔버스 경계 내로 위치 제한
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 displayRegion 크기 기준)
const currentLayerId = activeLayerIdRef.current || 1;
const activeLayerRegion = currentLayerId > 1 ? layerRegions[currentLayerId] : null;
const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width;
const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height;
const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight));
// 격자 스냅 적용
const snappedPosition =
@@ -4187,9 +4191,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y;
// 조건부 레이어 편집 시 displayRegion 크기 기준 경계 제한
const dragLayerId = activeLayerIdRef.current || 1;
const dragLayerRegion = dragLayerId > 1 ? layerRegions[dragLayerId] : null;
const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width;
const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height;
const newPosition = {
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)),
y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)),
z: (dragState.draggedComponent.position as Position).z || 1,
};
@@ -6375,24 +6385,54 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
);
})()}
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
{activeLayerId > 1 && (
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium">
{activeLayerId}
{layerRegions[activeLayerId] && (
<span className="ml-2 text-amber-600">
(: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px)
</span>
)}
{!layerRegions[activeLayerId] && (
<span className="ml-2 text-red-500">
( - )
</span>
)}
</span>
</div>
)}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{(() => {
// 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤
const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null;
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
return (
<div
className="flex justify-center"
style={{
width: "100%",
minHeight: screenResolution.height * zoomLevel,
minHeight: canvasH * zoomLevel,
contain: "layout style", // 레이아웃 재계산 범위 제한
}}
>
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
{/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
<div
className="bg-background border-border border shadow-lg"
className={cn(
"bg-background border shadow-lg",
activeRegion ? "border-amber-400 border-2" : "border-border"
)}
style={{
width: `${screenResolution.width}px`,
height: `${screenResolution.height}px`,
minWidth: `${screenResolution.width}px`,
maxWidth: `${screenResolution.width}px`,
minHeight: `${screenResolution.height}px`,
width: `${canvasW}px`,
height: `${canvasH}px`,
minWidth: `${canvasW}px`,
maxWidth: `${canvasW}px`,
minHeight: `${canvasH}px`,
flexShrink: 0,
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
transformOrigin: "top center", // 중앙 기준으로 스케일
@@ -7022,8 +7062,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
)}
</div>
</div>
</div>{" "}
{/* 🔥 줌 래퍼 닫기 */}
</div>
); /* 🔥 줌 래퍼 닫기 */
})()}
</div>
</div>{" "}
{/* 메인 컨테이너 닫기 */}