feat: 조건부 레이어 관리 및 애니메이션 최적화

- 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다.
- 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다.
- 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다.
- LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다.
- RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다.
This commit is contained in:
kjs
2026-02-09 15:02:53 +09:00
parent 78f23ea0a9
commit 7dc0bbb329
8 changed files with 389 additions and 59 deletions

View File

@@ -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>
);
})}
</>
);
})()}

View File

@@ -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) {
*,