Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs
2026-02-09 16:03:27 +09:00
parent 0ea5f3d5e4
commit f8c0fe9499
10 changed files with 8264 additions and 276 deletions

View File

@@ -1443,13 +1443,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
* 메뉴 및 관련 데이터 정리 헬퍼 함수
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 2. code_category에서 menu_objid를 NULL로 설정
// 1. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]

View File

@@ -787,6 +787,78 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo
}
};
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
// Zone 목록 조회
export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode);
res.json({ success: true, data: zones });
} catch (error) {
console.error("Zone 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." });
}
};
// Zone 생성
export const createZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body);
res.json({ success: true, data: zone });
} catch (error) {
console.error("Zone 생성 실패:", error);
res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." });
}
};
// Zone 업데이트 (위치/크기/트리거)
export const updateZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { zoneId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body);
res.json({ success: true, message: "Zone이 업데이트되었습니다." });
} catch (error) {
console.error("Zone 업데이트 실패:", error);
res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." });
}
};
// Zone 삭제
export const deleteZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { zoneId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteZone(parseInt(zoneId), companyCode);
res.json({ success: true, message: "Zone이 삭제되었습니다." });
} catch (error) {
console.error("Zone 삭제 실패:", error);
res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." });
}
};
// Zone에 레이어 추가
export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, zoneId } = req.params;
const { companyCode } = req.user as any;
const { conditionValue, layerName } = req.body;
const result = await screenManagementService.addLayerToZone(
parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName
);
res.json({ success: true, data: result });
} catch (error) {
console.error("Zone 레이어 추가 실패:", error);
res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." });
}
};
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================

View File

@@ -46,6 +46,11 @@ import {
getLayerLayout,
deleteLayer,
updateLayerCondition,
getScreenZones,
createZone,
updateZone,
deleteZone,
addLayerToZone,
} from "../controllers/screenManagementController";
const router = express.Router();
@@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
// 조건부 영역(Zone) 관리
router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록
router.post("/screens/:screenId/zones", createZone); // Zone 생성
router.put("/zones/:zoneId", updateZone); // Zone 업데이트
router.delete("/zones/:zoneId", deleteZone); // Zone 삭제
router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가
// POP 레이아웃 관리 (모바일/태블릿)
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장

View File

@@ -5363,6 +5363,170 @@ export class ScreenManagementService {
);
}
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
/**
* 화면의 조건부 영역(Zone) 목록 조회
*/
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
let zones;
if (companyCode === "*") {
// 최고 관리자: 모든 회사 Zone 조회 가능
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
[screenId],
);
} else {
// 일반 회사: 자사 Zone만 조회 (company_code = '*' 제외)
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 AND company_code = $2 ORDER BY zone_id`,
[screenId, companyCode],
);
}
return zones;
}
/**
* 조건부 영역(Zone) 생성
*/
async createZone(
screenId: number,
companyCode: string,
zoneData: {
zone_name?: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<any> {
const result = await queryOne<any>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
screenId,
companyCode,
zoneData.zone_name || '조건부 영역',
zoneData.x,
zoneData.y,
zoneData.width,
zoneData.height,
zoneData.trigger_component_id || null,
zoneData.trigger_operator || 'eq',
],
);
return result;
}
/**
* 조건부 영역(Zone) 업데이트 (위치/크기/트리거)
*/
async updateZone(
zoneId: number,
companyCode: string,
updates: {
zone_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<void> {
const setClauses: string[] = ['updated_at = NOW()'];
const params: any[] = [zoneId, companyCode];
let paramIdx = 3;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
setClauses.push(`${key} = $${paramIdx}`);
params.push(value);
paramIdx++;
}
}
await query(
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
WHERE zone_id = $1 AND company_code = $2`,
params,
);
}
/**
* 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리
*/
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
await query(
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
[companyCode, String(zoneId)],
);
await query(
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
}
/**
* Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당)
*/
async addLayerToZone(
screenId: number,
companyCode: string,
zoneId: number,
conditionValue: string,
layerName?: string,
): Promise<{ layerId: number }> {
// 다음 layer_id 계산
const maxResult = await queryOne<{ max_id: number }>(
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
const newLayerId = (maxResult?.max_id || 1) + 1;
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
const zone = await queryOne<any>(
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
const layoutData = {
version: "2.1",
components: [],
screenResolution: zone
? { width: zone.width, height: zone.height }
: { width: 800, height: 200 },
};
const conditionConfig = {
zone_id: zoneId,
condition_value: conditionValue,
};
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
SET layout_data = EXCLUDED.layout_data,
layer_name = EXCLUDED.layer_name,
condition_config = EXCLUDED.condition_config,
updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
);
return { layerId: newLayerId };
}
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)

