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:
commit
e29eaceeff
|
|
@ -1443,13 +1443,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
||||||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
||||||
*/
|
*/
|
||||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
||||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
// 1. code_category에서 menu_objid를 NULL로 설정
|
||||||
await query(
|
|
||||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
|
||||||
[menuObjid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||||
[menuObjid]
|
[menuObjid]
|
||||||
|
|
|
||||||
|
|
@ -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 레이아웃 관리 (모바일/태블릿)
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ import {
|
||||||
getLayerLayout,
|
getLayerLayout,
|
||||||
deleteLayer,
|
deleteLayer,
|
||||||
updateLayerCondition,
|
updateLayerCondition,
|
||||||
|
getScreenZones,
|
||||||
|
createZone,
|
||||||
|
updateZone,
|
||||||
|
deleteZone,
|
||||||
|
addLayerToZone,
|
||||||
} from "../controllers/screenManagementController";
|
} from "../controllers/screenManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특
|
||||||
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
|
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
|
||||||
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
|
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 레이아웃 관리 (모바일/태블릿)
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
|
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
|
||||||
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장
|
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장
|
||||||
|
|
|
||||||
|
|
@ -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 레이아웃 관리 (모바일/태블릿)
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
// 🆕 레이어 시스템 지원
|
// 🆕 레이어 시스템 지원
|
||||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||||
|
// 🆕 조건부 영역(Zone) 목록
|
||||||
|
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||||
|
|
||||||
// 편집 모달 상태
|
// 편집 모달 상태
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
|
@ -208,15 +210,18 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 🆕 조건부 레이어 로드 (기본 레이어 외 모든 레이어 로드)
|
// 🆕 조건부 레이어 + Zone 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConditionalLayers = async () => {
|
const loadConditionalLayersAndZones = async () => {
|
||||||
if (!screenId || !layout) return;
|
if (!screenId || !layout) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 모든 레이어 목록 조회
|
// 1. Zone 로드
|
||||||
|
const loadedZones = await screenApi.getScreenZones(screenId);
|
||||||
|
setZones(loadedZones);
|
||||||
|
|
||||||
|
// 2. 모든 레이어 목록 조회
|
||||||
const allLayers = await screenApi.getScreenLayers(screenId);
|
const allLayers = await screenApi.getScreenLayers(screenId);
|
||||||
// layer_id > 1인 레이어만 (기본 레이어 제외)
|
|
||||||
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
|
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
|
||||||
|
|
||||||
if (nonBaseLayers.length === 0) {
|
if (nonBaseLayers.length === 0) {
|
||||||
|
|
@ -224,7 +229,7 @@ function ScreenViewPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 각 레이어의 레이아웃 데이터 로드
|
// 3. 각 레이어의 레이아웃 데이터 로드
|
||||||
const layerDefinitions: LayerDefinition[] = [];
|
const layerDefinitions: LayerDefinition[] = [];
|
||||||
|
|
||||||
for (const layerInfo of nonBaseLayers) {
|
for (const layerInfo of nonBaseLayers) {
|
||||||
|
|
@ -233,12 +238,9 @@ function ScreenViewPage() {
|
||||||
const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {};
|
const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {};
|
||||||
|
|
||||||
// 레이어 컴포넌트 변환 (V2 → Legacy)
|
// 레이어 컴포넌트 변환 (V2 → Legacy)
|
||||||
// getLayerLayout 응답: { ...layout_data, layerId, layerName, conditionConfig }
|
|
||||||
// layout_data가 spread 되므로 components는 최상위에 있음
|
|
||||||
let layerComponents: any[] = [];
|
let layerComponents: any[] = [];
|
||||||
const rawComponents = layerData?.components;
|
const rawComponents = layerData?.components;
|
||||||
if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) {
|
if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) {
|
||||||
// V2 컴포넌트를 Legacy 형식으로 변환
|
|
||||||
const tempV2 = {
|
const tempV2 = {
|
||||||
version: "2.0" as const,
|
version: "2.0" as const,
|
||||||
components: rawComponents,
|
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 생성
|
// LayerDefinition 생성
|
||||||
const layerDef: LayerDefinition = {
|
const layerDef: LayerDefinition = {
|
||||||
id: String(layerInfo.layer_id),
|
id: String(layerInfo.layer_id),
|
||||||
name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||||
type: "conditional",
|
type: "conditional",
|
||||||
zIndex: layerInfo.layer_id * 10,
|
zIndex: layerInfo.layer_id * 10,
|
||||||
isVisible: false, // 조건 충족 시에만 표시
|
isVisible: false,
|
||||||
isLocked: 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,
|
targetComponentId: condConfig.targetComponentId,
|
||||||
operator: condConfig.operator || "eq",
|
operator: condConfig.operator || "eq",
|
||||||
value: condConfig.value,
|
value: condConfig.value,
|
||||||
} : undefined,
|
} : 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,
|
components: layerComponents,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -277,16 +292,16 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
|
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,
|
componentCount: l.components.length,
|
||||||
})));
|
})));
|
||||||
setConditionalLayers(layerDefinitions);
|
setConditionalLayers(layerDefinitions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이어 로드 실패:", error);
|
console.error("레이어/Zone 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadConditionalLayers();
|
loadConditionalLayersAndZones();
|
||||||
}, [screenId, layout]);
|
}, [screenId, layout]);
|
||||||
|
|
||||||
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
|
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
|
||||||
|
|
@ -760,20 +775,20 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 조건부 레이어 displayRegion 기반 높이 조정
|
// 🆕 Zone 기반 높이 조정
|
||||||
// 기본 레이어 컴포넌트는 displayRegion 포함한 위치에 배치되므로,
|
// Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산
|
||||||
// 비활성(빈 영역) 시 아래 컴포넌트를 위로 당겨 빈 공간 제거
|
// Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단)
|
||||||
for (const layer of conditionalLayers) {
|
for (const zone of zones) {
|
||||||
if (!layer.displayRegion) continue;
|
const zoneBottom = zone.y + zone.height;
|
||||||
const region = layer.displayRegion;
|
// 컴포넌트가 Zone 하단보다 아래에 있는 경우
|
||||||
const regionBottom = region.y + region.height;
|
if (component.position.y >= zoneBottom) {
|
||||||
const isActive = activeLayerIds.includes(layer.id);
|
// Zone에 매칭되는 활성 레이어가 있는지 확인
|
||||||
|
const hasActiveLayer = conditionalLayers.some(
|
||||||
// 컴포넌트가 조건부 영역 하단보다 아래에 있는 경우
|
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id)
|
||||||
if (component.position.y >= regionBottom) {
|
);
|
||||||
if (!isActive) {
|
if (!hasActiveLayer) {
|
||||||
// 비활성: 영역 높이만큼 위로 당김 (빈 공간 제거)
|
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
|
||||||
totalHeightAdjustment -= region.height;
|
totalHeightAdjustment -= zone.height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1099,12 +1114,16 @@ function ScreenViewPage() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
{/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */}
|
||||||
{conditionalLayers.map((layer) => {
|
{conditionalLayers.map((layer) => {
|
||||||
const isActive = activeLayerIds.includes(layer.id);
|
const isActive = activeLayerIds.includes(layer.id);
|
||||||
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1117,7 +1136,7 @@ function ScreenViewPage() {
|
||||||
width: region ? `${region.width}px` : "100%",
|
width: region ? `${region.width}px` : "100%",
|
||||||
height: region ? `${region.height}px` : "auto",
|
height: region ? `${region.height}px` : "auto",
|
||||||
zIndex: layer.zIndex || 20,
|
zIndex: layer.zIndex || 20,
|
||||||
overflow: "hidden", // 영역 밖 컴포넌트 숨김
|
overflow: "hidden",
|
||||||
transition: "none",
|
transition: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -284,59 +284,38 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
});
|
});
|
||||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||||
|
|
||||||
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
|
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
|
||||||
const conditionalRegionInfos = useMemo(() => {
|
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
|
||||||
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) 처리
|
|
||||||
const calculateYOffset = useCallback((componentY: number): number => {
|
const calculateYOffset = useCallback((componentY: number): number => {
|
||||||
if (collapsedRegions.length === 0) return 0;
|
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
|
||||||
|
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
|
||||||
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
|
|
||||||
const relevantRegions = collapsedRegions.filter(
|
for (const layer of layers) {
|
||||||
(region) => region.y + region.height <= componentY
|
if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue;
|
||||||
);
|
const zid = layer.zoneId;
|
||||||
|
if (!zoneMap.has(zid)) {
|
||||||
if (relevantRegions.length === 0) return 0;
|
zoneMap.set(zid, {
|
||||||
|
y: layer.displayRegion.y,
|
||||||
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
|
height: layer.displayRegion.height,
|
||||||
const mergedRegions: { y: number; bottom: number }[] = [];
|
hasActive: false,
|
||||||
for (const region of relevantRegions) {
|
});
|
||||||
const bottom = region.y + region.height;
|
}
|
||||||
if (mergedRegions.length === 0) {
|
if (activeLayerIds.includes(layer.id)) {
|
||||||
mergedRegions.push({ y: region.y, bottom });
|
zoneMap.get(zid)!.hasActive = true;
|
||||||
} 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 병합된 영역들의 높이 합산
|
let totalOffset = 0;
|
||||||
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
|
for (const [, zone] of zoneMap) {
|
||||||
}, [collapsedRegions]);
|
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
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
|
|
@ -2386,7 +2406,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
style={{ zIndex: layer.zIndex }}
|
style={{ zIndex: layer.zIndex }}
|
||||||
>
|
>
|
||||||
{layer.components.map((comp) => {
|
{layer.components.map((comp) => {
|
||||||
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
|
|
||||||
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||||
const adjustedY = comp.position.y - yOffset;
|
const adjustedY = comp.position.y - yOffset;
|
||||||
|
|
||||||
|
|
@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
|
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitPanelProvider>
|
<SplitPanelProvider>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,13 +13,13 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Zap,
|
Zap,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Box,
|
||||||
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { LayerConditionPanel } from "./LayerConditionPanel";
|
import { ComponentData, ConditionalZone } from "@/types/screen-management";
|
||||||
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
|
|
||||||
|
|
||||||
// DB 레이어 타입
|
// DB 레이어 타입
|
||||||
interface DBLayer {
|
interface DBLayer {
|
||||||
|
|
@ -34,6 +35,8 @@ interface LayerManagerPanelProps {
|
||||||
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
|
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
|
||||||
onLayerChange: (layerId: number) => void; // 레이어 전환
|
onLayerChange: (layerId: number) => void; // 레이어 전환
|
||||||
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
|
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
|
||||||
|
zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달)
|
||||||
|
onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
|
|
@ -41,13 +44,23 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
activeLayerId,
|
activeLayerId,
|
||||||
onLayerChange,
|
onLayerChange,
|
||||||
components = [],
|
components = [],
|
||||||
|
zones: externalZones,
|
||||||
|
onZonesChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [layers, setLayers] = useState<DBLayer[]>([]);
|
const [layers, setLayers] = useState<DBLayer[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
|
// 펼침/접힘 상태: zone_id별
|
||||||
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
|
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 [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
|
||||||
|
|
||||||
|
const zones = externalZones || [];
|
||||||
|
|
||||||
// 레이어 목록 로드
|
// 레이어 목록 로드
|
||||||
const loadLayers = useCallback(async () => {
|
const loadLayers = useCallback(async () => {
|
||||||
if (!screenId) return;
|
if (!screenId) return;
|
||||||
|
|
@ -62,60 +75,60 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
|
// 기본 레이어 컴포넌트 로드
|
||||||
const loadBaseLayerComponents = useCallback(async () => {
|
const loadBaseLayerComponents = useCallback(async () => {
|
||||||
if (!screenId) return;
|
if (!screenId) return;
|
||||||
try {
|
try {
|
||||||
const data = await screenApi.getLayerLayout(screenId, 1);
|
const data = await screenApi.getLayerLayout(screenId, 1);
|
||||||
if (data && data.components) {
|
if (data?.components) {
|
||||||
const legacy = convertV2ToLegacy(data);
|
setBaseLayerComponents(data.components as ComponentData[]);
|
||||||
if (legacy) {
|
|
||||||
setBaseLayerComponents(legacy.components as ComponentData[]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setBaseLayerComponents([]);
|
|
||||||
} catch {
|
} catch {
|
||||||
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
|
|
||||||
setBaseLayerComponents(components);
|
setBaseLayerComponents(components);
|
||||||
}
|
}
|
||||||
}, [screenId, components]);
|
}, [screenId, components]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLayers();
|
loadLayers();
|
||||||
}, [loadLayers]);
|
loadBaseLayerComponents();
|
||||||
|
}, [loadLayers, loadBaseLayerComponents]);
|
||||||
|
|
||||||
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
|
// Zone별 레이어 그룹핑
|
||||||
useEffect(() => {
|
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
|
||||||
if (conditionOpenLayerId !== null) {
|
return layers.filter(l => {
|
||||||
loadBaseLayerComponents();
|
const cc = l.condition_config;
|
||||||
}
|
return cc && cc.zone_id === zoneId;
|
||||||
}, [conditionOpenLayerId, loadBaseLayerComponents]);
|
});
|
||||||
|
}, [layers]);
|
||||||
|
|
||||||
// 새 레이어 추가
|
// Zone에 속하지 않는 조건부 레이어 (레거시)
|
||||||
const handleAddLayer = useCallback(async () => {
|
const orphanLayers = layers.filter(l => {
|
||||||
if (!screenId) return;
|
if (l.layer_id === 1) return false;
|
||||||
// 다음 layer_id 계산
|
const cc = l.condition_config;
|
||||||
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
|
return !cc || !cc.zone_id;
|
||||||
const newLayerId = maxLayerId + 1;
|
});
|
||||||
|
|
||||||
|
// 기본 레이어
|
||||||
|
const baseLayer = layers.find(l => l.layer_id === 1);
|
||||||
|
|
||||||
|
// Zone에 레이어 추가
|
||||||
|
const handleAddLayerToZone = useCallback(async (zoneId: number) => {
|
||||||
|
if (!screenId || !newConditionValue.trim()) return;
|
||||||
try {
|
try {
|
||||||
// 빈 레이아웃으로 새 레이어 저장
|
const result = await screenApi.addLayerToZone(
|
||||||
await screenApi.saveLayoutV2(screenId, {
|
screenId, zoneId, newConditionValue.trim(),
|
||||||
version: "2.0",
|
`레이어 (${newConditionValue.trim()})`,
|
||||||
components: [],
|
);
|
||||||
layerId: newLayerId,
|
toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`);
|
||||||
layerName: `조건부 레이어 ${newLayerId}`,
|
setAddingToZoneId(null);
|
||||||
});
|
setNewConditionValue("");
|
||||||
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
|
|
||||||
await loadLayers();
|
await loadLayers();
|
||||||
// 새 레이어로 전환
|
onLayerChange(result.layerId);
|
||||||
onLayerChange(newLayerId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이어 추가 실패:", error);
|
console.error("Zone 레이어 추가 실패:", error);
|
||||||
toast.error("레이어 추가에 실패했습니다.");
|
toast.error("레이어 추가에 실패했습니다.");
|
||||||
}
|
}
|
||||||
}, [screenId, layers, loadLayers, onLayerChange]);
|
}, [screenId, newConditionValue, loadLayers, onLayerChange]);
|
||||||
|
|
||||||
// 레이어 삭제
|
// 레이어 삭제
|
||||||
const handleDeleteLayer = useCallback(async (layerId: number) => {
|
const handleDeleteLayer = useCallback(async (layerId: number) => {
|
||||||
|
|
@ -124,42 +137,59 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
await screenApi.deleteLayer(screenId, layerId);
|
await screenApi.deleteLayer(screenId, layerId);
|
||||||
toast.success("레이어가 삭제되었습니다.");
|
toast.success("레이어가 삭제되었습니다.");
|
||||||
await loadLayers();
|
await loadLayers();
|
||||||
// 기본 레이어로 전환
|
if (activeLayerId === layerId) onLayerChange(1);
|
||||||
if (activeLayerId === layerId) {
|
|
||||||
onLayerChange(1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이어 삭제 실패:", error);
|
console.error("레이어 삭제 실패:", error);
|
||||||
toast.error("레이어 삭제에 실패했습니다.");
|
toast.error("레이어 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
|
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
|
||||||
|
|
||||||
// 조건 업데이트 (기존 condition_config의 displayRegion 보존)
|
// Zone 삭제
|
||||||
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
|
const handleDeleteZone = useCallback(async (zoneId: number) => {
|
||||||
if (!screenId) return;
|
if (!screenId) return;
|
||||||
try {
|
try {
|
||||||
// 기존 condition_config를 가져와서 displayRegion 보존
|
await screenApi.deleteZone(zoneId);
|
||||||
const layerData = await screenApi.getLayerLayout(screenId, layerId);
|
toast.success("조건부 영역이 삭제되었습니다.");
|
||||||
const existingCondition = layerData?.conditionConfig || {};
|
// Zone 목록 새로고침
|
||||||
const displayRegion = existingCondition.displayRegion;
|
const loadedZones = await screenApi.getScreenZones(screenId);
|
||||||
|
onZonesChange?.(loadedZones);
|
||||||
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();
|
await loadLayers();
|
||||||
|
onLayerChange(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("조건 업데이트 실패:", error);
|
console.error("Zone 삭제 실패:", error);
|
||||||
toast.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 (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
|
|
@ -172,19 +202,9 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
{layers.length}
|
{layers.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 레이어 목록 */}
|
{/* 레이어 + Zone 목록 */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -192,146 +212,266 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<span className="text-sm">로딩 중...</span>
|
<span className="text-sm">로딩 중...</span>
|
||||||
</div>
|
</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()
|
{baseLayer && (
|
||||||
.map((layer) => {
|
<div
|
||||||
const isActive = activeLayerId === layer.layer_id;
|
className={cn(
|
||||||
const isBase = layer.layer_id === 1;
|
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||||
const hasCondition = !!layer.condition_config;
|
activeLayerId === 1
|
||||||
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
|
? "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 (
|
return (
|
||||||
<div key={layer.layer_id} className="space-y-0">
|
<div key={`zone-${zone.zone_id}`} className="space-y-0">
|
||||||
|
{/* Zone 헤더 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||||
isActive
|
"border-amber-200 bg-amber-50/50 hover:bg-amber-100/50 dark:border-amber-800 dark:bg-amber-950/20",
|
||||||
? "border-primary bg-primary/5 shadow-sm"
|
isExpanded && "rounded-b-none border-b-0",
|
||||||
: "border-transparent hover:bg-muted",
|
|
||||||
isConditionOpen && "rounded-b-none border-b-0",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => onLayerChange(layer.layer_id)}
|
onClick={() => toggleZone(zone.zone_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";
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn(
|
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] border-amber-300 text-amber-700">
|
||||||
"shrink-0 rounded p-1",
|
Zone
|
||||||
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>
|
</Badge>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{layer.component_count}개 컴포넌트
|
{zoneLayers.length}개 레이어 | {zone.width}x{zone.height}
|
||||||
</span>
|
</span>
|
||||||
{hasCondition && (
|
{zone.trigger_component_id && (
|
||||||
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
|
<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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* Zone 액션 버튼 */}
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
{!isBase && (
|
<Button
|
||||||
<Button
|
variant="ghost" size="icon"
|
||||||
variant="ghost"
|
className="h-6 w-6 text-amber-600 hover:text-amber-800"
|
||||||
size="icon"
|
title="트리거 설정"
|
||||||
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
|
onClick={(e) => { e.stopPropagation(); setTriggerEditZoneId(isTriggerEdit ? null : zone.zone_id); }}
|
||||||
title="조건 설정"
|
>
|
||||||
onClick={(e) => {
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
e.stopPropagation();
|
</Button>
|
||||||
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
|
<Button
|
||||||
}}
|
variant="ghost" size="icon"
|
||||||
>
|
className="h-6 w-6 hover:text-destructive"
|
||||||
{isConditionOpen ? (
|
title="Zone 삭제"
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
onClick={(e) => { e.stopPropagation(); handleDeleteZone(zone.zone_id); }}
|
||||||
) : (
|
>
|
||||||
<ChevronRight className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
)}
|
</Button>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 조건 설정 패널 */}
|
{/* 펼쳐진 Zone 내용 */}
|
||||||
{!isBase && isConditionOpen && (
|
{isExpanded && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"rounded-b-md border border-t-0 bg-muted/30",
|
"rounded-b-md border border-t-0 border-amber-200 bg-amber-50/20 p-2 space-y-1",
|
||||||
isActive ? "border-primary" : "border-border",
|
"dark:border-amber-800 dark:bg-amber-950/10",
|
||||||
)}>
|
)}>
|
||||||
<LayerConditionPanel
|
{/* 트리거 설정 패널 */}
|
||||||
layer={{
|
{isTriggerEdit && (
|
||||||
id: String(layer.layer_id),
|
<div className="mb-2 rounded border bg-background p-2 space-y-2">
|
||||||
name: layer.layer_name,
|
<p className="text-[10px] font-medium text-muted-foreground">트리거 컴포넌트 선택</p>
|
||||||
type: "conditional",
|
{triggerableComponents.length === 0 ? (
|
||||||
zIndex: layer.layer_id,
|
<p className="text-[10px] text-muted-foreground">기본 레이어에 Select/Combobox/Radio 컴포넌트가 없습니다.</p>
|
||||||
isVisible: true,
|
) : (
|
||||||
isLocked: false,
|
<div className="space-y-1">
|
||||||
condition: layer.condition_config || undefined,
|
{triggerableComponents.map(c => (
|
||||||
components: [],
|
<button
|
||||||
}}
|
key={c.id}
|
||||||
components={baseLayerComponents}
|
className={cn(
|
||||||
baseLayerComponents={baseLayerComponents}
|
"w-full text-left rounded px-2 py-1 text-[11px] transition-colors",
|
||||||
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
|
zone.trigger_component_id === c.id
|
||||||
onUpdateDisplayRegion={() => {}}
|
? "bg-primary/10 text-primary font-medium"
|
||||||
onClose={() => setConditionOpenLayerId(null)}
|
: "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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* 도움말 */}
|
{/* Zone 생성 드래그 영역 */}
|
||||||
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
<div className="border-t px-3 py-2 space-y-1">
|
||||||
<p>레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Database, Cog } from "lucide-react";
|
import { Database, Cog } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -133,6 +133,9 @@ interface ScreenDesignerProps {
|
||||||
selectedScreen: ScreenDefinition | null;
|
selectedScreen: ScreenDefinition | null;
|
||||||
onBackToList: () => void;
|
onBackToList: () => void;
|
||||||
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||||
|
// POP 모드 지원
|
||||||
|
isPop?: boolean;
|
||||||
|
defaultDevicePreview?: "mobile" | "tablet";
|
||||||
}
|
}
|
||||||
|
|
||||||
import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
|
import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
|
||||||
|
|
@ -159,7 +162,15 @@ const panelConfigs: PanelConfig[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
|
export default function ScreenDesigner({
|
||||||
|
selectedScreen,
|
||||||
|
onBackToList,
|
||||||
|
onScreenUpdate,
|
||||||
|
isPop = false,
|
||||||
|
defaultDevicePreview = "tablet"
|
||||||
|
}: ScreenDesignerProps) {
|
||||||
|
// POP 모드 여부에 따른 API 분기
|
||||||
|
const USE_POP_API = isPop;
|
||||||
const [layout, setLayout] = useState<LayoutData>({
|
const [layout, setLayout] = useState<LayoutData>({
|
||||||
components: [],
|
components: [],
|
||||||
gridSettings: {
|
gridSettings: {
|
||||||
|
|
@ -501,25 +512,76 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
return lines;
|
return lines;
|
||||||
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||||
|
|
||||||
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
|
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
|
||||||
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
|
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
|
||||||
|
const activeLayerIdRef = useRef<number>(1);
|
||||||
|
const setActiveLayerIdWithRef = useCallback((id: number) => {
|
||||||
|
setActiveLayerIdLocal(id);
|
||||||
|
activeLayerIdRef.current = id;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
|
// 🆕 좌측 패널 탭 상태 관리
|
||||||
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
|
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
|
||||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
|
||||||
const visibleComponents = useMemo(() => {
|
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
|
||||||
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
|
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||||
if (!activeLayerId) {
|
|
||||||
return layout.components;
|
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
|
||||||
|
const [regionDrag, setRegionDrag] = useState<{
|
||||||
|
isDrawing: boolean; // 새 영역 그리기 모드
|
||||||
|
isDragging: boolean; // 기존 영역 이동 모드
|
||||||
|
isResizing: boolean; // 기존 영역 리사이즈 모드
|
||||||
|
targetLayerId: string | null; // 대상 Zone ID (문자열)
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
currentX: number;
|
||||||
|
currentY: number;
|
||||||
|
resizeHandle: string | null; // 리사이즈 핸들 위치
|
||||||
|
originalRegion: { x: number; y: number; width: number; height: number } | null;
|
||||||
|
}>({
|
||||||
|
isDrawing: false,
|
||||||
|
isDragging: false,
|
||||||
|
isResizing: false,
|
||||||
|
targetLayerId: null,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
currentX: 0,
|
||||||
|
currentY: 0,
|
||||||
|
resizeHandle: null,
|
||||||
|
originalRegion: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||||
|
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
||||||
|
|
||||||
|
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
||||||
|
setActiveLayerZone(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
|
||||||
|
const findZone = async () => {
|
||||||
|
try {
|
||||||
|
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId);
|
||||||
|
const zoneId = layerData?.conditionConfig?.zone_id;
|
||||||
|
if (zoneId) {
|
||||||
|
const zone = zones.find(z => z.zone_id === zoneId);
|
||||||
|
setActiveLayerZone(zone || null);
|
||||||
|
} else {
|
||||||
|
setActiveLayerZone(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setActiveLayerZone(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
findZone();
|
||||||
|
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
||||||
|
|
||||||
// 활성 레이어에 속한 컴포넌트만 필터링
|
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||||
return layout.components.filter((comp) => {
|
const visibleComponents = useMemo(() => {
|
||||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
return layout.components;
|
||||||
const compLayerId = comp.layerId || "default-layer";
|
}, [layout.components]);
|
||||||
return compLayerId === activeLayerId;
|
|
||||||
});
|
|
||||||
}, [layout.components, activeLayerId]);
|
|
||||||
|
|
||||||
// 이미 배치된 컬럼 목록 계산
|
// 이미 배치된 컬럼 목록 계산
|
||||||
const placedColumns = useMemo(() => {
|
const placedColumns = useMemo(() => {
|
||||||
|
|
@ -1448,9 +1510,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
|
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
|
||||||
}
|
}
|
||||||
|
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
let response: any;
|
let response: any;
|
||||||
if (USE_V2_API) {
|
if (USE_POP_API) {
|
||||||
|
// POP 모드: screen_layouts_pop 테이블 사용
|
||||||
|
const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
response = popResponse ? convertV2ToLegacy(popResponse) : null;
|
||||||
|
console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트");
|
||||||
|
} else if (USE_V2_API) {
|
||||||
|
// 데스크톱 V2 모드: screen_layouts_v2 테이블 사용
|
||||||
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
|
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
|
||||||
|
|
||||||
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인
|
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인
|
||||||
|
|
@ -1533,6 +1601,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
// 파일 컴포넌트 데이터 복원 (비동기)
|
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||||
|
|
||||||
|
// 🆕 조건부 영역(Zone) 로드
|
||||||
|
try {
|
||||||
|
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||||
|
setZones(loadedZones);
|
||||||
|
} catch { /* Zone 로드 실패 무시 */ }
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("레이아웃 로드 실패:", error);
|
// console.error("레이아웃 로드 실패:", error);
|
||||||
|
|
@ -1970,37 +2044,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
|
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
|
||||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||||
|
|
||||||
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
|
|
||||||
const updatedLayers = layout.layers?.map((layer) => ({
|
|
||||||
...layer,
|
|
||||||
components: layer.components.map((comp) => {
|
|
||||||
// 분할 패널 업데이트 로직 적용
|
|
||||||
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
|
|
||||||
return updatedComp || comp;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const layoutWithResolution = {
|
const layoutWithResolution = {
|
||||||
...layout,
|
...layout,
|
||||||
components: updatedComponents,
|
components: updatedComponents,
|
||||||
layers: updatedLayers, // 🆕 레이어 정보 포함
|
|
||||||
screenResolution: screenResolution,
|
screenResolution: screenResolution,
|
||||||
mainTableName: currentMainTableName, // 화면의 기본 테이블
|
mainTableName: currentMainTableName, // 화면의 기본 테이블
|
||||||
};
|
};
|
||||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
|
||||||
const buttonComponents = layoutWithResolution.components.filter(
|
|
||||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
|
||||||
);
|
|
||||||
// 💾 저장 로그 (디버그 완료 - 간소화)
|
|
||||||
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
|
|
||||||
// 분할 패널 디버그 로그 (주석 처리)
|
|
||||||
|
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
if (USE_V2_API) {
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
|
if (USE_POP_API) {
|
||||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
// POP 모드: screen_layouts_pop 테이블에 저장
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
} else if (USE_V2_API) {
|
||||||
|
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||||
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
|
...v2Layout,
|
||||||
|
layerId: currentLayerId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
}
|
}
|
||||||
|
|
@ -2023,6 +2085,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]);
|
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]);
|
||||||
|
|
||||||
|
// POP 미리보기 핸들러 (새 창에서 열기)
|
||||||
|
const handlePopPreview = useCallback(() => {
|
||||||
|
if (!selectedScreen?.screenId) {
|
||||||
|
toast.error("화면 정보가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceType = defaultDevicePreview || "tablet";
|
||||||
|
const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`;
|
||||||
|
window.open(previewUrl, "_blank", "width=800,height=900");
|
||||||
|
}, [selectedScreen, defaultDevicePreview]);
|
||||||
|
|
||||||
// 다국어 자동 생성 핸들러
|
// 다국어 자동 생성 핸들러
|
||||||
const handleGenerateMultilang = useCallback(async () => {
|
const handleGenerateMultilang = useCallback(async () => {
|
||||||
if (!selectedScreen?.screenId) {
|
if (!selectedScreen?.screenId) {
|
||||||
|
|
@ -2101,8 +2175,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
// 자동 저장 (매핑 정보가 손실되지 않도록)
|
// 자동 저장 (매핑 정보가 손실되지 않도록)
|
||||||
try {
|
try {
|
||||||
if (USE_V2_API) {
|
const v2Layout = convertLegacyToV2(updatedLayout);
|
||||||
const v2Layout = convertLegacyToV2(updatedLayout);
|
if (USE_POP_API) {
|
||||||
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
|
} else if (USE_V2_API) {
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
||||||
|
|
@ -2522,10 +2598,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 현재 활성 레이어에 컴포넌트 추가
|
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
|
||||||
const componentsWithLayerId = newComponents.map((comp) => ({
|
const componentsWithLayerId = newComponents.map((comp) => ({
|
||||||
...comp,
|
...comp,
|
||||||
layerId: activeLayerId || "default-layer",
|
layerId: activeLayerIdRef.current || 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 레이아웃에 새 컴포넌트들 추가
|
// 레이아웃에 새 컴포넌트들 추가
|
||||||
|
|
@ -2544,7 +2620,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
[layout, selectedScreen, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 레이아웃 드래그 처리
|
// 레이아웃 드래그 처리
|
||||||
|
|
@ -2598,7 +2674,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
label: layoutData.label,
|
label: layoutData.label,
|
||||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||||
dropZoneConfig: layoutData.dropZoneConfig,
|
dropZoneConfig: layoutData.dropZoneConfig,
|
||||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
} as ComponentData;
|
} as ComponentData;
|
||||||
|
|
||||||
// 레이아웃에 새 컴포넌트 추가
|
// 레이아웃에 새 컴포넌트 추가
|
||||||
|
|
@ -2615,7 +2691,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
|
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||||
);
|
);
|
||||||
|
|
||||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||||
|
|
@ -3007,9 +3083,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 displayRegion 크기 기준)
|
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준)
|
||||||
const currentLayerId = activeLayerIdRef.current || 1;
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
const activeLayerRegion = currentLayerId > 1 ? layerRegions[currentLayerId] : null;
|
const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null;
|
||||||
const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width;
|
const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width;
|
||||||
const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height;
|
const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height;
|
||||||
const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth));
|
const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth));
|
||||||
|
|
@ -3210,7 +3286,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
position: snappedPosition,
|
position: snappedPosition,
|
||||||
size: componentSize,
|
size: componentSize,
|
||||||
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
||||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||||
webType: component.webType, // 웹타입 정보 추가
|
webType: component.webType, // 웹타입 정보 추가
|
||||||
|
|
@ -3244,7 +3320,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||||
},
|
},
|
||||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
[layout, selectedScreen, saveToHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 앤 드롭 처리
|
// 드래그 앤 드롭 처리
|
||||||
|
|
@ -3253,7 +3329,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
(e: React.DragEvent) => {
|
async (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const dragData = e.dataTransfer.getData("application/json");
|
const dragData = e.dataTransfer.getData("application/json");
|
||||||
|
|
@ -3285,6 +3361,31 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장
|
||||||
|
if (parsedData.type === "create-zone" && selectedScreen?.screenId) {
|
||||||
|
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!canvasRect) return;
|
||||||
|
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
|
||||||
|
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
|
||||||
|
try {
|
||||||
|
await screenApi.createZone(selectedScreen.screenId, {
|
||||||
|
zone_name: "조건부 영역",
|
||||||
|
x: Math.max(0, dropX - 400),
|
||||||
|
y: Math.max(0, dropY),
|
||||||
|
width: Math.min(800, screenResolution.width),
|
||||||
|
height: 200,
|
||||||
|
});
|
||||||
|
// Zone 목록 새로고침
|
||||||
|
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||||
|
setZones(loadedZones);
|
||||||
|
toast.success("조건부 영역이 생성되었습니다.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Zone 생성 실패:", error);
|
||||||
|
toast.error("조건부 영역 생성에 실패했습니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 기존 테이블/컬럼 드래그 처리
|
// 기존 테이블/컬럼 드래그 처리
|
||||||
const { type, table, column } = parsedData;
|
const { type, table, column } = parsedData;
|
||||||
|
|
||||||
|
|
@ -3616,7 +3717,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: 300, height: 200 },
|
size: { width: 300, height: 200 },
|
||||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: true,
|
labelDisplay: true,
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
|
|
@ -3867,7 +3968,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
|
|
@ -3934,7 +4035,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||||
...(column.widgetType === "code" &&
|
...(column.widgetType === "code" &&
|
||||||
column.codeCategory && {
|
column.codeCategory && {
|
||||||
|
|
@ -4192,9 +4293,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
const rawX = relativeMouseX - dragState.grabOffset.x;
|
const rawX = relativeMouseX - dragState.grabOffset.x;
|
||||||
const rawY = relativeMouseY - dragState.grabOffset.y;
|
const rawY = relativeMouseY - dragState.grabOffset.y;
|
||||||
|
|
||||||
// 조건부 레이어 편집 시 displayRegion 크기 기준 경계 제한
|
// 조건부 레이어 편집 시 Zone 크기 기준 경계 제한
|
||||||
const dragLayerId = activeLayerIdRef.current || 1;
|
const dragLayerId = activeLayerIdRef.current || 1;
|
||||||
const dragLayerRegion = dragLayerId > 1 ? layerRegions[dragLayerId] : null;
|
const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null;
|
||||||
const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width;
|
const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width;
|
||||||
const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height;
|
const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height;
|
||||||
|
|
||||||
|
|
@ -4763,7 +4864,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
z: clipComponent.position.z || 1,
|
z: clipComponent.position.z || 1,
|
||||||
} as Position,
|
} as Position,
|
||||||
parentId: undefined, // 붙여넣기 시 부모 관계 해제
|
parentId: undefined, // 붙여넣기 시 부모 관계 해제
|
||||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
|
||||||
};
|
};
|
||||||
newComponents.push(newComponent);
|
newComponents.push(newComponent);
|
||||||
});
|
});
|
||||||
|
|
@ -4784,7 +4885,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
||||||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||||
}, [clipboard, layout, saveToHistory, activeLayerId]);
|
}, [clipboard, layout, saveToHistory]);
|
||||||
|
|
||||||
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
||||||
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
||||||
|
|
@ -5488,9 +5589,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
gridSettings: layoutWithResolution.gridSettings,
|
gridSettings: layoutWithResolution.gridSettings,
|
||||||
screenResolution: layoutWithResolution.screenResolution,
|
screenResolution: layoutWithResolution.screenResolution,
|
||||||
});
|
});
|
||||||
// V2 API 사용 여부에 따라 분기
|
// V2/POP API 사용 여부에 따라 분기
|
||||||
if (USE_V2_API) {
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
if (USE_POP_API) {
|
||||||
|
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||||
|
} else if (USE_V2_API) {
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
|
|
@ -5684,21 +5787,124 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
};
|
};
|
||||||
}, [layout, selectedComponent]);
|
}, [layout, selectedComponent]);
|
||||||
|
|
||||||
|
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
|
||||||
|
const handleRegionMouseDown = useCallback((
|
||||||
|
e: React.MouseEvent,
|
||||||
|
layerId: string,
|
||||||
|
mode: "move" | "resize",
|
||||||
|
handle?: string,
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const zoneId = Number(layerId); // layerId는 실제로 zoneId
|
||||||
|
const zone = zones.find(z => z.zone_id === zoneId);
|
||||||
|
if (!zone) return;
|
||||||
|
|
||||||
|
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!canvasRect) return;
|
||||||
|
|
||||||
|
const x = (e.clientX - canvasRect.left) / zoomLevel;
|
||||||
|
const y = (e.clientY - canvasRect.top) / zoomLevel;
|
||||||
|
|
||||||
|
setRegionDrag({
|
||||||
|
isDrawing: false,
|
||||||
|
isDragging: mode === "move",
|
||||||
|
isResizing: mode === "resize",
|
||||||
|
targetLayerId: String(zoneId),
|
||||||
|
startX: x,
|
||||||
|
startY: y,
|
||||||
|
currentX: x,
|
||||||
|
currentY: y,
|
||||||
|
resizeHandle: handle || null,
|
||||||
|
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
|
||||||
|
});
|
||||||
|
}, [zones, zoomLevel]);
|
||||||
|
|
||||||
|
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
|
||||||
|
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!regionDrag.isDragging && !regionDrag.isResizing) return;
|
||||||
|
if (!regionDrag.targetLayerId) return;
|
||||||
|
|
||||||
|
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!canvasRect) return;
|
||||||
|
|
||||||
|
const x = (e.clientX - canvasRect.left) / zoomLevel;
|
||||||
|
const y = (e.clientY - canvasRect.top) / zoomLevel;
|
||||||
|
|
||||||
|
if (regionDrag.isDragging && regionDrag.originalRegion) {
|
||||||
|
const dx = x - regionDrag.startX;
|
||||||
|
const dy = y - regionDrag.startY;
|
||||||
|
const newRegion = {
|
||||||
|
x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)),
|
||||||
|
y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)),
|
||||||
|
width: regionDrag.originalRegion.width,
|
||||||
|
height: regionDrag.originalRegion.height,
|
||||||
|
};
|
||||||
|
const zoneId = Number(regionDrag.targetLayerId);
|
||||||
|
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||||
|
} else if (regionDrag.isResizing && regionDrag.originalRegion) {
|
||||||
|
const dx = x - regionDrag.startX;
|
||||||
|
const dy = y - regionDrag.startY;
|
||||||
|
const orig = regionDrag.originalRegion;
|
||||||
|
const newRegion = { ...orig };
|
||||||
|
|
||||||
|
const handle = regionDrag.resizeHandle;
|
||||||
|
if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx));
|
||||||
|
if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy));
|
||||||
|
if (handle?.includes("w")) {
|
||||||
|
newRegion.x = Math.max(0, Math.round(orig.x + dx));
|
||||||
|
newRegion.width = Math.max(50, Math.round(orig.width - dx));
|
||||||
|
}
|
||||||
|
if (handle?.includes("n")) {
|
||||||
|
newRegion.y = Math.max(0, Math.round(orig.y + dy));
|
||||||
|
newRegion.height = Math.max(30, Math.round(orig.height - dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneId = Number(regionDrag.targetLayerId);
|
||||||
|
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||||
|
}
|
||||||
|
}, [regionDrag, zoomLevel]);
|
||||||
|
|
||||||
|
const handleRegionCanvasMouseUp = useCallback(async () => {
|
||||||
|
// 드래그 완료 시 DB에 Zone 저장
|
||||||
|
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) {
|
||||||
|
const zoneId = Number(regionDrag.targetLayerId);
|
||||||
|
const zone = zones.find(z => z.zone_id === zoneId);
|
||||||
|
if (zone) {
|
||||||
|
try {
|
||||||
|
await screenApi.updateZone(zoneId, {
|
||||||
|
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.error("Zone 저장 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 드래그 상태 초기화
|
||||||
|
setRegionDrag({
|
||||||
|
isDrawing: false,
|
||||||
|
isDragging: false,
|
||||||
|
isResizing: false,
|
||||||
|
targetLayerId: null,
|
||||||
|
startX: 0, startY: 0, currentX: 0, currentY: 0,
|
||||||
|
resizeHandle: null,
|
||||||
|
originalRegion: null,
|
||||||
|
});
|
||||||
|
}, [regionDrag, zones]);
|
||||||
|
|
||||||
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
|
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
|
||||||
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
|
// Zone 기반이므로 displayRegion 보존 불필요
|
||||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||||
setLayout((prevLayout) => ({
|
setLayout((prevLayout) => ({
|
||||||
...prevLayout,
|
...prevLayout,
|
||||||
layers: newLayers,
|
layers: newLayers,
|
||||||
// components는 그대로 유지 - layerId 속성으로 레이어 구분
|
|
||||||
// components: prevLayout.components (기본값으로 유지됨)
|
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🆕 활성 레이어 변경 핸들러
|
// 🆕 활성 레이어 변경 핸들러
|
||||||
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
|
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
|
||||||
setActiveLayerIdLocal(newActiveLayerId);
|
setActiveLayerIdWithRef(newActiveLayerId);
|
||||||
}, []);
|
}, [setActiveLayerIdWithRef]);
|
||||||
|
|
||||||
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
|
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
|
||||||
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
|
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
|
||||||
|
|
@ -5748,6 +5954,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
onBack={onBackToList}
|
onBack={onBackToList}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
|
onPreview={isPop ? handlePopPreview : undefined}
|
||||||
onResolutionChange={setScreenResolution}
|
onResolutionChange={setScreenResolution}
|
||||||
gridSettings={layout.gridSettings}
|
gridSettings={layout.gridSettings}
|
||||||
onGridSettingsChange={updateGridSettings}
|
onGridSettingsChange={updateGridSettings}
|
||||||
|
|
@ -5778,7 +5985,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
|
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
|
||||||
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
|
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
|
||||||
<TabsTrigger value="components" className="text-xs">
|
<TabsTrigger value="components" className="text-xs">
|
||||||
컴포넌트
|
컴포넌트
|
||||||
|
|
@ -5811,9 +6018,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 🆕 레이어 관리 탭 */}
|
{/* 🆕 레이어 관리 탭 (DB 기반) */}
|
||||||
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
|
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
|
||||||
<LayerManagerPanel components={layout.components} />
|
<LayerManagerPanel
|
||||||
|
screenId={selectedScreen?.screenId || null}
|
||||||
|
activeLayerId={Number(activeLayerIdRef.current) || 1}
|
||||||
|
onLayerChange={async (layerId) => {
|
||||||
|
if (!selectedScreen?.screenId) return;
|
||||||
|
try {
|
||||||
|
// 1. 현재 레이어 저장
|
||||||
|
const curId = Number(activeLayerIdRef.current) || 1;
|
||||||
|
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
|
||||||
|
await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId });
|
||||||
|
|
||||||
|
// 2. 새 레이어 로드
|
||||||
|
const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
|
||||||
|
if (data && data.components) {
|
||||||
|
const legacy = convertV2ToLegacy(data);
|
||||||
|
if (legacy) {
|
||||||
|
setLayout((prev) => ({ ...prev, components: legacy.components }));
|
||||||
|
} else {
|
||||||
|
setLayout((prev) => ({ ...prev, components: [] }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLayout((prev) => ({ ...prev, components: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveLayerIdWithRef(layerId);
|
||||||
|
setSelectedComponent(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이어 전환 실패:", error);
|
||||||
|
toast.error("레이어 전환에 실패했습니다.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
components={layout.components}
|
||||||
|
zones={zones}
|
||||||
|
onZonesChange={setZones}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||||
|
|
@ -6390,14 +6631,26 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
{activeLayerId > 1 && (
|
{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="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" />
|
<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} 편집 중
|
||||||
|
{activeLayerZone && (
|
||||||
|
<span className="ml-2 text-amber-600">
|
||||||
|
(캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!activeLayerZone && (
|
||||||
|
<span className="ml-2 text-red-500">
|
||||||
|
(조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// 🆕 조건부 레이어 편집 시 캔버스 크기를 displayRegion에 맞춤
|
// 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤
|
||||||
const activeRegion = activeLayerId > 1 ? layerRegions[activeLayerId] : null;
|
const activeRegion = activeLayerId > 1 ? activeLayerZone : null;
|
||||||
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
|
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
|
||||||
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
|
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
|
||||||
|
|
||||||
|
|
@ -6444,6 +6697,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
startSelectionDrag(e);
|
startSelectionDrag(e);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
// 영역 이동/리사이즈 처리
|
||||||
|
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||||
|
handleRegionCanvasMouseMove(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||||
|
handleRegionCanvasMouseUp();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||||
|
handleRegionCanvasMouseUp();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
|
@ -6512,6 +6781,106 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */}
|
||||||
|
{/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */}
|
||||||
|
{activeLayerId === 1 && zones.map((zone) => {
|
||||||
|
const layerId = zone.zone_id; // 렌더링용 ID
|
||||||
|
const region = zone;
|
||||||
|
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
|
||||||
|
const handleCursors: Record<string, string> = {
|
||||||
|
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
|
||||||
|
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
|
||||||
|
};
|
||||||
|
const handlePositions: Record<string, React.CSSProperties> = {
|
||||||
|
nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 },
|
||||||
|
sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 },
|
||||||
|
n: { top: -4, left: "50%", transform: "translateX(-50%)" },
|
||||||
|
s: { bottom: -4, left: "50%", transform: "translateX(-50%)" },
|
||||||
|
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
|
||||||
|
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
|
||||||
|
};
|
||||||
|
// 테두리 두께 (이동 핸들 영역)
|
||||||
|
const borderWidth = 6;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`region-${layerId}`}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${region.x}px`,
|
||||||
|
top: `${region.y}px`,
|
||||||
|
width: `${region.width}px`,
|
||||||
|
height: `${region.height}px`,
|
||||||
|
border: "2px dashed hsl(var(--primary))",
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: "hsl(var(--primary) / 0.05)",
|
||||||
|
zIndex: 50,
|
||||||
|
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
|
||||||
|
{/* 상단 */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-0"
|
||||||
|
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||||
|
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||||
|
/>
|
||||||
|
{/* 하단 */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0"
|
||||||
|
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||||
|
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||||
|
/>
|
||||||
|
{/* 좌측 */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 top-0"
|
||||||
|
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||||
|
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||||
|
/>
|
||||||
|
{/* 우측 */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 right-0 top-0"
|
||||||
|
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||||
|
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||||
|
/>
|
||||||
|
{/* 라벨 */}
|
||||||
|
<span
|
||||||
|
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
|
||||||
|
style={{ pointerEvents: "auto", cursor: "move" }}
|
||||||
|
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||||
|
>
|
||||||
|
Zone {zone.zone_id} - {zone.zone_name}
|
||||||
|
</span>
|
||||||
|
{/* 리사이즈 핸들 */}
|
||||||
|
{resizeHandles.map((handle) => (
|
||||||
|
<div
|
||||||
|
key={handle}
|
||||||
|
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
|
||||||
|
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
|
||||||
|
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<button
|
||||||
|
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
|
||||||
|
style={{ pointerEvents: "auto" }}
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!selectedScreen?.screenId) return;
|
||||||
|
try {
|
||||||
|
await screenApi.deleteZone(zone.zone_id);
|
||||||
|
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
|
||||||
|
toast.success("조건부 영역이 삭제되었습니다.");
|
||||||
|
} catch { toast.error("Zone 삭제 실패"); }
|
||||||
|
}}
|
||||||
|
title="영역 삭제"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
|
||||||
{/* 일반 컴포넌트들 */}
|
{/* 일반 컴포넌트들 */}
|
||||||
{regularComponents.map((component) => {
|
{regularComponents.map((component) => {
|
||||||
const children =
|
const children =
|
||||||
|
|
@ -7137,4 +7506,4 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
</LayerProvider>
|
</LayerProvider>
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -235,6 +235,57 @@ export const screenApi = {
|
||||||
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
|
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 레이아웃 관리 (모바일/태블릿)
|
// POP 레이아웃 관리 (모바일/태블릿)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -878,7 +878,7 @@ export interface LayerOverlayConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건부 레이어 표시 영역
|
* 조건부 레이어 표시 영역
|
||||||
* 조건 미충족 시 이 영역이 사라지고, 아래 컴포넌트들이 위로 이동
|
* @deprecated Zone 기반으로 전환 - ConditionalZone.x/y/width/height 사용
|
||||||
*/
|
*/
|
||||||
export interface DisplayRegion {
|
export interface DisplayRegion {
|
||||||
x: number;
|
x: number;
|
||||||
|
|
@ -887,6 +887,27 @@ export interface DisplayRegion {
|
||||||
height: number;
|
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; // 초기 표시 여부
|
isVisible: boolean; // 초기 표시 여부
|
||||||
isLocked: boolean; // 편집 잠금 여부
|
isLocked: boolean; // 편집 잠금 여부
|
||||||
|
|
||||||
// 조건부 표시 로직
|
// 조건부 표시 로직 (레거시 - Zone 미사용 레이어용)
|
||||||
condition?: LayerCondition;
|
condition?: LayerCondition;
|
||||||
|
|
||||||
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
|
// Zone 기반 조건부 설정 (신규)
|
||||||
|
zoneId?: number; // 소속 조건부 영역 ID
|
||||||
|
conditionValue?: string; // Zone 트리거 매칭 값
|
||||||
|
|
||||||
|
// 조건부 레이어 표시 영역 (레거시 호환 - Zone으로 대체됨)
|
||||||
displayRegion?: DisplayRegion;
|
displayRegion?: DisplayRegion;
|
||||||
|
|
||||||
// 모달/드로어 전용 설정
|
// 모달/드로어 전용 설정
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue