feat: 멀티테넌시 지원을 위한 레이어 관리 기능 추가
- 레이어 목록 조회, 특정 레이어 레이아웃 조회, 레이어 삭제 및 조건 설정 업데이트 기능을 추가했습니다. - 엔티티 참조 데이터 조회 및 공통 코드 데이터 조회에 멀티테넌시 필터를 적용하여 인증된 사용자의 회사 코드에 따라 데이터 접근을 제한했습니다. - 레이어 관리 패널에서 기본 레이어와 조건부 레이어의 컴포넌트를 통합하여 조건부 영역의 표시를 개선했습니다. - 레이아웃 저장 시 레이어 ID를 포함하여 레이어별로 저장할 수 있도록 변경했습니다.
This commit is contained in:
parent
84eb035069
commit
1c71b3aa83
|
|
@ -30,10 +30,13 @@ export class EntityReferenceController {
|
|||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 컬럼 정보 조회 (table_type_columns에서)
|
||||
|
|
@ -89,16 +92,34 @@ export class EntityReferenceController {
|
|||
});
|
||||
}
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
// 참조 테이블에 company_code 컬럼이 있는지 확인
|
||||
const hasCompanyCode = await queryOne<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
|
||||
[referenceTable]
|
||||
);
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
|
||||
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
|
||||
if (hasCompanyCode && companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
whereConditions.push(`company_code = $${queryParams.length}`);
|
||||
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
|
||||
}
|
||||
|
||||
// 검색 조건 추가
|
||||
if (search) {
|
||||
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
||||
queryParams.push(`%${search}%`);
|
||||
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
|
||||
}
|
||||
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
if (whereConditions.length > 0) {
|
||||
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||
}
|
||||
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||
queryParams.push(Number(limit));
|
||||
|
||||
|
|
@ -107,6 +128,7 @@ export class EntityReferenceController {
|
|||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const referenceData = await query<any>(sqlQuery, queryParams);
|
||||
|
|
@ -119,7 +141,7 @@ export class EntityReferenceController {
|
|||
})
|
||||
);
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -149,13 +171,16 @@ export class EntityReferenceController {
|
|||
try {
|
||||
const { codeCategory } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// code_info 테이블에서 코드 데이터 조회
|
||||
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const queryParams: any[] = [codeCategory, 'Y'];
|
||||
let sqlQuery = `
|
||||
SELECT code_value, code_name
|
||||
|
|
@ -163,9 +188,16 @@ export class EntityReferenceController {
|
|||
WHERE code_category = $1 AND is_active = $2
|
||||
`;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
if (companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
sqlQuery += ` AND company_code = $${queryParams.length}`;
|
||||
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sqlQuery += ` AND code_name ILIKE $3`;
|
||||
queryParams.push(`%${search}%`);
|
||||
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
|
||||
}
|
||||
|
||||
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
||||
|
|
@ -174,12 +206,12 @@ export class EntityReferenceController {
|
|||
const codeData = await query<any>(sqlQuery, queryParams);
|
||||
|
||||
// 옵션 형태로 변환
|
||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
||||
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
|
||||
value: code.code_value,
|
||||
label: code.code_name,
|
||||
}));
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -732,6 +732,61 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 레이어 목록 조회
|
||||
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: layers });
|
||||
} catch (error) {
|
||||
console.error("레이어 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 특정 레이어 레이아웃 조회
|
||||
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
console.error("레이어 레이아웃 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 레이어 삭제
|
||||
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, message: "레이어가 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 레이어 조건 설정 업데이트
|
||||
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionConfig, layerName } = req.body;
|
||||
await screenManagementService.updateLayerCondition(
|
||||
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
|
||||
);
|
||||
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("레이어 조건 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 코드 자동 생성
|
||||
export const generateScreenCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ import {
|
|||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
getScreenLayers,
|
||||
getLayerLayout,
|
||||
deleteLayer,
|
||||
updateLayerCondition,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -84,6 +88,12 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
|
|||
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
|
||||
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
|
||||
|
||||
// 🆕 레이어 관리
|
||||
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
|
||||
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
|
||||
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
|
||||
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
|
||||
|
||||
// 메뉴-화면 할당 관리
|
||||
router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
|
||||
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
||||
|
|
|
|||
|
|
@ -4245,11 +4245,11 @@ export class ScreenManagementService {
|
|||
},
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
|
|
@ -5073,38 +5073,63 @@ export class ScreenManagementService {
|
|||
|
||||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
|
||||
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
|
||||
// 일반 사용자: 회사별 우선 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
|
|
@ -5122,17 +5147,21 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 저장 (1 레코드 방식)
|
||||
* - screen_layouts_v2 테이블에 화면당 1개 레코드 저장
|
||||
* - layout_data JSON에 모든 컴포넌트 포함
|
||||
* V2 레이아웃 저장 (레이어별 저장)
|
||||
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
|
||||
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
|
||||
*/
|
||||
async saveLayoutV2(
|
||||
screenId: number,
|
||||
layoutData: any,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
const layerId = layoutData.layerId || 1;
|
||||
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
|
||||
const conditionConfig = layoutData.conditionConfig || null;
|
||||
|
||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
|
||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||
|
||||
// 권한 확인
|
||||
|
|
@ -5151,22 +5180,173 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
|
||||
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
||||
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
|
||||
const dataToSave = {
|
||||
version: "2.0",
|
||||
...layoutData
|
||||
...pureLayoutData,
|
||||
};
|
||||
|
||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||
// UPSERT (레이어별 저장)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[screenId, companyCode, JSON.stringify(dataToSave)],
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
|
||||
);
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료`);
|
||||
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 모든 레이어 목록 조회
|
||||
* 레이어가 없으면 기본 레이어를 자동 생성
|
||||
*/
|
||||
async getScreenLayers(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<any[]> {
|
||||
let layers;
|
||||
|
||||
if (companyCode === "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 레이어 조회
|
||||
if (layers.length === 0 && companyCode !== "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이어가 없으면 기본 레이어 자동 생성
|
||||
if (layers.length === 0) {
|
||||
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
|
||||
[screenId, companyCode, defaultLayout],
|
||||
);
|
||||
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
|
||||
|
||||
// 다시 조회
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레이어의 레이아웃 조회
|
||||
*/
|
||||
async getLayerLayout(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<any> {
|
||||
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
|
||||
[screenId, layerId],
|
||||
);
|
||||
}
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout.layout_data,
|
||||
layerId,
|
||||
layerName: layout.layer_name,
|
||||
conditionConfig: layout.condition_config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 삭제
|
||||
*/
|
||||
async deleteLayer(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
if (layerId === 1) {
|
||||
throw new Error("기본 레이어는 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 조건 설정 업데이트
|
||||
*/
|
||||
async updateLayerCondition(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
conditionConfig: any,
|
||||
layerName?: string,
|
||||
): Promise<void> {
|
||||
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
|
||||
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
|
||||
|
||||
if (layerName) {
|
||||
setClauses.push(`layer_name = $${params.length + 1}`);
|
||||
params.push(layerName);
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -284,6 +284,60 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||
|
||||
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
|
||||
const conditionalRegionInfos = useMemo(() => {
|
||||
return layers
|
||||
.filter((layer) => layer.type === "conditional" && layer.displayRegion)
|
||||
.map((layer) => ({
|
||||
layerId: layer.id,
|
||||
region: layer.displayRegion!,
|
||||
isActive: activeLayerIds.includes(layer.id),
|
||||
}))
|
||||
.sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 🆕 접힌 조건부 영역 (비활성 상태인 것만)
|
||||
const collapsedRegions = useMemo(() => {
|
||||
return conditionalRegionInfos
|
||||
.filter((info) => !info.isActive)
|
||||
.map((info) => info.region);
|
||||
}, [conditionalRegionInfos]);
|
||||
|
||||
// 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원)
|
||||
// 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌
|
||||
// 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리
|
||||
const calculateYOffset = useCallback((componentY: number): number => {
|
||||
if (collapsedRegions.length === 0) return 0;
|
||||
|
||||
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
|
||||
const relevantRegions = collapsedRegions.filter(
|
||||
(region) => region.y + region.height <= componentY
|
||||
);
|
||||
|
||||
if (relevantRegions.length === 0) return 0;
|
||||
|
||||
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
|
||||
const mergedRegions: { y: number; bottom: number }[] = [];
|
||||
for (const region of relevantRegions) {
|
||||
const bottom = region.y + region.height;
|
||||
if (mergedRegions.length === 0) {
|
||||
mergedRegions.push({ y: region.y, bottom });
|
||||
} else {
|
||||
const last = mergedRegions[mergedRegions.length - 1];
|
||||
if (region.y <= last.bottom) {
|
||||
// 겹치는 영역 - 병합 (더 큰 하단으로 확장)
|
||||
last.bottom = Math.max(last.bottom, bottom);
|
||||
} else {
|
||||
// 겹치지 않는 영역 - 새로 추가
|
||||
mergedRegions.push({ y: region.y, bottom });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 병합된 영역들의 높이 합산
|
||||
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
|
||||
}, [collapsedRegions]);
|
||||
|
||||
// 개선된 검증 시스템 (선택적 활성화)
|
||||
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||
? useFormValidation(
|
||||
|
|
@ -2198,6 +2252,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
: component;
|
||||
|
||||
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
|
||||
const allLayerComponents = useMemo(() => {
|
||||
return layers.flatMap((layer) => layer.components);
|
||||
}, [layers]);
|
||||
|
||||
// 🆕 레이어별 컴포넌트 렌더링 함수
|
||||
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
||||
// 활성화되지 않은 레이어는 렌더링하지 않음
|
||||
|
|
@ -2234,7 +2293,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
|
|
@ -2306,7 +2365,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
|
|
@ -2326,13 +2385,18 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
className="pointer-events-none absolute inset-0"
|
||||
style={{ zIndex: layer.zIndex }}
|
||||
>
|
||||
{layer.components.map((comp) => (
|
||||
{layer.components.map((comp) => {
|
||||
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
|
||||
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||
const adjustedY = comp.position.y - yOffset;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${comp.position.y}px`,
|
||||
top: `${adjustedY}px`,
|
||||
width: comp.style?.width || `${comp.size.width}px`,
|
||||
height: comp.style?.height || `${comp.size.height}px`,
|
||||
zIndex: comp.position.z || 1,
|
||||
|
|
@ -2340,16 +2404,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider>
|
||||
|
|
|
|||
|
|
@ -10,15 +10,28 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, AlertCircle, Check, X } from "lucide-react";
|
||||
import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
|
||||
import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management";
|
||||
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
|
||||
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용)
|
||||
interface ConditionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 컴포넌트의 데이터 소스 타입
|
||||
type DataSourceType = "code" | "entity" | "category" | "static" | "none";
|
||||
|
||||
interface LayerConditionPanelProps {
|
||||
layer: LayerDefinition;
|
||||
components: ComponentData[]; // 화면의 모든 컴포넌트
|
||||
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
|
||||
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
||||
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +47,9 @@ type OperatorType = "eq" | "neq" | "in";
|
|||
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||
layer,
|
||||
components,
|
||||
baseLayerComponents,
|
||||
onUpdateCondition,
|
||||
onUpdateDisplayRegion,
|
||||
onClose,
|
||||
}) => {
|
||||
// 조건 설정 상태
|
||||
|
|
@ -51,75 +66,246 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
||||
);
|
||||
|
||||
// 코드 목록 로딩 상태
|
||||
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
|
||||
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
||||
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
|
||||
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
|
||||
const [options, setOptions] = useState<ConditionOption[]>([]);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
|
||||
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
|
||||
const triggerableComponents = useMemo(() => {
|
||||
return components.filter((comp) => {
|
||||
// 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용
|
||||
const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0
|
||||
? baseLayerComponents
|
||||
: components;
|
||||
|
||||
const isTriggerComponent = (comp: ComponentData): boolean => {
|
||||
const componentType = (comp.componentType || "").toLowerCase();
|
||||
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
||||
const webType = ((comp as any).webType || "").toLowerCase();
|
||||
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
||||
|
||||
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
|
||||
const isTriggerType = triggerTypes.some((type) =>
|
||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
|
||||
return triggerTypes.some((type) =>
|
||||
componentType.includes(type) ||
|
||||
widgetType.includes(type) ||
|
||||
webType.includes(type) ||
|
||||
inputType.includes(type)
|
||||
);
|
||||
};
|
||||
|
||||
return isTriggerType;
|
||||
});
|
||||
}, [components]);
|
||||
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
|
||||
const baseLayerIds = new Set(
|
||||
(baseLayerComponents || []).map((c) => c.id)
|
||||
);
|
||||
|
||||
// 기본 레이어 트리거 컴포넌트
|
||||
const baseLayerTriggers = sourceComponents.filter(isTriggerComponent);
|
||||
|
||||
// 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시)
|
||||
// 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가
|
||||
const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0
|
||||
? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp))
|
||||
: [];
|
||||
|
||||
return { baseLayerTriggers, otherLayerTriggers };
|
||||
}, [components, baseLayerComponents]);
|
||||
|
||||
// 선택된 컴포넌트 정보
|
||||
const selectedComponent = useMemo(() => {
|
||||
return components.find((c) => c.id === targetComponentId);
|
||||
}, [components, targetComponentId]);
|
||||
|
||||
// 선택된 컴포넌트의 코드 카테고리
|
||||
const codeCategory = useMemo(() => {
|
||||
if (!selectedComponent) return null;
|
||||
// 선택된 컴포넌트의 데이터 소스 정보 추출
|
||||
const dataSourceInfo = useMemo<{
|
||||
type: DataSourceType;
|
||||
codeCategory?: string;
|
||||
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
|
||||
originTable?: string;
|
||||
originColumn?: string;
|
||||
// 엔티티: 참조 대상 정보 (직접 조회용 폴백)
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
categoryTable?: string;
|
||||
categoryColumn?: string;
|
||||
staticOptions?: any[];
|
||||
}>(() => {
|
||||
if (!selectedComponent) return { type: "none" };
|
||||
|
||||
// codeCategory 확인 (다양한 위치에 있을 수 있음)
|
||||
const category =
|
||||
(selectedComponent as any).codeCategory ||
|
||||
(selectedComponent as any).componentConfig?.codeCategory ||
|
||||
(selectedComponent as any).webTypeConfig?.codeCategory;
|
||||
const comp = selectedComponent as any;
|
||||
const config = comp.componentConfig || comp.webTypeConfig || {};
|
||||
const detailSettings = comp.detailSettings || {};
|
||||
|
||||
return category || null;
|
||||
// V2 컴포넌트: config.source 확인
|
||||
const source = config.source;
|
||||
|
||||
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
|
||||
if (source === "category") {
|
||||
const categoryTable = config.categoryTable || comp.tableName;
|
||||
const categoryColumn = config.categoryColumn || comp.columnName;
|
||||
return { type: "category", categoryTable, categoryColumn };
|
||||
}
|
||||
|
||||
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
|
||||
const codeCategory =
|
||||
config.codeGroup || // V2 컴포넌트
|
||||
config.codeCategory ||
|
||||
comp.codeCategory ||
|
||||
detailSettings.codeCategory;
|
||||
|
||||
if (source === "code" || codeCategory) {
|
||||
return { type: "code", codeCategory };
|
||||
}
|
||||
|
||||
// 3. 엔티티 참조 확인 (V2: source === "entity")
|
||||
// entity-reference API는 원본 테이블.컬럼으로 호출해야 함
|
||||
// (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑)
|
||||
const originTable = comp.tableName || config.tableName;
|
||||
const originColumn = comp.columnName || config.columnName;
|
||||
|
||||
const referenceTable =
|
||||
config.entityTable ||
|
||||
config.referenceTable ||
|
||||
comp.referenceTable ||
|
||||
detailSettings.referenceTable;
|
||||
|
||||
const referenceColumn =
|
||||
config.entityValueColumn ||
|
||||
config.referenceColumn ||
|
||||
comp.referenceColumn ||
|
||||
detailSettings.referenceColumn;
|
||||
|
||||
if (source === "entity" || referenceTable) {
|
||||
return { type: "entity", originTable, originColumn, referenceTable, referenceColumn };
|
||||
}
|
||||
|
||||
// 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재)
|
||||
const staticOptions = config.options;
|
||||
if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) {
|
||||
return { type: "static", staticOptions };
|
||||
}
|
||||
|
||||
return { type: "none" };
|
||||
}, [selectedComponent]);
|
||||
|
||||
// 컴포넌트 선택 시 코드 목록 로드
|
||||
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
|
||||
useEffect(() => {
|
||||
if (!codeCategory) {
|
||||
setCodeOptions([]);
|
||||
if (dataSourceInfo.type === "none") {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
setIsLoadingCodes(true);
|
||||
setCodeLoadError(null);
|
||||
// 정적 옵션은 즉시 설정
|
||||
if (dataSourceInfo.type === "static") {
|
||||
const staticOpts = dataSourceInfo.staticOptions || [];
|
||||
setOptions(staticOpts.map((opt: any) => ({
|
||||
value: opt.value || "",
|
||||
label: opt.label || opt.value || "",
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
setIsLoadingOptions(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const codes = await getCodesByCategory(codeCategory);
|
||||
setCodeOptions(codes);
|
||||
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
|
||||
// 카테고리 값에서 옵션 로드 (category_values 테이블)
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
|
||||
);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
// 트리 구조를 평탄화
|
||||
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
|
||||
const result: ConditionOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) : "";
|
||||
result.push({
|
||||
value: item.valueCode || item.valueLabel,
|
||||
label: `${prefix}${item.valueLabel}`,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
setOptions(flattenTree(Array.isArray(data.data) ? data.data : []));
|
||||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
|
||||
// 코드 카테고리에서 옵션 로드
|
||||
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
|
||||
setOptions(codes.map((code) => ({
|
||||
value: code.code,
|
||||
label: code.name,
|
||||
})));
|
||||
} else if (dataSourceInfo.type === "entity") {
|
||||
// 엔티티 참조에서 옵션 로드
|
||||
// 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출
|
||||
// (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑)
|
||||
// 방법 2: 직접 참조 테이블로 폴백
|
||||
let entityLoaded = false;
|
||||
|
||||
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
|
||||
try {
|
||||
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
||||
dataSourceInfo.originTable,
|
||||
dataSourceInfo.originColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
// 원본 테이블.컬럼으로 실패 시 폴백
|
||||
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: 참조 테이블에서 직접 조회
|
||||
if (!entityLoaded && dataSourceInfo.referenceTable) {
|
||||
try {
|
||||
const refColumn = dataSourceInfo.referenceColumn || "id";
|
||||
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
||||
dataSourceInfo.referenceTable,
|
||||
refColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
console.warn("직접 참조 테이블로도 엔티티 조회 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음
|
||||
if (!entityLoaded) {
|
||||
// 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환
|
||||
setOptions([]);
|
||||
}
|
||||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("코드 목록 로드 실패:", error);
|
||||
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
|
||||
setCodeOptions([]);
|
||||
console.error("옵션 목록 로드 실패:", error);
|
||||
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setIsLoadingCodes(false);
|
||||
setIsLoadingOptions(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCodes();
|
||||
}, [codeCategory]);
|
||||
loadOptions();
|
||||
}, [dataSourceInfo]);
|
||||
|
||||
// 조건 저장
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -180,14 +366,24 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<SelectValue placeholder="컴포넌트 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerableComponents.length === 0 ? (
|
||||
{triggerableComponents.baseLayerTriggers.length === 0 &&
|
||||
triggerableComponents.otherLayerTriggers.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
조건 설정 가능한 컴포넌트가 없습니다.
|
||||
<br />
|
||||
(셀렉트, 라디오, 코드 타입)
|
||||
</div>
|
||||
) : (
|
||||
triggerableComponents.map((comp) => (
|
||||
<>
|
||||
{/* 기본 레이어 컴포넌트 (우선 표시) */}
|
||||
{triggerableComponents.baseLayerTriggers.length > 0 && (
|
||||
<>
|
||||
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||||
기본 레이어
|
||||
</div>
|
||||
)}
|
||||
{triggerableComponents.baseLayerTriggers.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
|
|
@ -196,20 +392,65 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */}
|
||||
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-1">
|
||||
다른 레이어
|
||||
</div>
|
||||
{triggerableComponents.otherLayerTriggers.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 코드 카테고리 표시 */}
|
||||
{codeCategory && (
|
||||
{/* 데이터 소스 표시 */}
|
||||
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Code2 className="h-3 w-3" />
|
||||
<span>코드:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dataSourceInfo.codeCategory}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>엔티티:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>카테고리:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{codeCategory}
|
||||
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "static" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>정적 옵션</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
|
|
@ -241,42 +482,41 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
{operator === "in" ? "값 선택 (복수)" : "값"}
|
||||
</Label>
|
||||
|
||||
{isLoadingCodes ? (
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
코드 목록 로딩 중...
|
||||
옵션 목록 로딩 중...
|
||||
</div>
|
||||
) : codeLoadError ? (
|
||||
) : loadError ? (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{codeLoadError}
|
||||
{loadError}
|
||||
</div>
|
||||
) : codeOptions.length > 0 ? (
|
||||
// 코드 카테고리가 있는 경우 - 선택 UI
|
||||
) : options.length > 0 ? (
|
||||
// 옵션이 있는 경우 - 선택 UI
|
||||
operator === "in" ? (
|
||||
// 다중 선택 (in 연산자)
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||
{codeOptions.map((code) => (
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={code.codeValue}
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
||||
multiValues.includes(code.codeValue) && "bg-primary/10"
|
||||
multiValues.includes(opt.value) && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => toggleMultiValue(code.codeValue)}
|
||||
onClick={() => toggleMultiValue(opt.value)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center",
|
||||
multiValues.includes(code.codeValue)
|
||||
multiValues.includes(opt.value)
|
||||
? "bg-primary border-primary"
|
||||
: "border-input"
|
||||
)}>
|
||||
{multiValues.includes(code.codeValue) && (
|
||||
{multiValues.includes(opt.value) && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>{code.codeName}</span>
|
||||
<span className="text-muted-foreground">({code.codeValue})</span>
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -287,20 +527,20 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<SelectValue placeholder="값 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeOptions.map((code) => (
|
||||
{options.map((opt) => (
|
||||
<SelectItem
|
||||
key={code.codeValue}
|
||||
value={code.codeValue}
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{code.codeName} ({code.codeValue})
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
) : (
|
||||
// 코드 카테고리가 없는 경우 - 직접 입력
|
||||
// 옵션이 없는 경우 - 직접 입력
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
|
|
@ -313,14 +553,14 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
{operator === "in" && multiValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{multiValues.map((val) => {
|
||||
const code = codeOptions.find((c) => c.codeValue === val);
|
||||
const opt = options.find((o) => o.value === val);
|
||||
return (
|
||||
<Badge
|
||||
key={val}
|
||||
variant="secondary"
|
||||
className="text-[10px] gap-1"
|
||||
>
|
||||
{code?.codeName || val}
|
||||
{opt?.label || val}
|
||||
<X
|
||||
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
||||
onClick={() => toggleMultiValue(val)}
|
||||
|
|
@ -339,14 +579,60 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<span className="font-medium">요약: </span>
|
||||
<span className="text-muted-foreground">
|
||||
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
||||
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
|
||||
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
|
||||
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
|
||||
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
|
||||
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
|
||||
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
|
||||
{" "}이 레이어 표시
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 영역 설정 */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Label className="text-xs font-semibold">표시 영역</Label>
|
||||
|
||||
{layer.displayRegion ? (
|
||||
<>
|
||||
{/* 현재 영역 정보 표시 */}
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<div className="flex-1 text-[10px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{layer.displayRegion.width} x {layer.displayRegion.height}
|
||||
</span>
|
||||
<span className="ml-1">
|
||||
({layer.displayRegion.x}, {layer.displayRegion.y})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px] text-destructive hover:text-destructive"
|
||||
onClick={() => onUpdateDisplayRegion(undefined)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
캔버스에서 점선 영역을 드래그하여 이동/리사이즈할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
좌측의 레이어 항목을 캔버스로
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
드래그&드롭하여 영역을 배치하세요
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
영역을 추가하면 조건 미충족 시 해당 영역이 사라지고 아래 컴포넌트가 위로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,184 +1,244 @@
|
|||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { useLayer } from "@/contexts/LayerContext";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Unlock,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Layers,
|
||||
SplitSquareVertical,
|
||||
PanelRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
Zap,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LayerType, LayerDefinition, ComponentData, LayerCondition } from "@/types/screen-management";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
|
||||
import { toast } from "sonner";
|
||||
import { LayerConditionPanel } from "./LayerConditionPanel";
|
||||
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
|
||||
|
||||
// 레이어 타입별 아이콘
|
||||
const getLayerTypeIcon = (type: LayerType) => {
|
||||
switch (type) {
|
||||
case "base":
|
||||
return <Layers className="h-3 w-3" />;
|
||||
case "conditional":
|
||||
return <SplitSquareVertical className="h-3 w-3" />;
|
||||
case "modal":
|
||||
return <Settings2 className="h-3 w-3" />;
|
||||
case "drawer":
|
||||
return <PanelRight className="h-3 w-3" />;
|
||||
default:
|
||||
return <Layers className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 타입별 라벨
|
||||
function getLayerTypeLabel(type: LayerType): string {
|
||||
switch (type) {
|
||||
case "base":
|
||||
return "기본";
|
||||
case "conditional":
|
||||
return "조건부";
|
||||
case "modal":
|
||||
return "모달";
|
||||
case "drawer":
|
||||
return "드로어";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
// DB 레이어 타입
|
||||
interface DBLayer {
|
||||
layer_id: number;
|
||||
layer_name: string;
|
||||
condition_config: any;
|
||||
component_count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 레이어 타입별 색상
|
||||
function getLayerTypeColor(type: LayerType): string {
|
||||
switch (type) {
|
||||
case "base":
|
||||
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "conditional":
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300";
|
||||
case "modal":
|
||||
return "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300";
|
||||
case "drawer":
|
||||
return "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
interface LayerManagerPanelProps {
|
||||
screenId: number | null;
|
||||
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
|
||||
onLayerChange: (layerId: number) => void; // 레이어 전환
|
||||
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
|
||||
}
|
||||
|
||||
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,
|
||||
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
screenId,
|
||||
activeLayerId,
|
||||
onLayerChange,
|
||||
components = [],
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isConditionOpen, setIsConditionOpen] = useState(false);
|
||||
const [layers, setLayers] = useState<DBLayer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
|
||||
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
|
||||
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
|
||||
|
||||
// 조건부 레이어인지 확인
|
||||
const isConditionalLayer = layer.type === "conditional";
|
||||
// 조건 설정 여부
|
||||
const hasCondition = !!layer.condition;
|
||||
// 레이어 목록 로드
|
||||
const loadLayers = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await screenApi.getScreenLayers(screenId);
|
||||
setLayers(data);
|
||||
} catch (error) {
|
||||
console.error("레이어 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
|
||||
const loadBaseLayerComponents = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
try {
|
||||
const data = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (data && data.components) {
|
||||
const legacy = convertV2ToLegacy(data);
|
||||
if (legacy) {
|
||||
setBaseLayerComponents(legacy.components as ComponentData[]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBaseLayerComponents([]);
|
||||
} catch {
|
||||
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
|
||||
setBaseLayerComponents(components);
|
||||
}
|
||||
}, [screenId, components]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLayers();
|
||||
}, [loadLayers]);
|
||||
|
||||
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
|
||||
useEffect(() => {
|
||||
if (conditionOpenLayerId !== null) {
|
||||
loadBaseLayerComponents();
|
||||
}
|
||||
}, [conditionOpenLayerId, loadBaseLayerComponents]);
|
||||
|
||||
// 새 레이어 추가
|
||||
const handleAddLayer = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
// 다음 layer_id 계산
|
||||
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
|
||||
const newLayerId = maxLayerId + 1;
|
||||
|
||||
try {
|
||||
// 빈 레이아웃으로 새 레이어 저장
|
||||
await screenApi.saveLayoutV2(screenId, {
|
||||
version: "2.0",
|
||||
components: [],
|
||||
layerId: newLayerId,
|
||||
layerName: `조건부 레이어 ${newLayerId}`,
|
||||
});
|
||||
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
|
||||
await loadLayers();
|
||||
// 새 레이어로 전환
|
||||
onLayerChange(newLayerId);
|
||||
} catch (error) {
|
||||
console.error("레이어 추가 실패:", error);
|
||||
toast.error("레이어 추가에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, layers, loadLayers, onLayerChange]);
|
||||
|
||||
// 레이어 삭제
|
||||
const handleDeleteLayer = useCallback(async (layerId: number) => {
|
||||
if (!screenId || layerId === 1) return;
|
||||
try {
|
||||
await screenApi.deleteLayer(screenId, layerId);
|
||||
toast.success("레이어가 삭제되었습니다.");
|
||||
await loadLayers();
|
||||
// 기본 레이어로 전환
|
||||
if (activeLayerId === layerId) {
|
||||
onLayerChange(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
toast.error("레이어 삭제에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
|
||||
|
||||
// 조건 업데이트
|
||||
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
|
||||
if (!screenId) return;
|
||||
try {
|
||||
await screenApi.updateLayerCondition(screenId, layerId, condition || null);
|
||||
toast.success("조건이 저장되었습니다.");
|
||||
await loadLayers();
|
||||
} catch (error) {
|
||||
console.error("조건 업데이트 실패:", error);
|
||||
toast.error("조건 저장에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, loadLayers]);
|
||||
|
||||
return (
|
||||
<div className="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 h-full flex-col bg-background">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 레이어 타입 아이콘 */}
|
||||
<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>
|
||||
)}
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold">레이어</h3>
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
{layers.length}
|
||||
</Badge>
|
||||
</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}개 컴포넌트
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2"
|
||||
onClick={handleAddLayer}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 레이어 목록 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">로딩 중...</span>
|
||||
</div>
|
||||
) : layers.length === 0 ? (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">레이어를 로드하는 중...</p>
|
||||
<p className="text-[10px] text-muted-foreground">먼저 화면을 저장하면 기본 레이어가 생성됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
layers
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((layer) => {
|
||||
const isActive = activeLayerId === layer.layer_id;
|
||||
const isBase = layer.layer_id === 1;
|
||||
const hasCondition = !!layer.condition_config;
|
||||
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
|
||||
|
||||
return (
|
||||
<div key={layer.layer_id} className="space-y-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent hover:bg-muted",
|
||||
isConditionOpen && "rounded-b-none border-b-0",
|
||||
)}
|
||||
onClick={() => onLayerChange(layer.layer_id)}
|
||||
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
|
||||
draggable={!isBase}
|
||||
onDragStart={(e) => {
|
||||
if (isBase) return;
|
||||
e.dataTransfer.setData("application/json", JSON.stringify({
|
||||
type: "layer-region",
|
||||
layerId: layer.layer_id,
|
||||
layerName: layer.layer_name,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"shrink-0 rounded p-1",
|
||||
isBase
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
)}>
|
||||
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
|
||||
{isBase ? "기본" : "조건부"}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{layer.component_count}개 컴포넌트
|
||||
</span>
|
||||
{/* 조건 설정됨 표시 */}
|
||||
{hasCondition && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1 py-0 h-4 gap-0.5">
|
||||
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
조건
|
||||
</Badge>
|
||||
|
|
@ -186,21 +246,17 @@ const LayerItem: React.FC<LayerItemProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{/* 조건부 레이어일 때 조건 설정 버튼 */}
|
||||
{isConditionalLayer && (
|
||||
{/* 액션 버튼 */}
|
||||
<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"
|
||||
)}
|
||||
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
|
||||
title="조건 설정"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsConditionOpen(!isConditionOpen);
|
||||
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
|
||||
}}
|
||||
>
|
||||
{isConditionOpen ? (
|
||||
|
|
@ -210,50 +266,15 @@ const LayerItem: React.FC<LayerItemProps> = ({
|
|||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isBase && (
|
||||
<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"
|
||||
className="h-6 w-6 hover:text-destructive"
|
||||
title="레이어 삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
handleDeleteLayer(layer.layer_id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
|
@ -262,143 +283,41 @@ const LayerItem: React.FC<LayerItemProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 설정 패널 (조건부 레이어만) */}
|
||||
{isConditionalLayer && isConditionOpen && (
|
||||
{/* 조건 설정 패널 */}
|
||||
{!isBase && isConditionOpen && (
|
||||
<div className={cn(
|
||||
"border border-t-0 rounded-b-md bg-muted/30",
|
||||
isActive ? "border-primary" : "border-border"
|
||||
"rounded-b-md border border-t-0 bg-muted/30",
|
||||
isActive ? "border-primary" : "border-border",
|
||||
)}>
|
||||
<LayerConditionPanel
|
||||
layer={layer}
|
||||
components={allComponents}
|
||||
onUpdateCondition={onUpdateCondition}
|
||||
onClose={() => setIsConditionOpen(false)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
interface LayerManagerPanelProps {
|
||||
components?: ComponentData[]; // layout.components를 전달받음
|
||||
}
|
||||
|
||||
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({ components = [] }) => {
|
||||
const {
|
||||
layers,
|
||||
activeLayerId,
|
||||
setActiveLayerId,
|
||||
addLayer,
|
||||
removeLayer,
|
||||
toggleLayerVisibility,
|
||||
toggleLayerLock,
|
||||
updateLayer,
|
||||
} = useLayer();
|
||||
|
||||
// 레이어 조건 업데이트 핸들러
|
||||
const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => {
|
||||
updateLayer(layerId, { condition });
|
||||
}, [updateLayer]);
|
||||
|
||||
// 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반)
|
||||
const componentCountByLayer = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// 모든 레이어를 0으로 초기화
|
||||
layers.forEach(layer => {
|
||||
counts[layer.id] = 0;
|
||||
});
|
||||
|
||||
// layout.components에서 layerId별로 카운트
|
||||
components.forEach(comp => {
|
||||
const layerId = comp.layerId || "default-layer";
|
||||
if (counts[layerId] !== undefined) {
|
||||
counts[layerId]++;
|
||||
} else {
|
||||
// layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트
|
||||
if (counts["default-layer"] !== undefined) {
|
||||
counts["default-layer"]++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [components, layers]);
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold">레이어</h3>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{layers.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 레이어 추가 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => addLayer("conditional", "조건부 레이어")}>
|
||||
<SplitSquareVertical className="h-4 w-4 mr-2" />
|
||||
조건부 레이어
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => addLayer("modal", "모달 레이어")}>
|
||||
<Settings2 className="h-4 w-4 mr-2" />
|
||||
모달 레이어
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => addLayer("drawer", "드로어 레이어")}>
|
||||
<PanelRight className="h-4 w-4 mr-2" />
|
||||
드로어 레이어
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 레이어 목록 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{layers.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground text-sm py-8">
|
||||
레이어가 없습니다.
|
||||
<br />
|
||||
<span className="text-xs">위의 + 버튼으로 추가하세요.</span>
|
||||
</div>
|
||||
) : (
|
||||
layers
|
||||
.slice()
|
||||
.reverse() // 상위 레이어가 위에 표시
|
||||
.map((layer) => (
|
||||
<LayerItem
|
||||
key={layer.id}
|
||||
layer={layer}
|
||||
isActive={activeLayerId === layer.id}
|
||||
componentCount={componentCountByLayer[layer.id] || 0}
|
||||
allComponents={components}
|
||||
onSelect={() => setActiveLayerId(layer.id)}
|
||||
onToggleVisibility={() => toggleLayerVisibility(layer.id)}
|
||||
onToggleLock={() => toggleLayerLock(layer.id)}
|
||||
onRemove={() => removeLayer(layer.id)}
|
||||
onUpdateName={(name) => updateLayer(layer.id, { name })}
|
||||
onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)}
|
||||
/>
|
||||
))
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<p>더블클릭: 이름 편집 | 드래그: 순서 변경</p>
|
||||
<p>레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -500,25 +501,49 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
return lines;
|
||||
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||
|
||||
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
|
||||
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
|
||||
const activeLayerIdRef = useRef<number>(1);
|
||||
const setActiveLayerIdWithRef = useCallback((id: number) => {
|
||||
setActiveLayerIdLocal(id);
|
||||
activeLayerIdRef.current = id;
|
||||
}, []);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
|
||||
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const visibleComponents = useMemo(() => {
|
||||
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
|
||||
if (!activeLayerId) {
|
||||
return layout.components;
|
||||
}
|
||||
// 🆕 좌측 패널 탭 상태 관리
|
||||
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
|
||||
|
||||
// 활성 레이어에 속한 컴포넌트만 필터링
|
||||
return layout.components.filter((comp) => {
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const compLayerId = comp.layerId || "default-layer";
|
||||
return compLayerId === activeLayerId;
|
||||
// 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 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,
|
||||
});
|
||||
}, [layout.components, activeLayerId]);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return layout.components;
|
||||
}, [layout.components]);
|
||||
|
||||
// 이미 배치된 컬럼 목록 계산
|
||||
const placedColumns = useMemo(() => {
|
||||
|
|
@ -1532,6 +1557,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||
|
||||
// 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion)
|
||||
try {
|
||||
const layers = await screenApi.getScreenLayers(selectedScreen.screenId);
|
||||
const regions: Record<number, any> = {};
|
||||
for (const layer of layers) {
|
||||
if (layer.layer_id > 1 && layer.condition_config?.displayRegion) {
|
||||
regions[layer.layer_id] = {
|
||||
...layer.condition_config.displayRegion,
|
||||
layerName: layer.layer_name,
|
||||
};
|
||||
}
|
||||
}
|
||||
setLayerRegions(regions);
|
||||
} catch { /* 레이어 로드 실패 무시 */ }
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("레이아웃 로드 실패:", error);
|
||||
|
|
@ -1953,37 +1993,23 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
|
||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||
|
||||
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
|
||||
const updatedLayers = layout.layers?.map((layer) => ({
|
||||
...layer,
|
||||
components: layer.components.map((comp) => {
|
||||
// 분할 패널 업데이트 로직 적용
|
||||
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
|
||||
return updatedComp || comp;
|
||||
}),
|
||||
}));
|
||||
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
layers: updatedLayers, // 🆕 레이어 정보 포함
|
||||
screenResolution: screenResolution,
|
||||
mainTableName: currentMainTableName, // 화면의 기본 테이블
|
||||
};
|
||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
// 💾 저장 로그 (디버그 완료 - 간소화)
|
||||
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
|
||||
// 분할 패널 디버그 로그 (주석 처리)
|
||||
|
||||
// V2 API 사용 여부에 따라 분기
|
||||
if (USE_V2_API) {
|
||||
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
|
||||
// 🆕 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||
// layerId를 포함하여 저장
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
|
@ -2505,10 +2531,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}
|
||||
});
|
||||
|
||||
// 🆕 현재 활성 레이어에 컴포넌트 추가
|
||||
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
|
||||
const componentsWithLayerId = newComponents.map((comp) => ({
|
||||
...comp,
|
||||
layerId: activeLayerId || "default-layer",
|
||||
layerId: activeLayerIdRef.current || 1,
|
||||
}));
|
||||
|
||||
// 레이아웃에 새 컴포넌트들 추가
|
||||
|
|
@ -2527,7 +2553,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||
},
|
||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 레이아웃 드래그 처리
|
||||
|
|
@ -2581,7 +2607,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
label: layoutData.label,
|
||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||
dropZoneConfig: layoutData.dropZoneConfig,
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
} as ComponentData;
|
||||
|
||||
// 레이아웃에 새 컴포넌트 추가
|
||||
|
|
@ -2598,7 +2624,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
|
||||
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||
);
|
||||
|
||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||
|
|
@ -3189,7 +3215,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
position: snappedPosition,
|
||||
size: componentSize,
|
||||
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
|
|
@ -3223,7 +3249,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||
},
|
||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
|
|
@ -3232,7 +3258,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
|
|
@ -3264,6 +3290,41 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장
|
||||
if (parsedData.type === "layer-region" && parsedData.layerId && selectedScreen?.screenId) {
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
|
||||
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
|
||||
const newRegion = {
|
||||
x: Math.max(0, dropX - 400),
|
||||
y: Math.max(0, dropY),
|
||||
width: Math.min(800, screenResolution.width),
|
||||
height: 200,
|
||||
};
|
||||
// DB에 displayRegion 저장 (condition_config에 포함)
|
||||
try {
|
||||
// 기존 condition_config를 가져와서 displayRegion만 추가/업데이트
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId);
|
||||
const existingCondition = layerData?.conditionConfig || {};
|
||||
await screenApi.updateLayerCondition(
|
||||
selectedScreen.screenId,
|
||||
parsedData.layerId,
|
||||
{ ...existingCondition, displayRegion: newRegion }
|
||||
);
|
||||
// 레이어 영역 state에 반영 (캔버스에 즉시 표시)
|
||||
setLayerRegions((prev) => ({
|
||||
...prev,
|
||||
[parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName },
|
||||
}));
|
||||
toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error("레이어 영역 저장 실패:", error);
|
||||
toast.error("레이어 영역 저장에 실패했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 테이블/컬럼 드래그 처리
|
||||
const { type, table, column } = parsedData;
|
||||
|
||||
|
|
@ -3595,7 +3656,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
tableName: table.tableName,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: 300, height: 200 },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelFontSize: "14px",
|
||||
|
|
@ -3846,7 +3907,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -3913,7 +3974,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -4736,7 +4797,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
z: clipComponent.position.z || 1,
|
||||
} as Position,
|
||||
parentId: undefined, // 붙여넣기 시 부모 관계 해제
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
|
||||
};
|
||||
newComponents.push(newComponent);
|
||||
});
|
||||
|
|
@ -4757,7 +4818,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
||||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||
}, [clipboard, layout, saveToHistory, activeLayerId]);
|
||||
}, [clipboard, layout, saveToHistory]);
|
||||
|
||||
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
||||
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
||||
|
|
@ -5657,21 +5718,152 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
};
|
||||
}, [layout, selectedComponent]);
|
||||
|
||||
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
|
||||
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
|
||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||
setLayout((prevLayout) => ({
|
||||
...prevLayout,
|
||||
layers: newLayers,
|
||||
// components는 그대로 유지 - layerId 속성으로 레이어 구분
|
||||
// components: prevLayout.components (기본값으로 유지됨)
|
||||
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, 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.layers에 직접 설정된 displayRegion 등 메타데이터를 보존
|
||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||
setLayout((prevLayout) => {
|
||||
// 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합
|
||||
const mergedLayers = newLayers.map((newLayer) => {
|
||||
const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id);
|
||||
if (!existingLayer) return newLayer;
|
||||
|
||||
// LayerContext에서 온 데이터(condition 등)를 우선하되,
|
||||
// layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존
|
||||
return {
|
||||
...existingLayer, // 기존 메타데이터 보존 (displayRegion 등)
|
||||
...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등)
|
||||
// displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선
|
||||
displayRegion: newLayer.displayRegion !== undefined
|
||||
? newLayer.displayRegion
|
||||
: existingLayer.displayRegion,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
layers: mergedLayers,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 🆕 활성 레이어 변경 핸들러
|
||||
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
|
||||
setActiveLayerIdLocal(newActiveLayerId);
|
||||
}, []);
|
||||
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
|
||||
setActiveLayerIdWithRef(newActiveLayerId);
|
||||
}, [setActiveLayerIdWithRef]);
|
||||
|
||||
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
|
||||
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
|
||||
|
|
@ -5751,7 +5943,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
|
||||
<TabsTrigger value="components" className="text-xs">
|
||||
컴포넌트
|
||||
|
|
@ -5784,9 +5976,41 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 🆕 레이어 관리 탭 */}
|
||||
{/* 🆕 레이어 관리 탭 (DB 기반) */}
|
||||
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
|
||||
<LayerManagerPanel components={layout.components} />
|
||||
<LayerManagerPanel
|
||||
screenId={selectedScreen?.screenId || null}
|
||||
activeLayerId={Number(activeLayerIdRef.current) || 1}
|
||||
onLayerChange={async (layerId) => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
// 1. 현재 레이어 저장
|
||||
const curId = Number(activeLayerIdRef.current) || 1;
|
||||
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId });
|
||||
|
||||
// 2. 새 레이어 로드
|
||||
const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
|
||||
if (data && data.components) {
|
||||
const legacy = convertV2ToLegacy(data);
|
||||
if (legacy) {
|
||||
setLayout((prev) => ({ ...prev, components: legacy.components }));
|
||||
} else {
|
||||
setLayout((prev) => ({ ...prev, components: [] }));
|
||||
}
|
||||
} else {
|
||||
setLayout((prev) => ({ ...prev, components: [] }));
|
||||
}
|
||||
|
||||
setActiveLayerIdWithRef(layerId);
|
||||
setSelectedComponent(null);
|
||||
} catch (error) {
|
||||
console.error("레이어 전환 실패:", error);
|
||||
toast.error("레이어 전환에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
components={layout.components}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||
|
|
@ -6359,6 +6583,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
|
||||
{activeLayerId > 1 && (
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs font-medium">레이어 {activeLayerId} 편집 중</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||
<div
|
||||
className="flex justify-center"
|
||||
|
|
@ -6399,6 +6631,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
startSelectionDrag(e);
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 영역 이동/리사이즈 처리
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseMove(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseUp();
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseUp();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
|
|
@ -6467,6 +6715,79 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */}
|
||||
{activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => {
|
||||
const layerId = Number(layerIdStr);
|
||||
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
|
||||
const handleCursors: Record<string, string> = {
|
||||
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
|
||||
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
|
||||
};
|
||||
const handlePositions: Record<string, React.CSSProperties> = {
|
||||
nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 },
|
||||
sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 },
|
||||
n: { top: -4, left: "50%", transform: "translateX(-50%)" },
|
||||
s: { bottom: -4, left: "50%", transform: "translateX(-50%)" },
|
||||
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
|
||||
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={`region-${layerId}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
border: "2px dashed hsl(var(--primary))",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "hsl(var(--primary) / 0.05)",
|
||||
zIndex: 9999,
|
||||
cursor: "move",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 top-1 select-none text-[10px] font-medium text-primary">
|
||||
레이어 {layerId} - {region.layerName}
|
||||
</span>
|
||||
{/* 리사이즈 핸들 */}
|
||||
{resizeHandles.map((handle) => (
|
||||
<div
|
||||
key={handle}
|
||||
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
|
||||
style={{ ...handlePositions[handle], cursor: handleCursors[handle] }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
|
||||
/>
|
||||
))}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
|
||||
const cond = layerData?.conditionConfig || {};
|
||||
delete cond.displayRegion;
|
||||
await screenApi.updateLayerCondition(selectedScreen.screenId, layerId, Object.keys(cond).length > 0 ? cond : null);
|
||||
setLayerRegions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[layerId];
|
||||
return next;
|
||||
});
|
||||
} catch { toast.error("영역 삭제 실패"); }
|
||||
}}
|
||||
title="영역 삭제"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => {
|
||||
const children =
|
||||
|
|
|
|||
|
|
@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
id: screenToPreview!.screenId,
|
||||
tableName: screenToPreview?.tableName,
|
||||
}}
|
||||
layers={previewLayout.layers || []}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ const refreshToken = async (): Promise<string | null> => {
|
|||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.success && response.data?.data?.token) {
|
||||
|
|
@ -192,13 +192,16 @@ const startAutoRefresh = (): void => {
|
|||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
tokenRefreshTimer = setInterval(async () => {
|
||||
tokenRefreshTimer = setInterval(
|
||||
async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
}, 10 * 60 * 1000); // 10분
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // 10분
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
const token = TokenManager.getToken();
|
||||
|
|
@ -230,14 +233,18 @@ const setupActivityBasedRefresh = (): void => {
|
|||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||
let throttleTimer: NodeJS.Timeout | null = null;
|
||||
window.addEventListener(event, () => {
|
||||
window.addEventListener(
|
||||
event,
|
||||
() => {
|
||||
if (!throttleTimer) {
|
||||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}
|
||||
}, { passive: true });
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -213,6 +213,28 @@ export const screenApi = {
|
|||
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
|
||||
},
|
||||
|
||||
// 🆕 레이어 목록 조회
|
||||
getScreenLayers: async (screenId: number): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layers`);
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
// 🆕 특정 레이어 레이아웃 조회
|
||||
getLayerLayout: async (screenId: number, layerId: number): Promise<any> => {
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layers/${layerId}/layout`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// 🆕 레이어 삭제
|
||||
deleteLayer: async (screenId: number, layerId: number): Promise<void> => {
|
||||
await apiClient.delete(`/screen-management/screens/${screenId}/layers/${layerId}`);
|
||||
},
|
||||
|
||||
// 🆕 레이어 조건 설정 업데이트
|
||||
updateLayerCondition: async (screenId: number, layerId: number, conditionConfig: any, layerName?: string): Promise<void> => {
|
||||
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
|
||||
},
|
||||
|
||||
// 연결된 모달 화면 감지
|
||||
detectLinkedModals: async (
|
||||
screenId: number,
|
||||
|
|
|
|||
|
|
@ -153,6 +153,14 @@ export const componentV2Schema = z.object({
|
|||
// ============================================
|
||||
export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]);
|
||||
|
||||
// 조건부 레이어 표시 영역 스키마
|
||||
export const displayRegionSchema = z.object({
|
||||
x: z.number().default(0),
|
||||
y: z.number().default(0),
|
||||
width: z.number().default(800),
|
||||
height: z.number().default(200),
|
||||
});
|
||||
|
||||
export const layerSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
|
|
@ -170,6 +178,9 @@ export const layerSchema = z.object({
|
|||
})
|
||||
.optional(),
|
||||
|
||||
// 조건부 레이어 표시 영역 (조건 미충족 시 사라짐)
|
||||
displayRegion: displayRegionSchema.optional(),
|
||||
|
||||
// 모달/드로어 전용 설정
|
||||
overlayConfig: z
|
||||
.object({
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ interface LegacyComponentData {
|
|||
|
||||
interface LegacyLayoutData {
|
||||
components: LegacyComponentData[];
|
||||
layers?: any[]; // 레이어 시스템
|
||||
gridSettings?: any;
|
||||
screenResolution?: any;
|
||||
metadata?: any;
|
||||
|
|
@ -140,21 +141,22 @@ function applyDefaultsToSplitPanelComponents(mergedConfig: Record<string, any>):
|
|||
// V2 → Legacy 변환 (로드 시)
|
||||
// ============================================
|
||||
export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | null {
|
||||
if (!v2Layout || !v2Layout.components) {
|
||||
if (!v2Layout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const components: LegacyComponentData[] = v2Layout.components.map((comp) => {
|
||||
// V2 컴포넌트를 Legacy 컴포넌트로 변환하는 함수 (레이어 내 컴포넌트에도 재사용)
|
||||
const convertV2Component = (comp: ComponentV2, layerId?: string): LegacyComponentData => {
|
||||
const componentType = getComponentTypeFromUrl(comp.url);
|
||||
const defaults = getDefaultsByUrl(comp.url);
|
||||
let mergedConfig = mergeComponentConfig(defaults, comp.overrides);
|
||||
|
||||
// 🆕 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
|
||||
// 분할 패널인 경우 내부 컴포넌트에도 기본값 적용
|
||||
if (componentType === "v2-split-panel-layout") {
|
||||
mergedConfig = applyDefaultsToSplitPanelComponents(mergedConfig);
|
||||
}
|
||||
|
||||
// 🆕 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
|
||||
// 탭 위젯인 경우 탭 내부 컴포넌트에도 기본값 적용
|
||||
if (componentType === "v2-tabs-widget" && mergedConfig.tabs) {
|
||||
mergedConfig = {
|
||||
...mergedConfig,
|
||||
|
|
@ -170,7 +172,6 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
|||
};
|
||||
}
|
||||
|
||||
// 🆕 overrides에서 상위 레벨 속성들 추출
|
||||
const overrides = comp.overrides || {};
|
||||
|
||||
return {
|
||||
|
|
@ -181,45 +182,68 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
|||
position: comp.position,
|
||||
size: comp.size,
|
||||
componentConfig: mergedConfig,
|
||||
// 🆕 상위 레벨 속성 복원 (테이블/컬럼 연결 정보)
|
||||
// 상위 레벨 속성 복원
|
||||
tableName: overrides.tableName,
|
||||
columnName: overrides.columnName,
|
||||
label: overrides.label || mergedConfig.label || "", // 라벨이 없으면 빈 문자열
|
||||
label: overrides.label || mergedConfig.label || "",
|
||||
required: overrides.required,
|
||||
readonly: overrides.readonly,
|
||||
hidden: overrides.hidden, // 🆕 숨김 설정 복원
|
||||
hidden: overrides.hidden,
|
||||
codeCategory: overrides.codeCategory,
|
||||
inputType: overrides.inputType,
|
||||
webType: overrides.webType,
|
||||
// 🆕 autoFill 설정 복원 (자동 입력 기능)
|
||||
autoFill: overrides.autoFill,
|
||||
// 🆕 style 설정 복원 (라벨 텍스트, 라벨 스타일 등)
|
||||
style: overrides.style || {},
|
||||
// 🔧 webTypeConfig 복원 (버튼 제어기능, 플로우 가시성 등)
|
||||
webTypeConfig: overrides.webTypeConfig || {},
|
||||
// 기존 구조 호환을 위한 추가 필드
|
||||
parentId: null,
|
||||
gridColumns: 12,
|
||||
gridRowIndex: 0,
|
||||
// 🆕 레이어 ID 복원
|
||||
...(layerId ? { layerId } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// 🆕 레이어 구조가 있는 경우 (v2.1)
|
||||
const v2Layers = (v2Layout as any).layers;
|
||||
if (v2Layers && Array.isArray(v2Layers) && v2Layers.length > 0) {
|
||||
// 모든 레이어의 컴포넌트를 평탄화하여 layout.components에 저장 (layerId 포함)
|
||||
const allComponents: LegacyComponentData[] = [];
|
||||
const legacyLayers = v2Layers.map((layer: any) => {
|
||||
const layerComponents = (layer.components || []).map((comp: any) =>
|
||||
convertV2Component(comp, layer.id)
|
||||
);
|
||||
allComponents.push(...layerComponents);
|
||||
|
||||
return {
|
||||
...layer,
|
||||
// layer.components는 legacy 변환 시 빈 배열로 (layout.components + layerId 방식 사용)
|
||||
components: [],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
components: allComponents,
|
||||
layers: legacyLayers,
|
||||
gridSettings: v2Layout.gridSettings || {
|
||||
enabled: true, size: 20, color: "#d1d5db", opacity: 0.5,
|
||||
snapToGrid: true, columns: 12, gap: 16, padding: 16,
|
||||
},
|
||||
screenResolution: v2Layout.screenResolution || { width: 1920, height: 1080 },
|
||||
};
|
||||
}
|
||||
|
||||
// 레이어 없는 기존 방식
|
||||
if (!v2Layout.components) return null;
|
||||
|
||||
const components: LegacyComponentData[] = v2Layout.components.map((comp) => convertV2Component(comp));
|
||||
|
||||
return {
|
||||
components,
|
||||
gridSettings: v2Layout.gridSettings || {
|
||||
enabled: true,
|
||||
size: 20,
|
||||
color: "#d1d5db",
|
||||
opacity: 0.5,
|
||||
snapToGrid: true,
|
||||
columns: 12,
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
},
|
||||
screenResolution: v2Layout.screenResolution || {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
enabled: true, size: 20, color: "#d1d5db", opacity: 0.5,
|
||||
snapToGrid: true, columns: 12, gap: 16, padding: 16,
|
||||
},
|
||||
screenResolution: v2Layout.screenResolution || { width: 1920, height: 1080 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +251,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
|||
// Legacy → V2 변환 (저장 시)
|
||||
// ============================================
|
||||
export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||
const components: ComponentV2[] = legacyLayout.components.map((comp, index) => {
|
||||
// 컴포넌트 변환 함수 (레이어 내 컴포넌트 변환에도 재사용)
|
||||
const convertComponent = (comp: LegacyComponentData, index: number): ComponentV2 => {
|
||||
// 컴포넌트 타입 결정
|
||||
const componentType = comp.componentType || comp.widgetType || comp.type || "unknown";
|
||||
const url = getComponentUrl(componentType);
|
||||
|
|
@ -301,12 +326,33 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
|||
displayOrder: index,
|
||||
overrides: overrides,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 레이어 정보 변환 (layers가 있으면 레이어 구조로 저장)
|
||||
const legacyLayers = (legacyLayout as any).layers;
|
||||
if (legacyLayers && Array.isArray(legacyLayers) && legacyLayers.length > 0) {
|
||||
const v2Layers = legacyLayers.map((layer: any) => ({
|
||||
...layer,
|
||||
// 레이어 내 컴포넌트를 V2 형식으로 변환
|
||||
components: (layer.components || []).map((comp: any, idx: number) => convertComponent(comp, idx)),
|
||||
}));
|
||||
|
||||
return {
|
||||
version: "2.1",
|
||||
layers: v2Layers,
|
||||
components: [], // 레이어 구조 사용 시 상위 components는 빈 배열
|
||||
gridSettings: legacyLayout.gridSettings,
|
||||
screenResolution: legacyLayout.screenResolution,
|
||||
metadata: legacyLayout.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
// 레이어 없으면 기존 방식 (컴포넌트만)
|
||||
const components = legacyLayout.components.map((comp, index) => convertComponent(comp, index));
|
||||
|
||||
return {
|
||||
version: "2.0",
|
||||
components,
|
||||
// 레이아웃 메타데이터 포함
|
||||
gridSettings: legacyLayout.gridSettings,
|
||||
screenResolution: legacyLayout.screenResolution,
|
||||
metadata: legacyLayout.metadata,
|
||||
|
|
@ -317,7 +363,11 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
|||
// V2 레이아웃 유효성 검사
|
||||
// ============================================
|
||||
export function isValidV2Layout(data: any): data is LayoutV2 {
|
||||
return data && typeof data === "object" && data.version === "2.0" && Array.isArray(data.components);
|
||||
if (!data || typeof data !== "object") return false;
|
||||
// v2.0: components 기반, v2.1: layers 기반
|
||||
const isV2 = data.version === "2.0" && Array.isArray(data.components);
|
||||
const isV21 = data.version === "2.1" && Array.isArray(data.layers);
|
||||
return isV2 || isV21;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ export interface BaseComponent {
|
|||
gridColumnStart?: number; // 시작 컬럼 (1-12)
|
||||
gridRowIndex?: number; // 행 인덱스
|
||||
|
||||
// 🆕 레이어 시스템
|
||||
layerId?: string; // 컴포넌트가 속한 레이어 ID
|
||||
// 🆕 레이어 시스템 (DB layer_id: 1=기본, 2+=조건부)
|
||||
layerId?: string | number; // 컴포넌트가 속한 레이어 ID
|
||||
|
||||
parentId?: string;
|
||||
label?: string;
|
||||
|
|
@ -876,6 +876,17 @@ export interface LayerOverlayConfig {
|
|||
position?: "left" | "right" | "top" | "bottom"; // 드로어 위치
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 레이어 표시 영역
|
||||
* 조건 미충족 시 이 영역이 사라지고, 아래 컴포넌트들이 위로 이동
|
||||
*/
|
||||
export interface DisplayRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 정의
|
||||
*/
|
||||
|
|
@ -890,6 +901,9 @@ export interface LayerDefinition {
|
|||
// 조건부 표시 로직
|
||||
condition?: LayerCondition;
|
||||
|
||||
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
|
||||
displayRegion?: DisplayRegion;
|
||||
|
||||
// 모달/드로어 전용 설정
|
||||
overlayConfig?: LayerOverlayConfig;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue