feat: 멀티테넌시 지원을 위한 레이어 관리 기능 추가

- 레이어 목록 조회, 특정 레이어 레이아웃 조회, 레이어 삭제 및 조건 설정 업데이트 기능을 추가했습니다.
- 엔티티 참조 데이터 조회 및 공통 코드 데이터 조회에 멀티테넌시 필터를 적용하여 인증된 사용자의 회사 코드에 따라 데이터 접근을 제한했습니다.
- 레이어 관리 패널에서 기본 레이어와 조건부 레이어의 컴포넌트를 통합하여 조건부 영역의 표시를 개선했습니다.
- 레이아웃 저장 시 레이어 ID를 포함하여 레이어별로 저장할 수 있도록 변경했습니다.
This commit is contained in:
kjs 2026-02-09 13:21:56 +09:00
parent 84eb035069
commit 1c71b3aa83
14 changed files with 1571 additions and 598 deletions

View File

@ -30,10 +30,13 @@ export class EntityReferenceController {
try {
const { tableName, columnName } = req.params;
const { limit = 100, search } = req.query;
// 멀티테넌시: 인증된 사용자의 회사 코드
const companyCode = (req as any).user?.companyCode;
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
limit,
search,
companyCode,
});
// 컬럼 정보 조회 (table_type_columns에서)
@ -89,16 +92,34 @@ export class EntityReferenceController {
});
}
// 동적 쿼리로 참조 데이터 조회
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
// 참조 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCode = await queryOne<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
[referenceTable]
);
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
const whereConditions: string[] = [];
const queryParams: any[] = [];
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
if (hasCompanyCode && companyCode && companyCode !== "*") {
queryParams.push(companyCode);
whereConditions.push(`company_code = $${queryParams.length}`);
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
}
// 검색 조건 추가
if (search) {
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
queryParams.push(`%${search}%`);
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
}
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
if (whereConditions.length > 0) {
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
}
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
queryParams.push(Number(limit));
@ -107,6 +128,7 @@ export class EntityReferenceController {
referenceTable,
referenceColumn,
displayColumn,
companyCode,
});
const referenceData = await query<any>(sqlQuery, queryParams);
@ -119,7 +141,7 @@ export class EntityReferenceController {
})
);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
return res.json({
success: true,
@ -149,13 +171,16 @@ export class EntityReferenceController {
try {
const { codeCategory } = req.params;
const { limit = 100, search } = req.query;
// 멀티테넌시: 인증된 사용자의 회사 코드
const companyCode = (req as any).user?.companyCode;
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
limit,
search,
companyCode,
});
// code_info 테이블에서 코드 데이터 조회
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
const queryParams: any[] = [codeCategory, 'Y'];
let sqlQuery = `
SELECT code_value, code_name
@ -163,9 +188,16 @@ export class EntityReferenceController {
WHERE code_category = $1 AND is_active = $2
`;
// 멀티테넌시: company_code 필터링
if (companyCode && companyCode !== "*") {
queryParams.push(companyCode);
sqlQuery += ` AND company_code = $${queryParams.length}`;
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
}
if (search) {
sqlQuery += ` AND code_name ILIKE $3`;
queryParams.push(`%${search}%`);
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
}
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
@ -174,12 +206,12 @@ export class EntityReferenceController {
const codeData = await query<any>(sqlQuery, queryParams);
// 옵션 형태로 변환
const options: EntityReferenceOption[] = codeData.map((code) => ({
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
value: code.code_value,
label: code.code_name,
}));
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
return res.json({
success: true,

View File

@ -732,6 +732,61 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
}
};
// 🆕 레이어 목록 조회
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
res.json({ success: true, data: layers });
} catch (error) {
console.error("레이어 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
}
};
// 🆕 특정 레이어 레이아웃 조회
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
res.json({ success: true, data: layout });
} catch (error) {
console.error("레이어 레이아웃 조회 실패:", error);
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
}
};
// 🆕 레이어 삭제
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
res.json({ success: true, message: "레이어가 삭제되었습니다." });
} catch (error: any) {
console.error("레이어 삭제 실패:", error);
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
}
};
// 🆕 레이어 조건 설정 업데이트
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
const { conditionConfig, layerName } = req.body;
await screenManagementService.updateLayerCondition(
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
);
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
} catch (error) {
console.error("레이어 조건 업데이트 실패:", error);
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
}
};
// 화면 코드 자동 생성
export const generateScreenCode = async (
req: AuthenticatedRequest,

View File

@ -38,6 +38,10 @@ import {
copyCategoryMapping,
copyTableTypeColumns,
copyCascadingRelation,
getScreenLayers,
getLayerLayout,
deleteLayer,
updateLayerCondition,
} from "../controllers/screenManagementController";
const router = express.Router();
@ -84,6 +88,12 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// 🆕 레이어 관리
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
// 메뉴-화면 할당 관리
router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
router.get("/menus/:menuObjid/screens", getScreensByMenu);

View File

@ -4245,11 +4245,11 @@ export class ScreenManagementService {
},
);
// V2 레이아웃 저장 (UPSERT)
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
@ -5073,38 +5073,63 @@ export class ScreenManagementService {
let layout: { layout_data: any } | null = null;
// 🆕 기본 레이어(layer_id=1)를 우선 로드
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// 1. 화면 정의의 회사 코드로 레이아웃 조회
// 1. 화면 정의의 회사 코드 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, existingScreen.company_code],
);
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, existingScreen.company_code],
);
}
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY updated_at DESC
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
} else {
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
// 일반 사용자: 회사별 우선 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, companyCode],
);
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, companyCode],
);
}
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
@ -5122,17 +5147,21 @@ export class ScreenManagementService {
}
/**
* V2 (1 )
* - screen_layouts_v2 1
* - layout_data JSON에
* V2 ( )
* - screen_layouts_v2 1
* - layout_data JSON에
*/
async saveLayoutV2(
screenId: number,
layoutData: any,
companyCode: string,
): Promise<void> {
const layerId = layoutData.layerId || 1;
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
const conditionConfig = layoutData.conditionConfig || null;
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
// 권한 확인
@ -5151,22 +5180,173 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
const dataToSave = {
version: "2.0",
...layoutData
...pureLayoutData,
};
// UPSERT (있으면 업데이트, 없으면 삽입)
// UPSERT (레이어별 저장)
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(dataToSave)],
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
console.log(`V2 레이아웃 저장 완료`);
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`);
}
/**
*
*
*/
async getScreenLayers(
screenId: number,
companyCode: string,
): Promise<any[]> {
let layers;
if (companyCode === "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY layer_id`,
[screenId],
);
} else {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
// 회사별 레이어가 없으면 공통(*) 레이어 조회
if (layers.length === 0 && companyCode !== "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id`,
[screenId],
);
}
}
// 레이어가 없으면 기본 레이어 자동 생성
if (layers.length === 0) {
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
[screenId, companyCode, defaultLayout],
);
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
// 다시 조회
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
}
return layers;
}
/**
*
*/
async getLayerLayout(
screenId: number,
layerId: number,
companyCode: string,
): Promise<any> {
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
// 회사별 레이어가 없으면 공통(*) 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
[screenId, layerId],
);
}
if (!layout) return null;
return {
...layout.layout_data,
layerId,
layerName: layout.layer_name,
conditionConfig: layout.condition_config,
};
}
/**
*
*/
async deleteLayer(
screenId: number,
layerId: number,
companyCode: string,
): Promise<void> {
if (layerId === 1) {
throw new Error("기본 레이어는 삭제할 수 없습니다.");
}
await query(
`DELETE FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
}
/**
*
*/
async updateLayerCondition(
screenId: number,
layerId: number,
companyCode: string,
conditionConfig: any,
layerName?: string,
): Promise<void> {
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
if (layerName) {
setClauses.push(`layer_name = $${params.length + 1}`);
params.push(layerName);
}
await query(
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
params,
);
}
}

View File

@ -284,6 +284,60 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
const conditionalRegionInfos = useMemo(() => {
return layers
.filter((layer) => layer.type === "conditional" && layer.displayRegion)
.map((layer) => ({
layerId: layer.id,
region: layer.displayRegion!,
isActive: activeLayerIds.includes(layer.id),
}))
.sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬
}, [layers, activeLayerIds]);
// 🆕 접힌 조건부 영역 (비활성 상태인 것만)
const collapsedRegions = useMemo(() => {
return conditionalRegionInfos
.filter((info) => !info.isActive)
.map((info) => info.region);
}, [conditionalRegionInfos]);
// 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원)
// 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌
// 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리
const calculateYOffset = useCallback((componentY: number): number => {
if (collapsedRegions.length === 0) return 0;
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
const relevantRegions = collapsedRegions.filter(
(region) => region.y + region.height <= componentY
);
if (relevantRegions.length === 0) return 0;
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
const mergedRegions: { y: number; bottom: number }[] = [];
for (const region of relevantRegions) {
const bottom = region.y + region.height;
if (mergedRegions.length === 0) {
mergedRegions.push({ y: region.y, bottom });
} else {
const last = mergedRegions[mergedRegions.length - 1];
if (region.y <= last.bottom) {
// 겹치는 영역 - 병합 (더 큰 하단으로 확장)
last.bottom = Math.max(last.bottom, bottom);
} else {
// 겹치지 않는 영역 - 새로 추가
mergedRegions.push({ y: region.y, bottom });
}
}
}
// 병합된 영역들의 높이 합산
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
}, [collapsedRegions]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation(
@ -2198,6 +2252,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
: component;
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
const allLayerComponents = useMemo(() => {
return layers.flatMap((layer) => layer.components);
}, [layers]);
// 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
@ -2234,7 +2293,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
allComponents={allLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2306,7 +2365,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
allComponents={allLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2326,30 +2385,36 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
className="pointer-events-none absolute inset-0"
style={{ zIndex: layer.zIndex }}
>
{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={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
{layer.components.map((comp) => {
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
const adjustedY = comp.position.y - yOffset;
return (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${adjustedY}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>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
return (
<SplitPanelProvider>

View File

@ -10,15 +10,28 @@ import {
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Loader2, AlertCircle, Check, X } from "lucide-react";
import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management";
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
import { EntityReferenceAPI } from "@/lib/api/entityReference";
import { apiClient } from "@/lib/api/client";
// 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용)
interface ConditionOption {
value: string;
label: string;
}
// 컴포넌트의 데이터 소스 타입
type DataSourceType = "code" | "entity" | "category" | "static" | "none";
interface LayerConditionPanelProps {
layer: LayerDefinition;
components: ComponentData[]; // 화면의 모든 컴포넌트
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
onUpdateCondition: (condition: LayerCondition | undefined) => void;
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
onClose?: () => void;
}
@ -34,7 +47,9 @@ type OperatorType = "eq" | "neq" | "in";
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
layer,
components,
baseLayerComponents,
onUpdateCondition,
onUpdateDisplayRegion,
onClose,
}) => {
// 조건 설정 상태
@ -51,75 +66,246 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
Array.isArray(layer.condition?.value) ? layer.condition.value : []
);
// 코드 목록 로딩 상태
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
const [options, setOptions] = useState<ConditionOption[]>([]);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
const triggerableComponents = useMemo(() => {
return components.filter((comp) => {
// 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용
const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0
? baseLayerComponents
: components;
const isTriggerComponent = (comp: ComponentData): boolean => {
const componentType = (comp.componentType || "").toLowerCase();
const widgetType = ((comp as any).widgetType || "").toLowerCase();
const webType = ((comp as any).webType || "").toLowerCase();
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
const isTriggerType = triggerTypes.some((type) =>
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
return triggerTypes.some((type) =>
componentType.includes(type) ||
widgetType.includes(type) ||
webType.includes(type) ||
inputType.includes(type)
);
};
return isTriggerType;
});
}, [components]);
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
const baseLayerIds = new Set(
(baseLayerComponents || []).map((c) => c.id)
);
// 기본 레이어 트리거 컴포넌트
const baseLayerTriggers = sourceComponents.filter(isTriggerComponent);
// 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시)
// 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가
const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0
? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp))
: [];
return { baseLayerTriggers, otherLayerTriggers };
}, [components, baseLayerComponents]);
// 선택된 컴포넌트 정보
const selectedComponent = useMemo(() => {
return components.find((c) => c.id === targetComponentId);
}, [components, targetComponentId]);
// 선택된 컴포넌트의 코드 카테고리
const codeCategory = useMemo(() => {
if (!selectedComponent) return null;
// 선택된 컴포넌트의 데이터 소스 정보 추출
const dataSourceInfo = useMemo<{
type: DataSourceType;
codeCategory?: string;
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
originTable?: string;
originColumn?: string;
// 엔티티: 참조 대상 정보 (직접 조회용 폴백)
referenceTable?: string;
referenceColumn?: string;
categoryTable?: string;
categoryColumn?: string;
staticOptions?: any[];
}>(() => {
if (!selectedComponent) return { type: "none" };
// codeCategory 확인 (다양한 위치에 있을 수 있음)
const category =
(selectedComponent as any).codeCategory ||
(selectedComponent as any).componentConfig?.codeCategory ||
(selectedComponent as any).webTypeConfig?.codeCategory;
const comp = selectedComponent as any;
const config = comp.componentConfig || comp.webTypeConfig || {};
const detailSettings = comp.detailSettings || {};
return category || null;
// V2 컴포넌트: config.source 확인
const source = config.source;
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
if (source === "category") {
const categoryTable = config.categoryTable || comp.tableName;
const categoryColumn = config.categoryColumn || comp.columnName;
return { type: "category", categoryTable, categoryColumn };
}
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
const codeCategory =
config.codeGroup || // V2 컴포넌트
config.codeCategory ||
comp.codeCategory ||
detailSettings.codeCategory;
if (source === "code" || codeCategory) {
return { type: "code", codeCategory };
}
// 3. 엔티티 참조 확인 (V2: source === "entity")
// entity-reference API는 원본 테이블.컬럼으로 호출해야 함
// (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑)
const originTable = comp.tableName || config.tableName;
const originColumn = comp.columnName || config.columnName;
const referenceTable =
config.entityTable ||
config.referenceTable ||
comp.referenceTable ||
detailSettings.referenceTable;
const referenceColumn =
config.entityValueColumn ||
config.referenceColumn ||
comp.referenceColumn ||
detailSettings.referenceColumn;
if (source === "entity" || referenceTable) {
return { type: "entity", originTable, originColumn, referenceTable, referenceColumn };
}
// 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재)
const staticOptions = config.options;
if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) {
return { type: "static", staticOptions };
}
return { type: "none" };
}, [selectedComponent]);
// 컴포넌트 선택 시 코드 목록 로드
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
useEffect(() => {
if (!codeCategory) {
setCodeOptions([]);
if (dataSourceInfo.type === "none") {
setOptions([]);
return;
}
const loadCodes = async () => {
setIsLoadingCodes(true);
setCodeLoadError(null);
// 정적 옵션은 즉시 설정
if (dataSourceInfo.type === "static") {
const staticOpts = dataSourceInfo.staticOptions || [];
setOptions(staticOpts.map((opt: any) => ({
value: opt.value || "",
label: opt.label || opt.value || "",
})));
return;
}
const loadOptions = async () => {
setIsLoadingOptions(true);
setLoadError(null);
try {
const codes = await getCodesByCategory(codeCategory);
setCodeOptions(codes);
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
// 카테고리 값에서 옵션 로드 (category_values 테이블)
const response = await apiClient.get(
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
);
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
const result: ConditionOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) : "";
result.push({
value: item.valueCode || item.valueLabel,
label: `${prefix}${item.valueLabel}`,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
setOptions(flattenTree(Array.isArray(data.data) ? data.data : []));
} else {
setOptions([]);
}
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
// 코드 카테고리에서 옵션 로드
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
setOptions(codes.map((code) => ({
value: code.code,
label: code.name,
})));
} else if (dataSourceInfo.type === "entity") {
// 엔티티 참조에서 옵션 로드
// 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출
// (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑)
// 방법 2: 직접 참조 테이블로 폴백
let entityLoaded = false;
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
try {
const entityData = await EntityReferenceAPI.getEntityReferenceData(
dataSourceInfo.originTable,
dataSourceInfo.originColumn,
{ limit: 100 }
);
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
})));
entityLoaded = true;
} catch {
// 원본 테이블.컬럼으로 실패 시 폴백
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
}
}
// 폴백: 참조 테이블에서 직접 조회
if (!entityLoaded && dataSourceInfo.referenceTable) {
try {
const refColumn = dataSourceInfo.referenceColumn || "id";
const entityData = await EntityReferenceAPI.getEntityReferenceData(
dataSourceInfo.referenceTable,
refColumn,
{ limit: 100 }
);
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
})));
entityLoaded = true;
} catch {
console.warn("직접 참조 테이블로도 엔티티 조회 실패");
}
}
// 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음
if (!entityLoaded) {
// 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환
setOptions([]);
}
} else {
setOptions([]);
}
} catch (error: any) {
console.error("코드 목록 로드 실패:", error);
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
setCodeOptions([]);
console.error("옵션 목록 로드 실패:", error);
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
setOptions([]);
} finally {
setIsLoadingCodes(false);
setIsLoadingOptions(false);
}
};
loadCodes();
}, [codeCategory]);
loadOptions();
}, [dataSourceInfo]);
// 조건 저장
const handleSave = useCallback(() => {
@ -180,36 +366,91 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
<SelectValue placeholder="컴포넌트 선택..." />
</SelectTrigger>
<SelectContent>
{triggerableComponents.length === 0 ? (
{triggerableComponents.baseLayerTriggers.length === 0 &&
triggerableComponents.otherLayerTriggers.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
.
<br />
(, , )
</div>
) : (
triggerableComponents.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))
<>
{/* 기본 레이어 컴포넌트 (우선 표시) */}
{triggerableComponents.baseLayerTriggers.length > 0 && (
<>
{triggerableComponents.otherLayerTriggers.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
</div>
)}
{triggerableComponents.baseLayerTriggers.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))}
</>
)}
{/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */}
{triggerableComponents.otherLayerTriggers.length > 0 && (
<>
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-1">
</div>
{triggerableComponents.otherLayerTriggers.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))}
</>
)}
</>
)}
</SelectContent>
</Select>
{/* 코드 카테고리 표시 */}
{codeCategory && (
{/* 데이터 소스 표시 */}
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Code2 className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{dataSourceInfo.codeCategory}
</Badge>
</div>
)}
{dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
</Badge>
</div>
)}
{dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{codeCategory}
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
</Badge>
</div>
)}
{dataSourceInfo.type === "static" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span> </span>
</div>
)}
</div>
{/* 연산자 선택 */}
@ -241,42 +482,41 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
{operator === "in" ? "값 선택 (복수)" : "값"}
</Label>
{isLoadingCodes ? (
{isLoadingOptions ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
<Loader2 className="h-3 w-3 animate-spin" />
...
...
</div>
) : codeLoadError ? (
) : loadError ? (
<div className="flex items-center gap-2 text-xs text-destructive p-2">
<AlertCircle className="h-3 w-3" />
{codeLoadError}
{loadError}
</div>
) : codeOptions.length > 0 ? (
// 코드 카테고리가 있는 경우 - 선택 UI
) : options.length > 0 ? (
// 옵션이 있는 경우 - 선택 UI
operator === "in" ? (
// 다중 선택 (in 연산자)
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
{codeOptions.map((code) => (
{options.map((opt) => (
<div
key={code.codeValue}
key={opt.value}
className={cn(
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
multiValues.includes(code.codeValue) && "bg-primary/10"
multiValues.includes(opt.value) && "bg-primary/10"
)}
onClick={() => toggleMultiValue(code.codeValue)}
onClick={() => toggleMultiValue(opt.value)}
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center",
multiValues.includes(code.codeValue)
multiValues.includes(opt.value)
? "bg-primary border-primary"
: "border-input"
)}>
{multiValues.includes(code.codeValue) && (
{multiValues.includes(opt.value) && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
<span>{code.codeName}</span>
<span className="text-muted-foreground">({code.codeValue})</span>
<span>{opt.label}</span>
</div>
))}
</div>
@ -287,20 +527,20 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
<SelectValue placeholder="값 선택..." />
</SelectTrigger>
<SelectContent>
{codeOptions.map((code) => (
{options.map((opt) => (
<SelectItem
key={code.codeValue}
value={code.codeValue}
key={opt.value}
value={opt.value}
className="text-xs"
>
{code.codeName} ({code.codeValue})
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
) : (
// 코드 카테고리가 없는 경우 - 직접 입력
// 옵션이 없는 경우 - 직접 입력
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
@ -313,14 +553,14 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
{operator === "in" && multiValues.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{multiValues.map((val) => {
const code = codeOptions.find((c) => c.codeValue === val);
const opt = options.find((o) => o.value === val);
return (
<Badge
key={val}
variant="secondary"
className="text-[10px] gap-1"
>
{code?.codeName || val}
{opt?.label || val}
<X
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
onClick={() => toggleMultiValue(val)}
@ -339,14 +579,60 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
<span className="font-medium">: </span>
<span className="text-muted-foreground">
"{getComponentLabel(selectedComponent!)}" {" "}
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
{" "}
</span>
</div>
)}
{/* 표시 영역 설정 */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-semibold"> </Label>
{layer.displayRegion ? (
<>
{/* 현재 영역 정보 표시 */}
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
<div className="flex-1 text-[10px] text-muted-foreground">
<span className="font-medium text-foreground">
{layer.displayRegion.width} x {layer.displayRegion.height}
</span>
<span className="ml-1">
({layer.displayRegion.x}, {layer.displayRegion.y})
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-destructive hover:text-destructive"
onClick={() => onUpdateDisplayRegion(undefined)}
>
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
/ .
</p>
</>
) : (
<div className="space-y-2">
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs font-medium text-muted-foreground">
</p>
<p className="text-xs font-medium text-muted-foreground">
&
</p>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
</div>
)}
</div>
{/* 버튼 */}
<div className="flex gap-2 pt-2">
<Button

View File

@ -1,404 +1,323 @@
import React, { useState, useMemo, useCallback } from "react";
import { useLayer } from "@/contexts/LayerContext";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Eye,
EyeOff,
Lock,
Unlock,
Plus,
Trash2,
GripVertical,
Layers,
SplitSquareVertical,
PanelRight,
ChevronDown,
ChevronRight,
Settings2,
Zap,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
import { screenApi } from "@/lib/api/screen";
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
import { toast } from "sonner";
import { LayerConditionPanel } from "./LayerConditionPanel";
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
// 레이어 타입별 아이콘
const getLayerTypeIcon = (type: LayerType) => {
switch (type) {
case "base":
return <Layers className="h-3 w-3" />;
case "conditional":
return <SplitSquareVertical className="h-3 w-3" />;
case "modal":
return <Settings2 className="h-3 w-3" />;
case "drawer":
return <PanelRight className="h-3 w-3" />;
default:
return <Layers className="h-3 w-3" />;
}
};
// 레이어 타입별 라벨
function getLayerTypeLabel(type: LayerType): string {
switch (type) {
case "base":
return "기본";
case "conditional":
return "조건부";
case "modal":
return "모달";
case "drawer":
return "드로어";
default:
return type;
}
// DB 레이어 타입
interface DBLayer {
layer_id: number;
layer_name: string;
condition_config: any;
component_count: number;
updated_at: string;
}
// 레이어 타입별 색상
function getLayerTypeColor(type: LayerType): string {
switch (type) {
case "base":
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
case "conditional":
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
case "modal":
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
case "drawer":
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
default:
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
}
}
interface LayerItemProps {
layer: LayerDefinition;
isActive: boolean;
componentCount: number; // 실제 컴포넌트 수 (layout.components 기반)
allComponents: ComponentData[]; // 조건 설정에 필요한 전체 컴포넌트
onSelect: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onRemove: () => void;
onUpdateName: (name: string) => void;
onUpdateCondition: (condition: LayerCondition | undefined) => void;
}
const LayerItem: React.FC<LayerItemProps> = ({
layer,
isActive,
componentCount,
allComponents,
onSelect,
onToggleVisibility,
onToggleLock,
onRemove,
onUpdateName,
onUpdateCondition,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [isConditionOpen, setIsConditionOpen] = useState(false);
// 조건부 레이어인지 확인
const isConditionalLayer = layer.type === "conditional";
// 조건 설정 여부
const hasCondition = !!layer.condition;
return (
<div className="space-y-0">
{/* 레이어 메인 영역 */}
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm transition-all cursor-pointer",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "hover:bg-muted border-transparent",
!layer.isVisible && "opacity-50",
isConditionOpen && "rounded-b-none border-b-0",
)}
onClick={onSelect}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab flex-shrink-0" />
{/* 레이어 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{/* 레이어 타입 아이콘 */}
<span className={cn("flex-shrink-0", getLayerTypeColor(layer.type), "p-1 rounded")}>
{getLayerTypeIcon(layer.type)}
</span>
{/* 레이어 이름 */}
{isEditing ? (
<input
type="text"
value={layer.name}
onChange={(e) => onUpdateName(e.target.value)}
onBlur={() => setIsEditing(false)}
onKeyDown={(e) => {
if (e.key === "Enter") setIsEditing(false);
}}
className="flex-1 bg-transparent outline-none border-b border-primary text-sm"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="flex-1 truncate font-medium"
onDoubleClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
>
{layer.name}
</span>
)}
</div>
{/* 레이어 메타 정보 */}
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4">
{getLayerTypeLabel(layer.type)}
</Badge>
<span className="text-muted-foreground text-[10px]">
{componentCount}
</span>
{/* 조건 설정됨 표시 */}
{hasCondition && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼들 */}
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* 조건부 레이어일 때 조건 설정 버튼 */}
{isConditionalLayer && (
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6",
hasCondition && "text-amber-600"
)}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setIsConditionOpen(!isConditionOpen);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isVisible ? "레이어 숨기기" : "레이어 표시"}
onClick={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
>
{layer.isVisible ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={layer.isLocked ? "편집 잠금 해제" : "편집 잠금"}
onClick={(e) => {
e.stopPropagation();
onToggleLock();
}}
>
{layer.isLocked ? (
<Lock className="text-destructive h-3.5 w-3.5" />
) : (
<Unlock className="text-muted-foreground h-3.5 w-3.5" />
)}
</Button>
{layer.type !== "base" && (
<Button
variant="ghost"
size="icon"
className="hover:text-destructive h-6 w-6"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 조건 설정 패널 (조건부 레이어만) */}
{isConditionalLayer && isConditionOpen && (
<div className={cn(
"border border-t-0 rounded-b-md bg-muted/30",
isActive ? "border-primary" : "border-border"
)}>
<LayerConditionPanel
layer={layer}
components={allComponents}
onUpdateCondition={onUpdateCondition}
onClose={() => setIsConditionOpen(false)}
/>
</div>
)}
</div>
);
};
interface LayerManagerPanelProps {
components?: ComponentData[]; // layout.components를 전달받음
screenId: number | null;
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
onLayerChange: (layerId: number) => void; // 레이어 전환
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
const {
layers,
activeLayerId,
setActiveLayerId,
addLayer,
removeLayer,
toggleLayerVisibility,
toggleLayerLock,
updateLayer,
} = useLayer();
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
screenId,
activeLayerId,
onLayerChange,
components = [],
}) => {
const [layers, setLayers] = useState<DBLayer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
// 레이어 조건 업데이트 핸들러
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
updateLayer(layerId, { condition });
}, [updateLayer]);
// 레이어 목록 로드
const loadLayers = useCallback(async () => {
if (!screenId) return;
setIsLoading(true);
try {
const data = await screenApi.getScreenLayers(screenId);
setLayers(data);
} catch (error) {
console.error("레이어 목록 로드 실패:", error);
} finally {
setIsLoading(false);
}
}, [screenId]);
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
const componentCountByLayer = useMemo(() => {
const counts: Record<string, number> = {};
// 모든 레이어를 0으로 초기화
layers.forEach(layer => {
counts[layer.id] = 0;
});
// layout.components에서 layerId별로 카운트
components.forEach(comp => {
const layerId = comp.layerId || "default-layer";
if (counts[layerId] !== undefined) {
counts[layerId]++;
} else {
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
if (counts["default-layer"] !== undefined) {
counts["default-layer"]++;
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
const loadBaseLayerComponents = useCallback(async () => {
if (!screenId) return;
try {
const data = await screenApi.getLayerLayout(screenId, 1);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setBaseLayerComponents(legacy.components as ComponentData[]);
return;
}
}
});
setBaseLayerComponents([]);
} catch {
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
setBaseLayerComponents(components);
}
}, [screenId, components]);
return counts;
}, [components, layers]);
useEffect(() => {
loadLayers();
}, [loadLayers]);
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
useEffect(() => {
if (conditionOpenLayerId !== null) {
loadBaseLayerComponents();
}
}, [conditionOpenLayerId, loadBaseLayerComponents]);
// 새 레이어 추가
const handleAddLayer = useCallback(async () => {
if (!screenId) return;
// 다음 layer_id 계산
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
const newLayerId = maxLayerId + 1;
try {
// 빈 레이아웃으로 새 레이어 저장
await screenApi.saveLayoutV2(screenId, {
version: "2.0",
components: [],
layerId: newLayerId,
layerName: `조건부 레이어 ${newLayerId}`,
});
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
await loadLayers();
// 새 레이어로 전환
onLayerChange(newLayerId);
} catch (error) {
console.error("레이어 추가 실패:", error);
toast.error("레이어 추가에 실패했습니다.");
}
}, [screenId, layers, loadLayers, onLayerChange]);
// 레이어 삭제
const handleDeleteLayer = useCallback(async (layerId: number) => {
if (!screenId || layerId === 1) return;
try {
await screenApi.deleteLayer(screenId, layerId);
toast.success("레이어가 삭제되었습니다.");
await loadLayers();
// 기본 레이어로 전환
if (activeLayerId === layerId) {
onLayerChange(1);
}
} catch (error) {
console.error("레이어 삭제 실패:", error);
toast.error("레이어 삭제에 실패했습니다.");
}
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
// 조건 업데이트
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
if (!screenId) return;
try {
await screenApi.updateLayerCondition(screenId, layerId, condition || null);
toast.success("조건이 저장되었습니다.");
await loadLayers();
} catch (error) {
console.error("조건 업데이트 실패:", error);
toast.error("조건 저장에 실패했습니다.");
}
}, [screenId, loadLayers]);
return (
<div className="bg-background flex h-full flex-col">
<div className="flex h-full flex-col bg-background">
{/* 헤더 */}
<div className="flex items-center justify-between border-b px-3 py-2">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold"></h3>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{layers.length}
</Badge>
</div>
{/* 레이어 추가 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
<Plus className="h-3.5 w-3.5" />
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
<SplitSquareVertical className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
<Settings2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
<PanelRight className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
onClick={handleAddLayer}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 레이어 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{layers.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
.
<br />
<span className="text-xs"> + .</span>
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : layers.length === 0 ? (
<div className="space-y-2 py-4 text-center">
<p className="text-sm text-muted-foreground"> ...</p>
<p className="text-[10px] text-muted-foreground"> .</p>
</div>
) : (
layers
.slice()
.reverse() // 상위 레이어가 위에 표시
.map((layer) => (
<LayerItem
key={layer.id}
layer={layer}
isActive={activeLayerId === layer.id}
componentCount={componentCountByLayer[layer.id] || 0}
allComponents={components}
onSelect={() => setActiveLayerId(layer.id)}
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
onToggleLock={() => toggleLayerLock(layer.id)}
onRemove={() => removeLayer(layer.id)}
onUpdateName={(name) => updateLayer(layer.id, { name })}
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
/>
))
.reverse()
.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
const isBase = layer.layer_id === 1;
const hasCondition = !!layer.condition_config;
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
return (
<div key={layer.layer_id} className="space-y-0">
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
isConditionOpen && "rounded-b-none border-b-0",
)}
onClick={() => onLayerChange(layer.layer_id)}
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
draggable={!isBase}
onDragStart={(e) => {
if (isBase) return;
e.dataTransfer.setData("application/json", JSON.stringify({
type: "layer-region",
layerId: layer.layer_id,
layerName: layer.layer_name,
}));
e.dataTransfer.effectAllowed = "copy";
}}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={cn(
"shrink-0 rounded p-1",
isBase
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}>
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
</span>
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
{isBase ? "기본" : "조건부"}
</Badge>
<span className="text-[10px] text-muted-foreground">
{layer.component_count}
</span>
{hasCondition && (
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼 */}
<div className="flex shrink-0 items-center gap-0.5">
{!isBase && (
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
{!isBase && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteLayer(layer.layer_id);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 조건 설정 패널 */}
{!isBase && isConditionOpen && (
<div className={cn(
"rounded-b-md border border-t-0 bg-muted/30",
isActive ? "border-primary" : "border-border",
)}>
<LayerConditionPanel
layer={{
id: String(layer.layer_id),
name: layer.layer_name,
type: "conditional",
zIndex: layer.layer_id,
isVisible: true,
isLocked: false,
condition: layer.condition_config || undefined,
components: [],
}}
components={baseLayerComponents}
baseLayerComponents={baseLayerComponents}
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
onUpdateDisplayRegion={() => {}}
onClose={() => setConditionOpenLayerId(null)}
/>
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p>더블클릭: 이름 | 드래그: 순서 </p>
<p> | </p>
</div>
</div>
);

View File

@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
@ -500,25 +501,49 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return lines;
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
const activeLayerIdRef = useRef<number>(1);
const setActiveLayerIdWithRef = useCallback((id: number) => {
setActiveLayerIdLocal(id);
activeLayerIdRef.current = id;
}, []);
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
// 🆕 좌측 패널 탭 상태 관리
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
// 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시)
const [layerRegions, setLayerRegions] = useState<Record<number, { x: number; y: number; width: number; height: number; layerName: string }>>({});
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
const [regionDrag, setRegionDrag] = useState<{
isDrawing: boolean; // 새 영역 그리기 모드
isDragging: boolean; // 기존 영역 이동 모드
isResizing: boolean; // 기존 영역 리사이즈 모드
targetLayerId: string | null; // 대상 레이어 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,
});
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
const visibleComponents = useMemo(() => {
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
if (!activeLayerId) {
return layout.components;
}
// 활성 레이어에 속한 컴포넌트만 필터링
return layout.components.filter((comp) => {
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const compLayerId = comp.layerId || "default-layer";
return compLayerId === activeLayerId;
});
}, [layout.components, activeLayerId]);
return layout.components;
}, [layout.components]);
// 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => {
@ -1532,6 +1557,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
// 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion)
try {
const layers = await screenApi.getScreenLayers(selectedScreen.screenId);
const regions: Record<number, any> = {};
for (const layer of layers) {
if (layer.layer_id > 1 && layer.condition_config?.displayRegion) {
regions[layer.layer_id] = {
...layer.condition_config.displayRegion,
layerName: layer.layer_name,
};
}
}
setLayerRegions(regions);
} catch { /* 레이어 로드 실패 무시 */ }
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
@ -1953,37 +1993,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
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 = {
...layout,
components: updatedComponents,
layers: updatedLayers, // 🆕 레이어 정보 포함
screenResolution: screenResolution,
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 사용 여부에 따라 분기
if (USE_V2_API) {
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
// 🆕 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
const currentLayerId = activeLayerIdRef.current || 1;
const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
// layerId를 포함하여 저장
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
@ -2505,10 +2531,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
});
// 🆕 현재 활성 레이어에 컴포넌트 추가
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
const componentsWithLayerId = newComponents.map((comp) => ({
...comp,
layerId: activeLayerId || "default-layer",
layerId: activeLayerIdRef.current || 1,
}));
// 레이아웃에 새 컴포넌트들 추가
@ -2527,7 +2553,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory, activeLayerId],
[layout, selectedScreen, saveToHistory],
);
// 레이아웃 드래그 처리
@ -2581,7 +2607,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig,
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
@ -2598,7 +2624,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
[layout, screenResolution, saveToHistory, zoomLevel],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -3189,7 +3215,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
position: snappedPosition,
size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가
@ -3223,7 +3249,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory, activeLayerId],
[layout, selectedScreen, saveToHistory],
);
// 드래그 앤 드롭 처리
@ -3232,7 +3258,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
async (e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
@ -3264,6 +3290,41 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return;
}
// 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장
if (parsedData.type === "layer-region" && parsedData.layerId && 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);
const newRegion = {
x: Math.max(0, dropX - 400),
y: Math.max(0, dropY),
width: Math.min(800, screenResolution.width),
height: 200,
};
// DB에 displayRegion 저장 (condition_config에 포함)
try {
// 기존 condition_config를 가져와서 displayRegion만 추가/업데이트
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId);
const existingCondition = layerData?.conditionConfig || {};
await screenApi.updateLayerCondition(
selectedScreen.screenId,
parsedData.layerId,
{ ...existingCondition, displayRegion: newRegion }
);
// 레이어 영역 state에 반영 (캔버스에 즉시 표시)
setLayerRegions((prev) => ({
...prev,
[parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName },
}));
toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`);
} catch (error) {
console.error("레이어 영역 저장 실패:", error);
toast.error("레이어 영역 저장에 실패했습니다.");
}
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
@ -3595,7 +3656,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
style: {
labelDisplay: true,
labelFontSize: "14px",
@ -3846,7 +3907,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -3913,7 +3974,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
componentType: v2Mapping.componentType, // v2-input, v2-select 등
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -4736,7 +4797,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
z: clipComponent.position.z || 1,
} as Position,
parentId: undefined, // 붙여넣기 시 부모 관계 해제
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
};
newComponents.push(newComponent);
});
@ -4757,7 +4818,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory, activeLayerId]);
}, [clipboard, layout, saveToHistory]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
@ -5657,21 +5718,152 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
};
}, [layout, selectedComponent]);
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
const handleRegionMouseDown = useCallback((
e: React.MouseEvent,
layerId: string,
mode: "move" | "resize",
handle?: string,
) => {
e.stopPropagation();
e.preventDefault();
const lid = Number(layerId);
const region = layerRegions[lid];
if (!region) 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: layerId,
startX: x,
startY: y,
currentX: x,
currentY: y,
resizeHandle: handle || null,
originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height },
});
}, [layerRegions, 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 lid = Number(regionDrag.targetLayerId);
setLayerRegions((prev) => ({
...prev,
[lid]: { ...prev[lid], ...newRegion },
}));
} 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 lid = Number(regionDrag.targetLayerId);
setLayerRegions((prev) => ({
...prev,
[lid]: { ...prev[lid], ...newRegion },
}));
}
}, [regionDrag, zoomLevel]);
const handleRegionCanvasMouseUp = useCallback(async () => {
// 드래그 완료 시 DB에 영역 저장
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) {
const lid = Number(regionDrag.targetLayerId);
const region = layerRegions[lid];
if (region) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid);
const existingCondition = layerData?.conditionConfig || {};
await screenApi.updateLayerCondition(
selectedScreen.screenId, lid,
{ ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } }
);
} catch {
console.error("영역 저장 실패");
}
}
}
// 드래그 상태 초기화
setRegionDrag({
isDrawing: false,
isDragging: false,
isResizing: false,
targetLayerId: null,
startX: 0, startY: 0, currentX: 0, currentY: 0,
resizeHandle: null,
originalRegion: null,
});
}, [regionDrag, layerRegions, selectedScreen]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
// 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
// components는 그대로 유지 - layerId 속성으로 레이어 구분
// components: prevLayout.components (기본값으로 유지됨)
}));
setLayout((prevLayout) => {
// 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합
const mergedLayers = newLayers.map((newLayer) => {
const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id);
if (!existingLayer) return newLayer;
// LayerContext에서 온 데이터(condition 등)를 우선하되,
// layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존
return {
...existingLayer, // 기존 메타데이터 보존 (displayRegion 등)
...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등)
// displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선
displayRegion: newLayer.displayRegion !== undefined
? newLayer.displayRegion
: existingLayer.displayRegion,
};
});
return {
...prevLayout,
layers: mergedLayers,
};
});
}, []);
// 🆕 활성 레이어 변경 핸들러
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
setActiveLayerIdLocal(newActiveLayerId);
}, []);
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
setActiveLayerIdWithRef(newActiveLayerId);
}, [setActiveLayerIdWithRef]);
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
@ -5751,7 +5943,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</button>
</div>
<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">
<TabsTrigger value="components" className="text-xs">
@ -5784,9 +5976,41 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
/>
</TabsContent>
{/* 🆕 레이어 관리 탭 */}
{/* 🆕 레이어 관리 탭 (DB 기반) */}
<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}
/>
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
@ -6359,6 +6583,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
</div>
);
})()}
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
{activeLayerId > 1 && (
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium"> {activeLayerId} </span>
</div>
)}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
<div
className="flex justify-center"
@ -6399,6 +6631,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
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) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
@ -6467,6 +6715,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return (
<>
{/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */}
{activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => {
const layerId = Number(layerIdStr);
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%)" },
};
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: 9999,
cursor: "move",
pointerEvents: "auto",
}}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
>
<span className="pointer-events-none absolute left-2 top-1 select-none text-[10px] font-medium text-primary">
{layerId} - {region.layerName}
</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] }}
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"
onClick={async (e) => {
e.stopPropagation();
if (!selectedScreen?.screenId) return;
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
const cond = layerData?.conditionConfig || {};
delete cond.displayRegion;
await screenApi.updateLayerCondition(selectedScreen.screenId, layerId, Object.keys(cond).length > 0 ? cond : null);
setLayerRegions((prev) => {
const next = { ...prev };
delete next[layerId];
return next;
});
} catch { toast.error("영역 삭제 실패"); }
}}
title="영역 삭제"
>
x
</button>
</div>
);
})}
{/* 일반 컴포넌트들 */}
{regularComponents.map((component) => {
const children =

View File

@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
id: screenToPreview!.screenId,
tableName: screenToPreview?.tableName,
}}
layers={previewLayout.layers || []}
/>
</div>
))}

View File

@ -157,7 +157,7 @@ const refreshToken = async (): Promise<string | null> => {
headers: {
Authorization: `Bearer ${currentToken}`,
},
}
},
);
if (response.data?.success && response.data?.data?.token) {
@ -192,13 +192,16 @@ const startAutoRefresh = (): void => {
}
// 10분마다 토큰 상태 확인
tokenRefreshTimer = setInterval(async () => {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
await refreshToken();
}
}, 10 * 60 * 1000); // 10분
tokenRefreshTimer = setInterval(
async () => {
const token = TokenManager.getToken();
if (token && TokenManager.isTokenExpiringSoon(token)) {
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
await refreshToken();
}
},
10 * 60 * 1000,
); // 10분
// 페이지 로드 시 즉시 확인
const token = TokenManager.getToken();
@ -230,14 +233,18 @@ const setupActivityBasedRefresh = (): void => {
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
// 너무 잦은 호출 방지를 위해 throttle 적용
let throttleTimer: NodeJS.Timeout | null = null;
window.addEventListener(event, () => {
if (!throttleTimer) {
throttleTimer = setTimeout(() => {
handleActivity();
throttleTimer = null;
}, 1000); // 1초 throttle
}
}, { passive: true });
window.addEventListener(
event,
() => {
if (!throttleTimer) {
throttleTimer = setTimeout(() => {
handleActivity();
throttleTimer = null;
}, 1000); // 1초 throttle
}
},
{ passive: true },
);
});
};

