feat: 조건부 레이어 관리 및 애니메이션 최적화
- 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다. - 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다. - 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다. - LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다. - RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다.
This commit is contained in:
@@ -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) {
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user