feat: 조건부 레이어 관리 및 애니메이션 최적화
- 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다. - 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다. - 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다. - LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다. - RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다.
This commit is contained in:
@@ -5158,11 +5158,14 @@ export class ScreenManagementService {
|
||||
): Promise<void> {
|
||||
const layerId = layoutData.layerId || 1;
|
||||
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
|
||||
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
|
||||
const hasConditionConfig = 'conditionConfig' in layoutData;
|
||||
const conditionConfig = layoutData.conditionConfig || null;
|
||||
|
||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
|
||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||
console.log(`조건 설정 포함 여부: ${hasConditionConfig}`);
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
@@ -5187,16 +5190,27 @@ export class ScreenManagementService {
|
||||
...pureLayoutData,
|
||||
};
|
||||
|
||||
// UPSERT (레이어별 저장)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
|
||||
);
|
||||
if (hasConditionConfig) {
|
||||
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
|
||||
);
|
||||
} else {
|
||||
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`);
|
||||
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
|
||||
import { LayerDefinition } from "@/types/screen-management";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
@@ -86,6 +87,9 @@ function ScreenViewPage() {
|
||||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// 🆕 레이어 시스템 지원
|
||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
@@ -204,6 +208,131 @@ function ScreenViewPage() {
|
||||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드)
|
||||
useEffect(() => {
|
||||
const loadConditionalLayers = async () => {
|
||||
if (!screenId || !layout) return;
|
||||
|
||||
try {
|
||||
// 1. 모든 레이어 목록 조회
|
||||
const allLayers = await screenApi.getScreenLayers(screenId);
|
||||
// layer_id > 1인 레이어만 (기본 레이어 제외)
|
||||
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
|
||||
|
||||
if (nonBaseLayers.length === 0) {
|
||||
setConditionalLayers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 각 레이어의 레이아웃 데이터 로드
|
||||
const layerDefinitions: LayerDefinition[] = [];
|
||||
|
||||
for (const layerInfo of nonBaseLayers) {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id);
|
||||
const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {};
|
||||
|
||||
// 레이어 컴포넌트 변환 (V2 → Legacy)
|
||||
// getLayerLayout 응답: { ...layout_data, layerId, layerName, conditionConfig }
|
||||
// layout_data가 spread 되므로 components는 최상위에 있음
|
||||
let layerComponents: any[] = [];
|
||||
const rawComponents = layerData?.components;
|
||||
if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) {
|
||||
// V2 컴포넌트를 Legacy 형식으로 변환
|
||||
const tempV2 = {
|
||||
version: "2.0" as const,
|
||||
components: rawComponents,
|
||||
gridSettings: layerData.gridSettings,
|
||||
screenResolution: layerData.screenResolution,
|
||||
};
|
||||
if (isValidV2Layout(tempV2)) {
|
||||
const converted = convertV2ToLegacy(tempV2);
|
||||
if (converted) {
|
||||
layerComponents = converted.components || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LayerDefinition 생성
|
||||
const layerDef: LayerDefinition = {
|
||||
id: String(layerInfo.layer_id),
|
||||
name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||
type: "conditional",
|
||||
zIndex: layerInfo.layer_id * 10,
|
||||
isVisible: false, // 조건 충족 시에만 표시
|
||||
isLocked: false,
|
||||
condition: condConfig.targetComponentId ? {
|
||||
targetComponentId: condConfig.targetComponentId,
|
||||
operator: condConfig.operator || "eq",
|
||||
value: condConfig.value,
|
||||
} : undefined,
|
||||
displayRegion: condConfig.displayRegion || undefined,
|
||||
components: layerComponents,
|
||||
};
|
||||
|
||||
layerDefinitions.push(layerDef);
|
||||
} catch (layerError) {
|
||||
console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
|
||||
id: l.id, name: l.name, condition: l.condition, displayRegion: l.displayRegion,
|
||||
componentCount: l.components.length,
|
||||
})));
|
||||
setConditionalLayers(layerDefinitions);
|
||||
} catch (error) {
|
||||
console.error("레이어 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadConditionalLayers();
|
||||
}, [screenId, layout]);
|
||||
|
||||
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
|
||||
const activeLayerIds = useMemo(() => {
|
||||
if (conditionalLayers.length === 0 || !layout) return [] as string[];
|
||||
|
||||
const allComponents = layout.components || [];
|
||||
const newActiveIds: string[] = [];
|
||||
|
||||
conditionalLayers.forEach((layer) => {
|
||||
if (layer.condition) {
|
||||
const { targetComponentId, operator, value } = layer.condition;
|
||||
|
||||
// 트리거 컴포넌트 찾기 (기본 레이어에서)
|
||||
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||||
|
||||
// columnName으로 formData에서 값 조회
|
||||
const fieldKey =
|
||||
(targetComponent as any)?.columnName ||
|
||||
(targetComponent as any)?.componentConfig?.columnName ||
|
||||
targetComponentId;
|
||||
|
||||
const targetValue = formData[fieldKey];
|
||||
|
||||
let isMatch = false;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
isMatch = targetValue == value;
|
||||
break;
|
||||
case "neq":
|
||||
isMatch = targetValue != value;
|
||||
break;
|
||||
case "in":
|
||||
isMatch = Array.isArray(value) && value.includes(targetValue);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
newActiveIds.push(layer.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newActiveIds;
|
||||
}, [formData, conditionalLayers, layout]);
|
||||
|
||||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||||
useEffect(() => {
|
||||
@@ -513,6 +642,7 @@ function ScreenViewPage() {
|
||||
{layoutReady && layout && layout.components.length > 0 ? (
|
||||
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="bg-background relative"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
@@ -630,7 +760,25 @@ function ScreenViewPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHeightAdjustment > 0) {
|
||||
// 🆕 조건부 레이어 displayRegion 기반 높이 조정
|
||||
// 기본 레이어 컴포넌트는 displayRegion 포함한 위치에 배치되므로,
|
||||
// 비활성(빈 영역) 시 아래 컴포넌트를 위로 당겨 빈 공간 제거
|
||||
for (const layer of conditionalLayers) {
|
||||
if (!layer.displayRegion) continue;
|
||||
const region = layer.displayRegion;
|
||||
const regionBottom = region.y + region.height;
|
||||
const isActive = activeLayerIds.includes(layer.id);
|
||||
|
||||
// 컴포넌트가 조건부 영역 하단보다 아래에 있는 경우
|
||||
if (component.position.y >= regionBottom) {
|
||||
if (!isActive) {
|
||||
// 비활성: 영역 높이만큼 위로 당김 (빈 공간 제거)
|
||||
totalHeightAdjustment -= region.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHeightAdjustment !== 0) {
|
||||
return {
|
||||
...component,
|
||||
position: {
|
||||
@@ -950,6 +1098,77 @@ function ScreenViewPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{conditionalLayers.map((layer) => {
|
||||
const isActive = activeLayerIds.includes(layer.id);
|
||||
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
||||
|
||||
const region = layer.displayRegion;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`conditional-layer-${layer.id}`}
|
||||
data-conditional-layer="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: region ? `${region.x}px` : "0px",
|
||||
top: region ? `${region.y}px` : "0px",
|
||||
width: region ? `${region.width}px` : "100%",
|
||||
height: region ? `${region.height}px` : "auto",
|
||||
zIndex: layer.zIndex || 20,
|
||||
overflow: "hidden", // 영역 밖 컴포넌트 숨김
|
||||
transition: "none",
|
||||
}}
|
||||
>
|
||||
{layer.components
|
||||
.filter((comp) => !comp.parentId)
|
||||
.map((comp) => (
|
||||
<RealtimePreview
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
menuObjid={menuObjid}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
columnOrder={tableColumnOrder}
|
||||
tableDisplayData={tableDisplayData}
|
||||
onSelectedRowsChange={(
|
||||
_,
|
||||
selectedData,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
columnOrder,
|
||||
tableDisplayData,
|
||||
) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
setTableColumnOrder(columnOrder);
|
||||
setTableDisplayData(tableDisplayData || []);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -263,12 +263,20 @@ input,
|
||||
textarea,
|
||||
select {
|
||||
transition-property:
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter,
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter,
|
||||
backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */
|
||||
[data-screen-runtime] [id^="component-"] {
|
||||
transition: none !important;
|
||||
}
|
||||
[data-screen-runtime] [data-conditional-layer] {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Disable animations for users who prefer reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3049,9 +3049,13 @@ export default function ScreenDesigner({
|
||||
})
|
||||
: 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 =
|
||||
@@ -4265,9 +4269,15 @@ export default function ScreenDesigner({
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -6623,28 +6633,50 @@ export default function ScreenDesigner({
|
||||
{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} 편집 중</span>
|
||||
<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", // 중앙 기준으로 스케일
|
||||
@@ -7363,8 +7395,9 @@ export default function ScreenDesigner({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 🔥 줌 래퍼 닫기 */}
|
||||
</div>
|
||||
); /* 🔥 줌 래퍼 닫기 */
|
||||
})()}
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ConditionalSectionViewer({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-all",
|
||||
"relative w-full",
|
||||
isDesignMode && showBorder && "border-muted-foreground/30 bg-muted/20 rounded-lg border-2 border-dashed",
|
||||
!isDesignMode && !isActive && "hidden",
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user