View File

@ -213,6 +213,28 @@ export const screenApi = {
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
},
// 🆕 레이어 목록 조회
getScreenLayers: async (screenId: number): Promise<any[]> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layers`);
return response.data.data || [];
},
// 🆕 특정 레이어 레이아웃 조회
getLayerLayout: async (screenId: number, layerId: number): Promise<any> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layers/${layerId}/layout`);
return response.data.data;
},
// 🆕 레이어 삭제
deleteLayer: async (screenId: number, layerId: number): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}/layers/${layerId}`);
},
// 🆕 레이어 조건 설정 업데이트
updateLayerCondition: async (screenId: number, layerId: number, conditionConfig: any, layerName?: string): Promise<void> => {
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
},
// 연결된 모달 화면 감지
detectLinkedModals: async (
screenId: number,

View File

@ -153,6 +153,14 @@ export const componentV2Schema = z.object({
// ============================================
export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]);
// 조건부 레이어 표시 영역 스키마
export const displayRegionSchema = z.object({
x: z.number().default(0),
y: z.number().default(0),
width: z.number().default(800),
height: z.number().default(200),
});
export const layerSchema = z.object({
id: z.string(),
name: z.string(),
@ -170,6 +178,9 @@ export const layerSchema = z.object({
})
.optional(),
// 조건부 레이어 표시 영역 (조건 미충족 시 사라짐)
displayRegion: displayRegionSchema.optional(),
// 모달/드로어 전용 설정
overlayConfig: z
.object({

View File

@ -28,6 +28,7 @@ interface LegacyComponentData {
interface LegacyLayoutData {
components: LegacyComponentData[];
layers?: any[]; // 레이어 시스템
gridSettings?: any;
screenResolution?: any;
metadata?: any;
@ -140,21 +141,22 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>):
// V2 → Legacy 변환 (로드 시)
// ============================================
export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | null {
if (!v2Layout || !v2Layout.components) {
if (!v2Layout) {
return null;
}
const components: LegacyComponentData[] = v2Layout.components.map((comp) => {
// V2 컴포넌트를 Legacy 컴포넌트로 변환하는 함수 (레이어 내 컴포넌트에도 재사용)
const convertV2Component = (comp: ComponentV2, layerId?: string): LegacyComponentData => {
const componentType = getComponentTypeFromUrl(comp.url);
const defaults = getDefaultsByUrl(comp.url);
let mergedConfig = mergeComponentConfig(defaults, comp.overrides);
// 🆕 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
// 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
if (componentType === "v2-split-panel-layout") {
mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig);
}
// 🆕 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
// 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
if (componentType === "v2-tabs-widget" && mergedConfig.tabs) {
mergedConfig = {
...mergedConfig,
@ -170,7 +172,6 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
};
}
// 🆕 overrides에서 상위 레벨 속성들 추출
const overrides = comp.overrides || {};
return {
@ -181,45 +182,68 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
position: comp.position,
size: comp.size,
componentConfig: mergedConfig,
// 🆕 상위 레벨 속성 복원 (테이블/컬럼 연결 정보)
// 상위 레벨 속성 복원
tableName: overrides.tableName,
columnName: overrides.columnName,
label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열
label: overrides.label || mergedConfig.label || "",
required: overrides.required,
readonly: overrides.readonly,
hidden: overrides.hidden, // 🆕 숨김 설정 복원
hidden: overrides.hidden,
codeCategory: overrides.codeCategory,
inputType: overrides.inputType,
webType: overrides.webType,
// 🆕 autoFill 설정 복원 (자동 입력 기능)
autoFill: overrides.autoFill,
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
style: overrides.style || {},
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
webTypeConfig: overrides.webTypeConfig || {},
// 기존 구조 호환을 위한 추가 필드
parentId: null,
gridColumns: 12,
gridRowIndex: 0,
// 🆕 레이어 ID 복원
...(layerId ? { layerId } : {}),
};
});
};
// 🆕 레이어 구조가 있는 경우 (v2.1)
const v2Layers = (v2Layout as any).layers;
if (v2Layers && Array.isArray(v2Layers) && v2Layers.length > 0) {
// 모든 레이어의 컴포넌트를 평탄화하여 layout.components에 저장 (layerId 포함)
const allComponents: LegacyComponentData[] = [];
const legacyLayers = v2Layers.map((layer: any) => {
const layerComponents = (layer.components || []).map((comp: any) =>
convertV2Component(comp, layer.id)
);
allComponents.push(...layerComponents);
return {
...layer,
// layer.components는 legacy 변환 시 빈 배열로 (layout.components + layerId 방식 사용)
components: [],
};
});
return {
components: allComponents,
layers: legacyLayers,
gridSettings: v2Layout.gridSettings || {
enabled: true, size: 20, color: "#d1d5db", opacity: 0.5,
snapToGrid: true, columns: 12, gap: 16, padding: 16,
},
screenResolution: v2Layout.screenResolution || { width: 1920, height: 1080 },
};
}
// 레이어 없는 기존 방식
if (!v2Layout.components) return null;
const components: LegacyComponentData[] = v2Layout.components.map((comp) => convertV2Component(comp));
return {
components,
gridSettings: v2Layout.gridSettings || {
enabled: true,
size: 20,
color: "#d1d5db",
opacity: 0.5,
snapToGrid: true,
columns: 12,
gap: 16,
padding: 16,
},
screenResolution: v2Layout.screenResolution || {
width: 1920,
height: 1080,
enabled: true, size: 20, color: "#d1d5db", opacity: 0.5,
snapToGrid: true, columns: 12, gap: 16, padding: 16,
},
screenResolution: v2Layout.screenResolution || { width: 1920, height: 1080 },
};
}
@ -227,7 +251,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
// Legacy → V2 변환 (저장 시)
// ============================================
export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
const components: ComponentV2[] = legacyLayout.components.map((comp, index) => {
// 컴포넌트 변환 함수 (레이어 내 컴포넌트 변환에도 재사용)
const convertComponent = (comp: LegacyComponentData, index: number): ComponentV2 => {
// 컴포넌트 타입 결정
const componentType = comp.componentType || comp.widgetType || comp.type || "unknown";
const url = getComponentUrl(componentType);
@ -301,12 +326,33 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
displayOrder: index,
overrides: overrides,
};
});
};
// 🆕 레이어 정보 변환 (layers가 있으면 레이어 구조로 저장)
const legacyLayers = (legacyLayout as any).layers;
if (legacyLayers && Array.isArray(legacyLayers) && legacyLayers.length > 0) {
const v2Layers = legacyLayers.map((layer: any) => ({
...layer,
// 레이어 내 컴포넌트를 V2 형식으로 변환
components: (layer.components || []).map((comp: any, idx: number) => convertComponent(comp, idx)),
}));
return {
version: "2.1",
layers: v2Layers,
components: [], // 레이어 구조 사용 시 상위 components는 빈 배열
gridSettings: legacyLayout.gridSettings,
screenResolution: legacyLayout.screenResolution,
metadata: legacyLayout.metadata,
};
}
// 레이어 없으면 기존 방식 (컴포넌트만)
const components = legacyLayout.components.map((comp, index) => convertComponent(comp, index));
return {
version: "2.0",
components,
// 레이아웃 메타데이터 포함
gridSettings: legacyLayout.gridSettings,
screenResolution: legacyLayout.screenResolution,
metadata: legacyLayout.metadata,
@ -317,7 +363,11 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
// V2 레이아웃 유효성 검사
// ============================================
export function isValidV2Layout(data: any): data is LayoutV2 {
return data && typeof data === "object" && data.version === "2.0" && Array.isArray(data.components);
if (!data || typeof data !== "object") return false;
// v2.0: components 기반, v2.1: layers 기반
const isV2 = data.version === "2.0" && Array.isArray(data.components);
const isV21 = data.version === "2.1" && Array.isArray(data.layers);
return isV2 || isV21;
}
// ============================================

View File

@ -38,8 +38,8 @@ export interface BaseComponent {
gridColumnStart?: number; // 시작 컬럼 (1-12)
gridRowIndex?: number; // 행 인덱스
// 🆕 레이어 시스템
layerId?: string; // 컴포넌트가 속한 레이어 ID
// 🆕 레이어 시스템 (DB layer_id: 1=기본, 2+=조건부)
layerId?: string | number; // 컴포넌트가 속한 레이어 ID
parentId?: string;
label?: string;
@ -876,6 +876,17 @@ export interface LayerOverlayConfig {
position?: "left" | "right" | "top" | "bottom"; // 드로어 위치
}
/**
*
* ,
*/
export interface DisplayRegion {
x: number;
y: number;
width: number;
height: number;
}
/**
*
*/
@ -890,6 +901,9 @@ export interface LayerDefinition {
// 조건부 표시 로직
condition?: LayerCondition;
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
displayRegion?: DisplayRegion;
// 모달/드로어 전용 설정
overlayConfig?: LayerOverlayConfig;