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:
@@ -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(", ")}] 중 하나이면`}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
|
||||
Reference in New Issue
Block a user