View File

@@ -89,6 +89,8 @@ function ScreenViewPage() {
// 🆕 레이어 시스템 지원
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
// 🆕 조건부 영역(Zone) 목록
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
@@ -208,15 +210,18 @@ function ScreenViewPage() {
}
}, [screenId]);
// 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드)
// 🆕 조건부 레이어 + Zone 로드
useEffect(() => {
const loadConditionalLayers = async () => {
const loadConditionalLayersAndZones = async () => {
if (!screenId || !layout) return;
try {
// 1. 모든 레이어 목록 조회
// 1. Zone 로드
const loadedZones = await screenApi.getScreenZones(screenId);
setZones(loadedZones);
// 2. 모든 레이어 목록 조회
const allLayers = await screenApi.getScreenLayers(screenId);
// layer_id > 1인 레이어만 (기본 레이어 제외)
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
if (nonBaseLayers.length === 0) {
@@ -224,7 +229,7 @@ function ScreenViewPage() {
return;
}
// 2. 각 레이어의 레이아웃 데이터 로드
// 3. 각 레이어의 레이아웃 데이터 로드
const layerDefinitions: LayerDefinition[] = [];
for (const layerInfo of nonBaseLayers) {
@@ -233,12 +238,9 @@ function ScreenViewPage() {
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,
@@ -253,20 +255,33 @@ function ScreenViewPage() {
}
}
// Zone 기반 condition_config 처리
const zoneId = condConfig.zone_id;
const conditionValue = condConfig.condition_value;
const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null;
// 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, // 조건 충족 시에만 표시
isVisible: false,
isLocked: false,
condition: condConfig.targetComponentId ? {
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
condition: zone ? {
targetComponentId: zone.trigger_component_id || "",
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
value: conditionValue,
} : condConfig.targetComponentId ? {
targetComponentId: condConfig.targetComponentId,
operator: condConfig.operator || "eq",
value: condConfig.value,
} : undefined,
displayRegion: condConfig.displayRegion || undefined,
// Zone 기반: displayRegion은 Zone에서 가져옴
zoneId: zoneId || undefined,
conditionValue: conditionValue || undefined,
displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined,
components: layerComponents,
};
@@ -277,16 +292,16 @@ function ScreenViewPage() {
}
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
id: l.id, name: l.name, condition: l.condition, displayRegion: l.displayRegion,
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
componentCount: l.components.length,
})));
setConditionalLayers(layerDefinitions);
} catch (error) {
console.error("레이어 로드 실패:", error);
console.error("레이어/Zone 로드 실패:", error);
}
};
loadConditionalLayers();
loadConditionalLayersAndZones();
}, [screenId, layout]);
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
@@ -760,20 +775,20 @@ function ScreenViewPage() {
}
}
// 🆕 조건부 레이어 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;
// 🆕 Zone 기반 높이 조정
// Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산
// Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단)
for (const zone of zones) {
const zoneBottom = zone.y + zone.height;
// 컴포넌트가 Zone 하단보다 아래에 있는 경우
if (component.position.y >= zoneBottom) {
// Zone에 매칭되는 활성 레이어가 있는지 확인
const hasActiveLayer = conditionalLayers.some(
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id)
);
if (!hasActiveLayer) {
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
totalHeightAdjustment -= zone.height;
}
}
}
@@ -1099,12 +1114,16 @@ function ScreenViewPage() {
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */}
{conditionalLayers.map((layer) => {
const isActive = activeLayerIds.includes(layer.id);
if (!isActive || !layer.components || layer.components.length === 0) return null;
const region = layer.displayRegion;
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null;
const region = zone
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
: layer.displayRegion;
return (
<div
@@ -1117,7 +1136,7 @@ function ScreenViewPage() {
width: region ? `${region.width}px` : "100%",
height: region ? `${region.height}px` : "auto",
zIndex: layer.zIndex || 20,
overflow: "hidden", // 영역 밖 컴포넌트 숨김
overflow: "hidden",
transition: "none",
}}
>

View File

@@ -284,59 +284,38 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
const conditionalRegionInfos = useMemo(() => {
return layers
.filter((layer) => layer.type === "conditional" && layer.displayRegion)
.map((layer) => ({
layerId: layer.id,
region: layer.displayRegion!,
isActive: activeLayerIds.includes(layer.id),
}))
.sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬
}, [layers, activeLayerIds]);
// 🆕 접힌 조건부 영역 (비활성 상태인 것만)
const collapsedRegions = useMemo(() => {
return conditionalRegionInfos
.filter((info) => !info.isActive)
.map((info) => info.region);
}, [conditionalRegionInfos]);
// 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원)
// 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌
// 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
const calculateYOffset = useCallback((componentY: number): number => {
if (collapsedRegions.length === 0) return 0;
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
const relevantRegions = collapsedRegions.filter(
(region) => region.y + region.height <= componentY
);
if (relevantRegions.length === 0) return 0;
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
const mergedRegions: { y: number; bottom: number }[] = [];
for (const region of relevantRegions) {
const bottom = region.y + region.height;
if (mergedRegions.length === 0) {
mergedRegions.push({ y: region.y, bottom });
} else {
const last = mergedRegions[mergedRegions.length - 1];
if (region.y <= last.bottom) {
// 겹치는 영역 - 병합 (더 큰 하단으로 확장)
last.bottom = Math.max(last.bottom, bottom);
} else {
// 겹치지 않는 영역 - 새로 추가
mergedRegions.push({ y: region.y, bottom });
}
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
for (const layer of layers) {
if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue;
const zid = layer.zoneId;
if (!zoneMap.has(zid)) {
zoneMap.set(zid, {
y: layer.displayRegion.y,
height: layer.displayRegion.height,
hasActive: false,
});
}
if (activeLayerIds.includes(layer.id)) {
zoneMap.get(zid)!.hasActive = true;
}
}
// 병합된 영역들의 높이 합산
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
}, [collapsedRegions]);
let totalOffset = 0;
for (const [, zone] of zoneMap) {
const zoneBottom = zone.y + zone.height;
// 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘
if (componentY >= zoneBottom && !zone.hasActive) {
totalOffset += zone.height;
}
}
return totalOffset;
}, [layers, activeLayerIds]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
@@ -2378,7 +2357,48 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
// 일반/조건부 레이어 (base, conditional)
// 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링
if (layer.type === "conditional" && layer.displayRegion) {
const region = layer.displayRegion;
return (
<div
key={layer.id}
className="pointer-events-none absolute"
style={{
left: `${region.x}px`,
top: `${region.y}px`,
width: `${region.width}px`,
height: `${region.height}px`,
zIndex: layer.zIndex,
overflow: "hidden",
}}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
);
}
// 기본/기타 레이어 (base)
return (
<div
key={layer.id}
@@ -2386,7 +2406,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
style={{ zIndex: layer.zIndex }}
>
{layer.components.map((comp) => {
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
const adjustedY = comp.position.y - yOffset;
@@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
})}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
return (
<SplitPanelProvider>

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
@@ -12,13 +13,13 @@ import {
ChevronRight,
Zap,
Loader2,
Box,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { screenApi } from "@/lib/api/screen";
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
import { toast } from "sonner";
import { LayerConditionPanel } from "./LayerConditionPanel";
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
import { ComponentData, ConditionalZone } from "@/types/screen-management";
// DB 레이어 타입
interface DBLayer {
@@ -34,6 +35,8 @@ interface LayerManagerPanelProps {
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
onLayerChange: (layerId: number) => void; // 레이어 전환
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달)
onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
@@ -41,13 +44,23 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
activeLayerId,
onLayerChange,
components = [],
zones: externalZones,
onZonesChange,
}) => {
const [layers, setLayers] = useState<DBLayer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
// 펼침/접힘 상태: zone_id별
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set());
// Zone에 레이어 추가 시 조건값 입력 상태
const [addingToZoneId, setAddingToZoneId] = useState<number | null>(null);
const [newConditionValue, setNewConditionValue] = useState("");
// Zone 트리거 설정 열기 상태
const [triggerEditZoneId, setTriggerEditZoneId] = useState<number | null>(null);
// 기본 레이어 컴포넌트 (트리거 선택용)
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
const zones = externalZones || [];
// 레이어 목록 로드
const loadLayers = useCallback(async () => {
if (!screenId) return;
@@ -62,60 +75,60 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
}
}, [screenId]);
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
// 기본 레이어 컴포넌트 로드
const loadBaseLayerComponents = useCallback(async () => {
if (!screenId) return;
try {
const data = await screenApi.getLayerLayout(screenId, 1);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setBaseLayerComponents(legacy.components as ComponentData[]);
return;
}
if (data?.components) {
setBaseLayerComponents(data.components as ComponentData[]);
}
setBaseLayerComponents([]);
} catch {
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
setBaseLayerComponents(components);
}
}, [screenId, components]);
useEffect(() => {
loadLayers();
}, [loadLayers]);
loadBaseLayerComponents();
}, [loadLayers, loadBaseLayerComponents]);
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
useEffect(() => {
if (conditionOpenLayerId !== null) {
loadBaseLayerComponents();
}
}, [conditionOpenLayerId, loadBaseLayerComponents]);
// Zone별 레이어 그룹핑
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
return layers.filter(l => {
const cc = l.condition_config;
return cc && cc.zone_id === zoneId;
});
}, [layers]);
// 새 레이어 추가
const handleAddLayer = useCallback(async () => {
if (!screenId) return;
// 다음 layer_id 계산
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
const newLayerId = maxLayerId + 1;
// Zone에 속하지 않는 조건부 레이어 (레거시)
const orphanLayers = layers.filter(l => {
if (l.layer_id === 1) return false;
const cc = l.condition_config;
return !cc || !cc.zone_id;
});
// 기본 레이어
const baseLayer = layers.find(l => l.layer_id === 1);
// Zone에 레이어 추가
const handleAddLayerToZone = useCallback(async (zoneId: number) => {
if (!screenId || !newConditionValue.trim()) return;
try {
// 빈 레이아웃으로 새 레이어 저장
await screenApi.saveLayoutV2(screenId, {
version: "2.0",
components: [],
layerId: newLayerId,
layerName: `조건부 레이어 ${newLayerId}`,
});
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
const result = await screenApi.addLayerToZone(
screenId, zoneId, newConditionValue.trim(),
`레이어 (${newConditionValue.trim()})`,
);
toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`);
setAddingToZoneId(null);
setNewConditionValue("");
await loadLayers();
// 새 레이어로 전환
onLayerChange(newLayerId);
onLayerChange(result.layerId);
} catch (error) {
console.error("레이어 추가 실패:", error);
console.error("Zone 레이어 추가 실패:", error);
toast.error("레이어 추가에 실패했습니다.");
}
}, [screenId, layers, loadLayers, onLayerChange]);
}, [screenId, newConditionValue, loadLayers, onLayerChange]);
// 레이어 삭제
const handleDeleteLayer = useCallback(async (layerId: number) => {
@@ -124,42 +137,59 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
await screenApi.deleteLayer(screenId, layerId);
toast.success("레이어가 삭제되었습니다.");
await loadLayers();
// 기본 레이어로 전환
if (activeLayerId === layerId) {
onLayerChange(1);
}
if (activeLayerId === layerId) onLayerChange(1);
} catch (error) {
console.error("레이어 삭제 실패:", error);
toast.error("레이어 삭제에 실패했습니다.");
}
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
// 조건 업데이트 (기존 condition_config의 displayRegion 보존)
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
// Zone 삭제
const handleDeleteZone = useCallback(async (zoneId: number) => {
if (!screenId) return;
try {
// 기존 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 screenApi.deleteZone(zoneId);
toast.success("조건부 영역이 삭제되었습니다.");
// Zone 목록 새로고침
const loadedZones = await screenApi.getScreenZones(screenId);
onZonesChange?.(loadedZones);
await loadLayers();
onLayerChange(1);
} catch (error) {
console.error("조건 업데이트 실패:", error);
toast.error("조건 저장에 실패했습니다.");
console.error("Zone 삭제 실패:", error);
toast.error("Zone 삭제에 실패했습니다.");
}
}, [screenId, loadLayers]);
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
// Zone 트리거 컴포넌트 업데이트
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
try {
await screenApi.updateZone(zoneId, {
trigger_component_id: triggerComponentId,
trigger_operator: operator,
});
const loadedZones = await screenApi.getScreenZones(screenId!);
onZonesChange?.(loadedZones);
toast.success("트리거가 설정되었습니다.");
} catch (error) {
console.error("Zone 트리거 업데이트 실패:", error);
toast.error("트리거 설정에 실패했습니다.");
}
}, [screenId, onZonesChange]);
// Zone 접힘/펼침 토글
const toggleZone = (zoneId: number) => {
setExpandedZones(prev => {
const next = new Set(prev);
next.has(zoneId) ? next.delete(zoneId) : next.add(zoneId);
return next;
});
};
// 트리거로 사용 가능한 컴포넌트 (select, combobox 등)
const triggerableComponents = baseLayerComponents.filter(c =>
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
);
return (
<div className="flex h-full flex-col bg-background">
@@ -172,19 +202,9 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
{layers.length}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
onClick={handleAddLayer}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 레이어 목록 */}
{/* 레이어 + Zone 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{isLoading ? (
@@ -192,146 +212,266 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : layers.length === 0 ? (
<div className="space-y-2 py-4 text-center">
<p className="text-sm text-muted-foreground"> ...</p>
<p className="text-[10px] text-muted-foreground"> .</p>
</div>
) : (
layers
.slice()
.reverse()
.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
const isBase = layer.layer_id === 1;
const hasCondition = !!layer.condition_config;
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
<>
{/* 기본 레이어 */}
{baseLayer && (
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
activeLayerId === 1
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
)}
onClick={() => onLayerChange(1)}
>
<span className="shrink-0 rounded bg-blue-100 p-1 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
<Layers className="h-3 w-3" />
</span>
<div className="min-w-0 flex-1">
<span className="truncate font-medium">{baseLayer.layer_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]"></Badge>
<span className="text-[10px] text-muted-foreground">{baseLayer.component_count} </span>
</div>
</div>
</div>
)}
{/* 조건부 영역(Zone) 목록 */}
{zones.map((zone) => {
const zoneLayers = getLayersForZone(zone.zone_id);
const isExpanded = expandedZones.has(zone.zone_id);
const isTriggerEdit = triggerEditZoneId === zone.zone_id;
return (
<div key={layer.layer_id} className="space-y-0">
<div key={`zone-${zone.zone_id}`} className="space-y-0">
{/* Zone 헤더 */}
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
isConditionOpen && "rounded-b-none border-b-0",
"border-amber-200 bg-amber-50/50 hover:bg-amber-100/50 dark:border-amber-800 dark:bg-amber-950/20",
isExpanded && "rounded-b-none border-b-0",
)}
onClick={() => onLayerChange(layer.layer_id)}
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
draggable={!isBase}
onDragStart={(e) => {
if (isBase) return;
e.dataTransfer.setData("application/json", JSON.stringify({
type: "layer-region",
layerId: layer.layer_id,
layerName: layer.layer_name,
}));
e.dataTransfer.effectAllowed = "copy";
}}
onClick={() => toggleZone(zone.zone_id)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-amber-600" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-amber-600" />
)}
<Box className="h-3.5 w-3.5 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium text-amber-800 dark:text-amber-300">{zone.zone_name}</span>
<div className="flex items-center gap-2">
<span className={cn(
"shrink-0 rounded p-1",
isBase
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}>
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
</span>
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
{isBase ? "기본" : "조건부"}
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] border-amber-300 text-amber-700">
Zone
</Badge>
<span className="text-[10px] text-muted-foreground">
{layer.component_count}
{zoneLayers.length} | {zone.width}x{zone.height}
</span>
{hasCondition && (
{zone.trigger_component_id && (
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
<Zap className="h-2.5 w-2.5" />
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼 */}
{/* Zone 액션 버튼 */}
<div className="flex shrink-0 items-center gap-0.5">
{!isBase && (
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
{!isBase && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteLayer(layer.layer_id);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon"
className="h-6 w-6 text-amber-600 hover:text-amber-800"
title="트리거 설정"
onClick={(e) => { e.stopPropagation(); setTriggerEditZoneId(isTriggerEdit ? null : zone.zone_id); }}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon"
className="h-6 w-6 hover:text-destructive"
title="Zone 삭제"
onClick={(e) => { e.stopPropagation(); handleDeleteZone(zone.zone_id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 조건 설정 패널 */}
{!isBase && isConditionOpen && (
{/* 펼쳐진 Zone 내용 */}
{isExpanded && (
<div className={cn(
"rounded-b-md border border-t-0 bg-muted/30",
isActive ? "border-primary" : "border-border",
"rounded-b-md border border-t-0 border-amber-200 bg-amber-50/20 p-2 space-y-1",
"dark:border-amber-800 dark:bg-amber-950/10",
)}>
<LayerConditionPanel
layer={{
id: String(layer.layer_id),
name: layer.layer_name,
type: "conditional",
zIndex: layer.layer_id,
isVisible: true,
isLocked: false,
condition: layer.condition_config || undefined,
components: [],
}}
components={baseLayerComponents}
baseLayerComponents={baseLayerComponents}
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
onUpdateDisplayRegion={() => {}}
onClose={() => setConditionOpenLayerId(null)}
/>
{/* 트리거 설정 패널 */}
{isTriggerEdit && (
<div className="mb-2 rounded border bg-background p-2 space-y-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{triggerableComponents.length === 0 ? (
<p className="text-[10px] text-muted-foreground"> Select/Combobox/Radio .</p>
) : (
<div className="space-y-1">
{triggerableComponents.map(c => (
<button
key={c.id}
className={cn(
"w-full text-left rounded px-2 py-1 text-[11px] transition-colors",
zone.trigger_component_id === c.id
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted",
)}
onClick={() => handleUpdateZoneTrigger(zone.zone_id, c.id!)}
>
{c.componentConfig?.label || c.id} ({c.componentType})
</button>
))}
</div>
)}
</div>
)}
{/* Zone 소속 레이어 목록 */}
{zoneLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
return (
<div
key={layer.layer_id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-1.5 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-background",
)}
onClick={() => onLayerChange(layer.layer_id)}
>
<SplitSquareVertical className="h-3 w-3 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium truncate">{layer.layer_name}</span>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">
: {layer.condition_config?.condition_value || "미설정"}
</span>
<span className="text-[10px] text-muted-foreground">
| {layer.component_count}
</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
{/* 레이어 추가 */}
{addingToZoneId === zone.zone_id ? (
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
<Input
value={newConditionValue}
onChange={(e) => setNewConditionValue(e.target.value)}
placeholder="조건값 입력 (예: 옵션1)"
className="h-6 text-[11px] flex-1"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }}
/>
<Button
variant="default" size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => handleAddLayerToZone(zone.zone_id)}
disabled={!newConditionValue.trim()}
>
</Button>
<Button
variant="ghost" size="sm"
className="h-6 px-1 text-[10px]"
onClick={() => { setAddingToZoneId(null); setNewConditionValue(""); }}
>
</Button>
</div>
) : (
<Button
variant="outline" size="sm"
className="h-6 w-full gap-1 text-[10px] border-dashed"
onClick={() => setAddingToZoneId(zone.zone_id)}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
);
})
})}
{/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */}
{orphanLayers.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-[10px] font-medium text-muted-foreground px-1">Zone </p>
{orphanLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
return (
<div
key={layer.layer_id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
)}
onClick={() => onLayerChange(layer.layer_id)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium">{layer.layer_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]"></Badge>
<span className="text-[10px] text-muted-foreground">{layer.component_count}</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-6 w-6 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p> | </p>
{/* Zone 생성 드래그 영역 */}
<div className="border-t px-3 py-2 space-y-1">
<div
className="flex cursor-grab items-center gap-2 rounded border border-dashed border-amber-300 bg-amber-50/50 px-2 py-1.5 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-950/20 dark:text-amber-400"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" }));
e.dataTransfer.effectAllowed = "copy";
}}
>
<GripVertical className="h-3.5 w-3.5" />
<Box className="h-3.5 w-3.5" />
<span> ( )</span>
</div>
<p className="text-[10px] text-muted-foreground">
Zone을 , Zone
</p>
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -235,6 +235,57 @@ export const screenApi = {
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
},
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
// Zone 목록 조회
getScreenZones: async (screenId: number): Promise<any[]> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/zones`);
return response.data.data || [];
},
// Zone 생성
createZone: async (screenId: number, zoneData: {
zone_name?: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id?: string;
trigger_operator?: string;
}): Promise<any> => {
const response = await apiClient.post(`/screen-management/screens/${screenId}/zones`, zoneData);
return response.data.data;
},
// Zone 업데이트 (위치/크기/트리거)
updateZone: async (zoneId: number, updates: {
zone_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
trigger_component_id?: string;
trigger_operator?: string;
}): Promise<void> => {
await apiClient.put(`/screen-management/zones/${zoneId}`, updates);
},
// Zone 삭제
deleteZone: async (zoneId: number): Promise<void> => {
await apiClient.delete(`/screen-management/zones/${zoneId}`);
},
// Zone에 레이어 추가
addLayerToZone: async (screenId: number, zoneId: number, conditionValue: string, layerName?: string): Promise<{ layerId: number }> => {
const response = await apiClient.post(`/screen-management/screens/${screenId}/zones/${zoneId}/layers`, {
conditionValue,
layerName,
});
return response.data.data;
},
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================

View File

@@ -878,7 +878,7 @@ export interface LayerOverlayConfig {
/**
* 조건부 레이어 표시 영역
* 조건 미충족 시 이 영역이 사라지고, 아래 컴포넌트들이 위로 이동
* @deprecated Zone 기반으로 전환 - ConditionalZone.x/y/width/height 사용
*/
export interface DisplayRegion {
x: number;
@@ -887,6 +887,27 @@ export interface DisplayRegion {
height: number;
}
/**
* 조건부 영역(Zone)
* - 기본 레이어 캔버스에서 영역을 정의하고, 여러 레이어를 할당
* - Zone 내에서는 항상 1개 레이어만 활성 (exclusive)
* - Zone 단위로 접힘/펼침 판단 (Y 오프셋 계산 단순화)
*/
export interface ConditionalZone {
zone_id: number;
screen_id: number;
company_code: string;
zone_name: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id: string | null; // 기본 레이어의 트리거 컴포넌트 ID
trigger_operator: string; // eq, neq, in
created_at?: string;
updated_at?: string;
}
/**
* 레이어 정의
*/
@@ -898,10 +919,14 @@ export interface LayerDefinition {
isVisible: boolean; // 초기 표시 여부
isLocked: boolean; // 편집 잠금 여부
// 조건부 표시 로직
// 조건부 표시 로직 (레거시 - Zone 미사용 레이어용)
condition?: LayerCondition;
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
// Zone 기반 조건부 설정 (신규)
zoneId?: number; // 소속 조건부 영역 ID
conditionValue?: string; // Zone 트리거 매칭 값
// 조건부 레이어 표시 영역 (레거시 호환 - Zone으로 대체됨)
displayRegion?: DisplayRegion;
// 모달/드로어 전용 설정