jskim-node #387
|
|
@ -30,10 +30,13 @@ export class EntityReferenceController {
|
||||||
try {
|
try {
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const { limit = 100, search } = req.query;
|
const { limit = 100, search } = req.query;
|
||||||
|
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
|
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
|
||||||
limit,
|
limit,
|
||||||
search,
|
search,
|
||||||
|
companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컬럼 정보 조회 (table_type_columns에서)
|
// 컬럼 정보 조회 (table_type_columns에서)
|
||||||
|
|
@ -89,16 +92,34 @@ export class EntityReferenceController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 쿼리로 참조 데이터 조회
|
// 참조 테이블에 company_code 컬럼이 있는지 확인
|
||||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
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[] = [];
|
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) {
|
if (search) {
|
||||||
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
|
||||||
queryParams.push(`%${search}%`);
|
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}`;
|
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||||
queryParams.push(Number(limit));
|
queryParams.push(Number(limit));
|
||||||
|
|
||||||
|
|
@ -107,6 +128,7 @@ export class EntityReferenceController {
|
||||||
referenceTable,
|
referenceTable,
|
||||||
referenceColumn,
|
referenceColumn,
|
||||||
displayColumn,
|
displayColumn,
|
||||||
|
companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const referenceData = await query<any>(sqlQuery, queryParams);
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -149,13 +171,16 @@ export class EntityReferenceController {
|
||||||
try {
|
try {
|
||||||
const { codeCategory } = req.params;
|
const { codeCategory } = req.params;
|
||||||
const { limit = 100, search } = req.query;
|
const { limit = 100, search } = req.query;
|
||||||
|
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
|
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
|
||||||
limit,
|
limit,
|
||||||
search,
|
search,
|
||||||
|
companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// code_info 테이블에서 코드 데이터 조회
|
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
|
||||||
const queryParams: any[] = [codeCategory, 'Y'];
|
const queryParams: any[] = [codeCategory, 'Y'];
|
||||||
let sqlQuery = `
|
let sqlQuery = `
|
||||||
SELECT code_value, code_name
|
SELECT code_value, code_name
|
||||||
|
|
@ -163,9 +188,16 @@ export class EntityReferenceController {
|
||||||
WHERE code_category = $1 AND is_active = $2
|
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) {
|
if (search) {
|
||||||
sqlQuery += ` AND code_name ILIKE $3`;
|
|
||||||
queryParams.push(`%${search}%`);
|
queryParams.push(`%${search}%`);
|
||||||
|
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
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 codeData = await query<any>(sqlQuery, queryParams);
|
||||||
|
|
||||||
// 옵션 형태로 변환
|
// 옵션 형태로 변환
|
||||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
|
||||||
value: code.code_value,
|
value: code.code_value,
|
||||||
label: code.code_name,
|
label: code.code_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
|
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
export const generateScreenCode = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ import {
|
||||||
copyCategoryMapping,
|
copyCategoryMapping,
|
||||||
copyTableTypeColumns,
|
copyTableTypeColumns,
|
||||||
copyCascadingRelation,
|
copyCascadingRelation,
|
||||||
|
getScreenLayers,
|
||||||
|
getLayerLayout,
|
||||||
|
deleteLayer,
|
||||||
|
updateLayerCondition,
|
||||||
} from "../controllers/screenManagementController";
|
} from "../controllers/screenManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
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.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
|
||||||
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
|
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.post("/screens/:screenId/assign-menu", assignScreenToMenu);
|
||||||
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
||||||
|
|
|
||||||
|
|
@ -4245,11 +4245,11 @@ export class ScreenManagementService {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// V2 레이아웃 저장 (UPSERT)
|
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, NOW(), NOW())
|
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||||
ON CONFLICT (screen_id, company_code)
|
ON CONFLICT (screen_id, company_code, layer_id)
|
||||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||||
);
|
);
|
||||||
|
|
@ -5073,38 +5073,63 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
let layout: { layout_data: any } | null = null;
|
let layout: { layout_data: any } | null = null;
|
||||||
|
|
||||||
|
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||||
layout = await queryOne<{ layout_data: any }>(
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`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],
|
[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) {
|
if (!layout) {
|
||||||
layout = await queryOne<{ layout_data: any }>(
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
WHERE screen_id = $1
|
WHERE screen_id = $1
|
||||||
ORDER BY updated_at DESC
|
ORDER BY layer_id ASC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[screenId],
|
[screenId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
|
// 일반 사용자: 회사별 우선 + 기본 레이어
|
||||||
layout = await queryOne<{ layout_data: any }>(
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`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],
|
[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 !== "*") {
|
if (!layout && companyCode !== "*") {
|
||||||
layout = await queryOne<{ layout_data: any }>(
|
layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`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],
|
[screenId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5122,17 +5147,21 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2 레이아웃 저장 (1 레코드 방식)
|
* V2 레이아웃 저장 (레이어별 저장)
|
||||||
* - screen_layouts_v2 테이블에 화면당 1개 레코드 저장
|
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
|
||||||
* - layout_data JSON에 모든 컴포넌트 포함
|
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
|
||||||
*/
|
*/
|
||||||
async saveLayoutV2(
|
async saveLayoutV2(
|
||||||
screenId: number,
|
screenId: number,
|
||||||
layoutData: any,
|
layoutData: any,
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const layerId = layoutData.layerId || 1;
|
||||||
|
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
|
||||||
|
const conditionConfig = layoutData.conditionConfig || null;
|
||||||
|
|
||||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
|
||||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
|
|
@ -5151,22 +5180,173 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
|
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
||||||
|
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
...layoutData
|
...pureLayoutData,
|
||||||
};
|
};
|
||||||
|
|
||||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
// UPSERT (레이어별 저장)
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
`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, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
ON CONFLICT (screen_id, company_code)
|
ON CONFLICT (screen_id, company_code, layer_id)
|
||||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||||
[screenId, companyCode, JSON.stringify(dataToSave)],
|
[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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,60 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
});
|
});
|
||||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
}, [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
|
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||||
? useFormValidation(
|
? useFormValidation(
|
||||||
|
|
@ -2198,6 +2252,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
|
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
|
||||||
|
const allLayerComponents = useMemo(() => {
|
||||||
|
return layers.flatMap((layer) => layer.components);
|
||||||
|
}, [layers]);
|
||||||
|
|
||||||
// 🆕 레이어별 컴포넌트 렌더링 함수
|
// 🆕 레이어별 컴포넌트 렌더링 함수
|
||||||
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
||||||
// 활성화되지 않은 레이어는 렌더링하지 않음
|
// 활성화되지 않은 레이어는 렌더링하지 않음
|
||||||
|
|
@ -2234,7 +2293,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={layer.components}
|
allComponents={allLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2306,7 +2365,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={layer.components}
|
allComponents={allLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2326,30 +2385,36 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
className="pointer-events-none absolute inset-0"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{ zIndex: layer.zIndex }}
|
style={{ zIndex: layer.zIndex }}
|
||||||
>
|
>
|
||||||
{layer.components.map((comp) => (
|
{layer.components.map((comp) => {
|
||||||
<div
|
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
|
||||||
key={comp.id}
|
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||||
className="pointer-events-auto absolute"
|
const adjustedY = comp.position.y - yOffset;
|
||||||
style={{
|
|
||||||
left: `${comp.position.x}px`,
|
return (
|
||||||
top: `${comp.position.y}px`,
|
<div
|
||||||
width: comp.style?.width || `${comp.size.width}px`,
|
key={comp.id}
|
||||||
height: comp.style?.height || `${comp.size.height}px`,
|
className="pointer-events-auto absolute"
|
||||||
zIndex: comp.position.z || 1,
|
style={{
|
||||||
}}
|
left: `${comp.position.x}px`,
|
||||||
>
|
top: `${adjustedY}px`,
|
||||||
<InteractiveScreenViewer
|
width: comp.style?.width || `${comp.size.width}px`,
|
||||||
component={comp}
|
height: comp.style?.height || `${comp.size.height}px`,
|
||||||
allComponents={layer.components}
|
zIndex: comp.position.z || 1,
|
||||||
formData={externalFormData}
|
}}
|
||||||
onFormDataChange={onFormDataChange}
|
>
|
||||||
screenInfo={screenInfo}
|
<InteractiveScreenViewer
|
||||||
/>
|
component={comp}
|
||||||
</div>
|
allComponents={allLayerComponents}
|
||||||
))}
|
formData={externalFormData}
|
||||||
|
onFormDataChange={onFormDataChange}
|
||||||
|
screenInfo={screenInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
|
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitPanelProvider>
|
<SplitPanelProvider>
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,28 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { 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 { 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 {
|
interface LayerConditionPanelProps {
|
||||||
layer: LayerDefinition;
|
layer: LayerDefinition;
|
||||||
components: ComponentData[]; // 화면의 모든 컴포넌트
|
components: ComponentData[]; // 화면의 모든 컴포넌트
|
||||||
|
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
|
||||||
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
||||||
|
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +47,9 @@ type OperatorType = "eq" | "neq" | "in";
|
||||||
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
layer,
|
layer,
|
||||||
components,
|
components,
|
||||||
|
baseLayerComponents,
|
||||||
onUpdateCondition,
|
onUpdateCondition,
|
||||||
|
onUpdateDisplayRegion,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
// 조건 설정 상태
|
// 조건 설정 상태
|
||||||
|
|
@ -51,75 +66,246 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
||||||
);
|
);
|
||||||
|
|
||||||
// 코드 목록 로딩 상태
|
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
|
||||||
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
|
const [options, setOptions] = useState<ConditionOption[]>([]);
|
||||||
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||||
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
|
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
|
||||||
const triggerableComponents = useMemo(() => {
|
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 componentType = (comp.componentType || "").toLowerCase();
|
||||||
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
||||||
const webType = ((comp as any).webType || "").toLowerCase();
|
const webType = ((comp as any).webType || "").toLowerCase();
|
||||||
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
||||||
|
|
||||||
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
||||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
|
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
|
||||||
const isTriggerType = triggerTypes.some((type) =>
|
return triggerTypes.some((type) =>
|
||||||
componentType.includes(type) ||
|
componentType.includes(type) ||
|
||||||
widgetType.includes(type) ||
|
widgetType.includes(type) ||
|
||||||
webType.includes(type) ||
|
webType.includes(type) ||
|
||||||
inputType.includes(type)
|
inputType.includes(type)
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return isTriggerType;
|
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
|
||||||
});
|
const baseLayerIds = new Set(
|
||||||
}, [components]);
|
(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(() => {
|
const selectedComponent = useMemo(() => {
|
||||||
return components.find((c) => c.id === targetComponentId);
|
return components.find((c) => c.id === targetComponentId);
|
||||||
}, [components, targetComponentId]);
|
}, [components, targetComponentId]);
|
||||||
|
|
||||||
// 선택된 컴포넌트의 코드 카테고리
|
// 선택된 컴포넌트의 데이터 소스 정보 추출
|
||||||
const codeCategory = useMemo(() => {
|
const dataSourceInfo = useMemo<{
|
||||||
if (!selectedComponent) return null;
|
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 comp = selectedComponent as any;
|
||||||
const category =
|
const config = comp.componentConfig || comp.webTypeConfig || {};
|
||||||
(selectedComponent as any).codeCategory ||
|
const detailSettings = comp.detailSettings || {};
|
||||||
(selectedComponent as any).componentConfig?.codeCategory ||
|
|
||||||
(selectedComponent as any).webTypeConfig?.codeCategory;
|
|
||||||
|
|
||||||
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]);
|
}, [selectedComponent]);
|
||||||
|
|
||||||
// 컴포넌트 선택 시 코드 목록 로드
|
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!codeCategory) {
|
if (dataSourceInfo.type === "none") {
|
||||||
setCodeOptions([]);
|
setOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadCodes = async () => {
|
// 정적 옵션은 즉시 설정
|
||||||
setIsLoadingCodes(true);
|
if (dataSourceInfo.type === "static") {
|
||||||
setCodeLoadError(null);
|
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 {
|
try {
|
||||||
const codes = await getCodesByCategory(codeCategory);
|
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
|
||||||
setCodeOptions(codes);
|
// 카테고리 값에서 옵션 로드 (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) {
|
} catch (error: any) {
|
||||||
console.error("코드 목록 로드 실패:", error);
|
console.error("옵션 목록 로드 실패:", error);
|
||||||
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
|
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
||||||
setCodeOptions([]);
|
setOptions([]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingCodes(false);
|
setIsLoadingOptions(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCodes();
|
loadOptions();
|
||||||
}, [codeCategory]);
|
}, [dataSourceInfo]);
|
||||||
|
|
||||||
// 조건 저장
|
// 조건 저장
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
|
|
@ -180,36 +366,91 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
<SelectValue placeholder="컴포넌트 선택..." />
|
<SelectValue placeholder="컴포넌트 선택..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{triggerableComponents.length === 0 ? (
|
{triggerableComponents.baseLayerTriggers.length === 0 &&
|
||||||
|
triggerableComponents.otherLayerTriggers.length === 0 ? (
|
||||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||||
조건 설정 가능한 컴포넌트가 없습니다.
|
조건 설정 가능한 컴포넌트가 없습니다.
|
||||||
<br />
|
<br />
|
||||||
(셀렉트, 라디오, 코드 타입)
|
(셀렉트, 라디오, 코드 타입)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
triggerableComponents.map((comp) => (
|
<>
|
||||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
{/* 기본 레이어 컴포넌트 (우선 표시) */}
|
||||||
<div className="flex items-center gap-2">
|
{triggerableComponents.baseLayerTriggers.length > 0 && (
|
||||||
<span>{getComponentLabel(comp)}</span>
|
<>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||||
{comp.componentType || (comp as any).widgetType}
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||||||
</Badge>
|
기본 레이어
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
)}
|
||||||
))
|
{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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* 코드 카테고리 표시 */}
|
{/* 데이터 소스 표시 */}
|
||||||
{codeCategory && (
|
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<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>
|
<span>카테고리:</span>
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{codeCategory}
|
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{dataSourceInfo.type === "static" && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>정적 옵션</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 연산자 선택 */}
|
||||||
|
|
@ -241,42 +482,41 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
{operator === "in" ? "값 선택 (복수)" : "값"}
|
{operator === "in" ? "값 선택 (복수)" : "값"}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{isLoadingCodes ? (
|
{isLoadingOptions ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
코드 목록 로딩 중...
|
옵션 목록 로딩 중...
|
||||||
</div>
|
</div>
|
||||||
) : codeLoadError ? (
|
) : loadError ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
||||||
<AlertCircle className="h-3 w-3" />
|
<AlertCircle className="h-3 w-3" />
|
||||||
{codeLoadError}
|
{loadError}
|
||||||
</div>
|
</div>
|
||||||
) : codeOptions.length > 0 ? (
|
) : options.length > 0 ? (
|
||||||
// 코드 카테고리가 있는 경우 - 선택 UI
|
// 옵션이 있는 경우 - 선택 UI
|
||||||
operator === "in" ? (
|
operator === "in" ? (
|
||||||
// 다중 선택 (in 연산자)
|
// 다중 선택 (in 연산자)
|
||||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||||
{codeOptions.map((code) => (
|
{options.map((opt) => (
|
||||||
<div
|
<div
|
||||||
key={code.codeValue}
|
key={opt.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
"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(
|
<div className={cn(
|
||||||
"w-4 h-4 rounded border flex items-center justify-center",
|
"w-4 h-4 rounded border flex items-center justify-center",
|
||||||
multiValues.includes(code.codeValue)
|
multiValues.includes(opt.value)
|
||||||
? "bg-primary border-primary"
|
? "bg-primary border-primary"
|
||||||
: "border-input"
|
: "border-input"
|
||||||
)}>
|
)}>
|
||||||
{multiValues.includes(code.codeValue) && (
|
{multiValues.includes(opt.value) && (
|
||||||
<Check className="h-3 w-3 text-primary-foreground" />
|
<Check className="h-3 w-3 text-primary-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span>{code.codeName}</span>
|
<span>{opt.label}</span>
|
||||||
<span className="text-muted-foreground">({code.codeValue})</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,20 +527,20 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
<SelectValue placeholder="값 선택..." />
|
<SelectValue placeholder="값 선택..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{codeOptions.map((code) => (
|
{options.map((opt) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={code.codeValue}
|
key={opt.value}
|
||||||
value={code.codeValue}
|
value={opt.value}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{code.codeName} ({code.codeValue})
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
// 코드 카테고리가 없는 경우 - 직접 입력
|
// 옵션이 없는 경우 - 직접 입력
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
|
@ -313,14 +553,14 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
{operator === "in" && multiValues.length > 0 && (
|
{operator === "in" && multiValues.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{multiValues.map((val) => {
|
{multiValues.map((val) => {
|
||||||
const code = codeOptions.find((c) => c.codeValue === val);
|
const opt = options.find((o) => o.value === val);
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
key={val}
|
key={val}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[10px] gap-1"
|
className="text-[10px] gap-1"
|
||||||
>
|
>
|
||||||
{code?.codeName || val}
|
{opt?.label || val}
|
||||||
<X
|
<X
|
||||||
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
||||||
onClick={() => toggleMultiValue(val)}
|
onClick={() => toggleMultiValue(val)}
|
||||||
|
|
@ -339,14 +579,60 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||||
<span className="font-medium">요약: </span>
|
<span className="font-medium">요약: </span>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
||||||
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
|
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
|
||||||
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
|
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
|
||||||
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
|
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
|
||||||
{" "}이 레이어 표시
|
{" "}이 레이어 표시
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,404 +1,323 @@
|
||||||
import React, { useState, useMemo, useCallback } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { useLayer } from "@/contexts/LayerContext";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Lock,
|
|
||||||
Unlock,
|
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Layers,
|
Layers,
|
||||||
SplitSquareVertical,
|
SplitSquareVertical,
|
||||||
PanelRight,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Settings2,
|
|
||||||
Zap,
|
Zap,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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 { LayerConditionPanel } from "./LayerConditionPanel";
|
||||||
|
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
|
||||||
|
|
||||||
// 레이어 타입별 아이콘
|
// DB 레이어 타입
|
||||||
const getLayerTypeIcon = (type: LayerType) => {
|
interface DBLayer {
|
||||||
switch (type) {
|
layer_id: number;
|
||||||
case "base":
|
layer_name: string;
|
||||||
return <Layers className="h-3 w-3" />;
|
condition_config: any;
|
||||||
case "conditional":
|
component_count: number;
|
||||||
return <SplitSquareVertical className="h-3 w-3" />;
|
updated_at: string;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이어 타입별 색상
|
|
||||||
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 {
|
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 = [] }) => {
|
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||||
const {
|
screenId,
|
||||||
layers,
|
activeLayerId,
|
||||||
activeLayerId,
|
onLayerChange,
|
||||||
setActiveLayerId,
|
components = [],
|
||||||
addLayer,
|
}) => {
|
||||||
removeLayer,
|
const [layers, setLayers] = useState<DBLayer[]>([]);
|
||||||
toggleLayerVisibility,
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
toggleLayerLock,
|
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
|
||||||
updateLayer,
|
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
|
||||||
} = useLayer();
|
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
|
||||||
|
|
||||||
// 레이어 조건 업데이트 핸들러
|
// 레이어 목록 로드
|
||||||
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
|
const loadLayers = useCallback(async () => {
|
||||||
updateLayer(layerId, { condition });
|
if (!screenId) return;
|
||||||
}, [updateLayer]);
|
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 loadBaseLayerComponents = useCallback(async () => {
|
||||||
const counts: Record<string, number> = {};
|
if (!screenId) return;
|
||||||
|
try {
|
||||||
// 모든 레이어를 0으로 초기화
|
const data = await screenApi.getLayerLayout(screenId, 1);
|
||||||
layers.forEach(layer => {
|
if (data && data.components) {
|
||||||
counts[layer.id] = 0;
|
const legacy = convertV2ToLegacy(data);
|
||||||
});
|
if (legacy) {
|
||||||
|
setBaseLayerComponents(legacy.components as ComponentData[]);
|
||||||
// layout.components에서 layerId별로 카운트
|
return;
|
||||||
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"]++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
setBaseLayerComponents([]);
|
||||||
|
} catch {
|
||||||
|
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
|
||||||
|
setBaseLayerComponents(components);
|
||||||
|
}
|
||||||
|
}, [screenId, components]);
|
||||||
|
|
||||||
return counts;
|
useEffect(() => {
|
||||||
}, [components, layers]);
|
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 (
|
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 justify-between border-b px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||||
<h3 className="text-sm font-semibold">레이어</h3>
|
<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}
|
{layers.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 레이어 추가 드롭다운 */}
|
<Button
|
||||||
<DropdownMenu>
|
variant="ghost"
|
||||||
<DropdownMenuTrigger asChild>
|
size="sm"
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
|
className="h-7 gap-1 px-2"
|
||||||
<Plus className="h-3.5 w-3.5" />
|
onClick={handleAddLayer}
|
||||||
<ChevronDown className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</DropdownMenuTrigger>
|
추가
|
||||||
<DropdownMenuContent align="end">
|
</Button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 레이어 목록 */}
|
{/* 레이어 목록 */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
{layers.length === 0 ? (
|
{isLoading ? (
|
||||||
<div className="text-center text-muted-foreground text-sm py-8">
|
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||||
레이어가 없습니다.
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<br />
|
<span className="text-sm">로딩 중...</span>
|
||||||
<span className="text-xs">위의 + 버튼으로 추가하세요.</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
layers
|
layers
|
||||||
.slice()
|
.slice()
|
||||||
.reverse() // 상위 레이어가 위에 표시
|
.reverse()
|
||||||
.map((layer) => (
|
.map((layer) => {
|
||||||
<LayerItem
|
const isActive = activeLayerId === layer.layer_id;
|
||||||
key={layer.id}
|
const isBase = layer.layer_id === 1;
|
||||||
layer={layer}
|
const hasCondition = !!layer.condition_config;
|
||||||
isActive={activeLayerId === layer.id}
|
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
|
||||||
componentCount={componentCountByLayer[layer.id] || 0}
|
|
||||||
allComponents={components}
|
return (
|
||||||
onSelect={() => setActiveLayerId(layer.id)}
|
<div key={layer.layer_id} className="space-y-0">
|
||||||
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
|
<div
|
||||||
onToggleLock={() => toggleLayerLock(layer.id)}
|
className={cn(
|
||||||
onRemove={() => removeLayer(layer.id)}
|
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||||
onUpdateName={(name) => updateLayer(layer.id, { name })}
|
isActive
|
||||||
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
|
? "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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* 도움말 */}
|
{/* 도움말 */}
|
||||||
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
||||||
<p>더블클릭: 이름 편집 | 드래그: 순서 변경</p>
|
<p>레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
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 {
|
||||||
|
|
@ -500,25 +501,49 @@ 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")에 속한 것으로 처리
|
|
||||||
|
// 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 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(() => {
|
const visibleComponents = useMemo(() => {
|
||||||
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
|
return layout.components;
|
||||||
if (!activeLayerId) {
|
}, [layout.components]);
|
||||||
return layout.components;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 활성 레이어에 속한 컴포넌트만 필터링
|
|
||||||
return layout.components.filter((comp) => {
|
|
||||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
|
||||||
const compLayerId = comp.layerId || "default-layer";
|
|
||||||
return compLayerId === activeLayerId;
|
|
||||||
});
|
|
||||||
}, [layout.components, activeLayerId]);
|
|
||||||
|
|
||||||
// 이미 배치된 컬럼 목록 계산
|
// 이미 배치된 컬럼 목록 계산
|
||||||
const placedColumns = useMemo(() => {
|
const placedColumns = useMemo(() => {
|
||||||
|
|
@ -1532,6 +1557,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
// 파일 컴포넌트 데이터 복원 (비동기)
|
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
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) {
|
} catch (error) {
|
||||||
// console.error("레이아웃 로드 실패:", 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 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 API 사용 여부에 따라 분기
|
||||||
if (USE_V2_API) {
|
if (USE_V2_API) {
|
||||||
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
|
// 🆕 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||||
|
const currentLayerId = activeLayerIdRef.current || 1;
|
||||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
// layerId를 포함하여 저장
|
||||||
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
|
...v2Layout,
|
||||||
|
layerId: currentLayerId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
}
|
}
|
||||||
|
|
@ -2505,10 +2531,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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 레이아웃에 새 컴포넌트들 추가
|
// 레이아웃에 새 컴포넌트들 추가
|
||||||
|
|
@ -2527,7 +2553,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
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,
|
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;
|
||||||
|
|
||||||
// 레이아웃에 새 컴포넌트 추가
|
// 레이아웃에 새 컴포넌트 추가
|
||||||
|
|
@ -2598,7 +2624,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으로 대체됨
|
||||||
|
|
@ -3189,7 +3215,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, // 웹타입 정보 추가
|
||||||
|
|
@ -3223,7 +3249,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
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(
|
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");
|
||||||
|
|
@ -3264,6 +3290,41 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
return;
|
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;
|
const { type, table, column } = parsedData;
|
||||||
|
|
||||||
|
|
@ -3595,7 +3656,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",
|
||||||
|
|
@ -3846,7 +3907,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 && {
|
||||||
|
|
@ -3913,7 +3974,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 && {
|
||||||
|
|
@ -4736,7 +4797,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);
|
||||||
});
|
});
|
||||||
|
|
@ -4757,7 +4818,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]);
|
||||||
|
|
||||||
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
||||||
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
||||||
|
|
@ -5657,21 +5718,152 @@ 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 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에도 반영
|
||||||
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
|
// 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존
|
||||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||||
setLayout((prevLayout) => ({
|
setLayout((prevLayout) => {
|
||||||
...prevLayout,
|
// 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합
|
||||||
layers: newLayers,
|
const mergedLayers = newLayers.map((newLayer) => {
|
||||||
// components는 그대로 유지 - layerId 속성으로 레이어 구분
|
const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id);
|
||||||
// components: prevLayout.components (기본값으로 유지됨)
|
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) => {
|
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는 비워둠
|
||||||
|
|
@ -5751,7 +5943,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">
|
||||||
컴포넌트
|
컴포넌트
|
||||||
|
|
@ -5784,9 +5976,41 @@ 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}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||||
|
|
@ -6359,6 +6583,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
</div>
|
</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 최적화 */}
|
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||||
<div
|
<div
|
||||||
className="flex justify-center"
|
className="flex justify-center"
|
||||||
|
|
@ -6399,6 +6631,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";
|
||||||
|
|
@ -6467,6 +6715,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
|
|
||||||
return (
|
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) => {
|
{regularComponents.map((component) => {
|
||||||
const children =
|
const children =
|
||||||
|
|
|
||||||
|
|
@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
id: screenToPreview!.screenId,
|
id: screenToPreview!.screenId,
|
||||||
tableName: screenToPreview?.tableName,
|
tableName: screenToPreview?.tableName,
|
||||||
}}
|
}}
|
||||||
|
layers={previewLayout.layers || []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ const refreshToken = async (): Promise<string | null> => {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${currentToken}`,
|
Authorization: `Bearer ${currentToken}`,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data?.success && response.data?.data?.token) {
|
if (response.data?.success && response.data?.data?.token) {
|
||||||
|
|
@ -192,13 +192,16 @@ const startAutoRefresh = (): void => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10분마다 토큰 상태 확인
|
// 10분마다 토큰 상태 확인
|
||||||
tokenRefreshTimer = setInterval(async () => {
|
tokenRefreshTimer = setInterval(
|
||||||
const token = TokenManager.getToken();
|
async () => {
|
||||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
const token = TokenManager.getToken();
|
||||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||||
await refreshToken();
|
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||||
}
|
await refreshToken();
|
||||||
}, 10 * 60 * 1000); // 10분
|
}
|
||||||
|
},
|
||||||
|
10 * 60 * 1000,
|
||||||
|
); // 10분
|
||||||
|
|
||||||
// 페이지 로드 시 즉시 확인
|
// 페이지 로드 시 즉시 확인
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
|
|
@ -230,14 +233,18 @@ const setupActivityBasedRefresh = (): void => {
|
||||||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||||
let throttleTimer: NodeJS.Timeout | null = null;
|
let throttleTimer: NodeJS.Timeout | null = null;
|
||||||
window.addEventListener(event, () => {
|
window.addEventListener(
|
||||||
if (!throttleTimer) {
|
event,
|
||||||
throttleTimer = setTimeout(() => {
|
() => {
|
||||||
handleActivity();
|
if (!throttleTimer) {
|
||||||
throttleTimer = null;
|
throttleTimer = setTimeout(() => {
|
||||||
}, 1000); // 1초 throttle
|
handleActivity();
|
||||||
}
|
throttleTimer = null;
|
||||||
}, { passive: true });
|
}, 1000); // 1초 throttle
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,28 @@ export const screenApi = {
|
||||||
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
|
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 (
|
detectLinkedModals: async (
|
||||||
screenId: number,
|
screenId: number,
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,14 @@ export const componentV2Schema = z.object({
|
||||||
// ============================================
|
// ============================================
|
||||||
export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]);
|
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({
|
export const layerSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|
@ -170,6 +178,9 @@ export const layerSchema = z.object({
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
|
// 조건부 레이어 표시 영역 (조건 미충족 시 사라짐)
|
||||||
|
displayRegion: displayRegionSchema.optional(),
|
||||||
|
|
||||||
// 모달/드로어 전용 설정
|
// 모달/드로어 전용 설정
|
||||||
overlayConfig: z
|
overlayConfig: z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ interface LegacyComponentData {
|
||||||
|
|
||||||
interface LegacyLayoutData {
|
interface LegacyLayoutData {
|
||||||
components: LegacyComponentData[];
|
components: LegacyComponentData[];
|
||||||
|
layers?: any[]; // 레이어 시스템
|
||||||
gridSettings?: any;
|
gridSettings?: any;
|
||||||
screenResolution?: any;
|
screenResolution?: any;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
|
|
@ -140,21 +141,22 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>):
|
||||||
// V2 → Legacy 변환 (로드 시)
|
// V2 → Legacy 변환 (로드 시)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | null {
|
export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | null {
|
||||||
if (!v2Layout || !v2Layout.components) {
|
if (!v2Layout) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const components: LegacyComponentData[] = v2Layout.components.map((comp) => {
|
// V2 컴포넌트를 Legacy 컴포넌트로 변환하는 함수 (레이어 내 컴포넌트에도 재사용)
|
||||||
|
const convertV2Component = (comp: ComponentV2, layerId?: string): LegacyComponentData => {
|
||||||
const componentType = getComponentTypeFromUrl(comp.url);
|
const componentType = getComponentTypeFromUrl(comp.url);
|
||||||
const defaults = getDefaultsByUrl(comp.url);
|
const defaults = getDefaultsByUrl(comp.url);
|
||||||
let mergedConfig = mergeComponentConfig(defaults, comp.overrides);
|
let mergedConfig = mergeComponentConfig(defaults, comp.overrides);
|
||||||
|
|
||||||
// 🆕 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
|
// 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
|
||||||
if (componentType === "v2-split-panel-layout") {
|
if (componentType === "v2-split-panel-layout") {
|
||||||
mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig);
|
mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
|
// 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
|
||||||
if (componentType === "v2-tabs-widget" && mergedConfig.tabs) {
|
if (componentType === "v2-tabs-widget" && mergedConfig.tabs) {
|
||||||
mergedConfig = {
|
mergedConfig = {
|
||||||
...mergedConfig,
|
...mergedConfig,
|
||||||
|
|
@ -170,7 +172,6 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 overrides에서 상위 레벨 속성들 추출
|
|
||||||
const overrides = comp.overrides || {};
|
const overrides = comp.overrides || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -181,45 +182,68 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
position: comp.position,
|
position: comp.position,
|
||||||
size: comp.size,
|
size: comp.size,
|
||||||
componentConfig: mergedConfig,
|
componentConfig: mergedConfig,
|
||||||
// 🆕 상위 레벨 속성 복원 (테이블/컬럼 연결 정보)
|
// 상위 레벨 속성 복원
|
||||||
tableName: overrides.tableName,
|
tableName: overrides.tableName,
|
||||||
columnName: overrides.columnName,
|
columnName: overrides.columnName,
|
||||||
label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열
|
label: overrides.label || mergedConfig.label || "",
|
||||||
required: overrides.required,
|
required: overrides.required,
|
||||||
readonly: overrides.readonly,
|
readonly: overrides.readonly,
|
||||||
hidden: overrides.hidden, // 🆕 숨김 설정 복원
|
hidden: overrides.hidden,
|
||||||
codeCategory: overrides.codeCategory,
|
codeCategory: overrides.codeCategory,
|
||||||
inputType: overrides.inputType,
|
inputType: overrides.inputType,
|
||||||
webType: overrides.webType,
|
webType: overrides.webType,
|
||||||
// 🆕 autoFill 설정 복원 (자동 입력 기능)
|
|
||||||
autoFill: overrides.autoFill,
|
autoFill: overrides.autoFill,
|
||||||
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
|
|
||||||
style: overrides.style || {},
|
style: overrides.style || {},
|
||||||
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
|
|
||||||
webTypeConfig: overrides.webTypeConfig || {},
|
webTypeConfig: overrides.webTypeConfig || {},
|
||||||
// 기존 구조 호환을 위한 추가 필드
|
|
||||||
parentId: null,
|
parentId: null,
|
||||||
gridColumns: 12,
|
gridColumns: 12,
|
||||||
gridRowIndex: 0,
|
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 {
|
return {
|
||||||
components,
|
components,
|
||||||
gridSettings: v2Layout.gridSettings || {
|
gridSettings: v2Layout.gridSettings || {
|
||||||
enabled: true,
|
enabled: true, size: 20, color: "#d1d5db", opacity: 0.5,
|
||||||
size: 20,
|
snapToGrid: true, columns: 12, gap: 16, padding: 16,
|
||||||
color: "#d1d5db",
|
|
||||||
opacity: 0.5,
|
|
||||||
snapToGrid: true,
|
|
||||||
columns: 12,
|
|
||||||
gap: 16,
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
screenResolution: v2Layout.screenResolution || {
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
},
|
},
|
||||||
|
screenResolution: v2Layout.screenResolution || { width: 1920, height: 1080 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,7 +251,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
// Legacy → V2 변환 (저장 시)
|
// Legacy → V2 변환 (저장 시)
|
||||||
// ============================================
|
// ============================================
|
||||||
export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
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 componentType = comp.componentType || comp.widgetType || comp.type || "unknown";
|
||||||
const url = getComponentUrl(componentType);
|
const url = getComponentUrl(componentType);
|
||||||
|
|
@ -301,12 +326,33 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||||
displayOrder: index,
|
displayOrder: index,
|
||||||
overrides: overrides,
|
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 {
|
return {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
components,
|
components,
|
||||||
// 레이아웃 메타데이터 포함
|
|
||||||
gridSettings: legacyLayout.gridSettings,
|
gridSettings: legacyLayout.gridSettings,
|
||||||
screenResolution: legacyLayout.screenResolution,
|
screenResolution: legacyLayout.screenResolution,
|
||||||
metadata: legacyLayout.metadata,
|
metadata: legacyLayout.metadata,
|
||||||
|
|
@ -317,7 +363,11 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||||
// V2 레이아웃 유효성 검사
|
// V2 레이아웃 유효성 검사
|
||||||
// ============================================
|
// ============================================
|
||||||
export function isValidV2Layout(data: any): data is LayoutV2 {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ export interface BaseComponent {
|
||||||
gridColumnStart?: number; // 시작 컬럼 (1-12)
|
gridColumnStart?: number; // 시작 컬럼 (1-12)
|
||||||
gridRowIndex?: number; // 행 인덱스
|
gridRowIndex?: number; // 행 인덱스
|
||||||
|
|
||||||
// 🆕 레이어 시스템
|
// 🆕 레이어 시스템 (DB layer_id: 1=기본, 2+=조건부)
|
||||||
layerId?: string; // 컴포넌트가 속한 레이어 ID
|
layerId?: string | number; // 컴포넌트가 속한 레이어 ID
|
||||||
|
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|
@ -876,6 +876,17 @@ export interface LayerOverlayConfig {
|
||||||
position?: "left" | "right" | "top" | "bottom"; // 드로어 위치
|
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;
|
condition?: LayerCondition;
|
||||||
|
|
||||||
|
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
|
||||||
|
displayRegion?: DisplayRegion;
|
||||||
|
|
||||||
// 모달/드로어 전용 설정
|
// 모달/드로어 전용 설정
|
||||||
overlayConfig?: LayerOverlayConfig;
|
overlayConfig?: LayerOverlayConfig;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue