diff --git a/backend-node/src/controllers/entityReferenceController.ts b/backend-node/src/controllers/entityReferenceController.ts index 18f0036f..3fa9d1f6 100644 --- a/backend-node/src/controllers/entityReferenceController.ts +++ b/backend-node/src/controllers/entityReferenceController.ts @@ -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( + `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(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(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, diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 53ff1b96..a0521eec 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -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: "레이어 조건 업데이트에 실패했습니다." }); + } +}; + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 456a74a0..08bf57f6 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -42,6 +42,10 @@ import { copyCategoryMapping, copyTableTypeColumns, copyCascadingRelation, + getScreenLayers, + getLayerLayout, + deleteLayer, + updateLayerCondition, } from "../controllers/screenManagementController"; const router = express.Router(); @@ -88,6 +92,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); // 레이어 조건 설정 + // POP 레이아웃 관리 (모바일/태블릿) router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회 router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장 diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 4f5bf1e9..a8765d18 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -885,9 +885,9 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - const parts = rule.parts + const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) - .map((part: any) => { + .map(async (part: any) => { if (part.generationMethod === "manual") { // 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리) // placeholder 텍스트는 프론트엔드에서 별도로 표시 @@ -982,17 +982,52 @@ class NumberingRuleService { // 카테고리 매핑에서 해당 값에 대한 형식 찾기 // selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용) const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find((m: any) => { - // ID로 매칭 + let mapping = categoryMappings.find((m: any) => { + // ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우) if (m.categoryValueId?.toString() === selectedValueStr) return true; - // 라벨로 매칭 - if (m.categoryValueLabel === selectedValueStr) return true; - // valueCode로 매칭 (라벨과 동일할 수 있음) + // valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우) + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) + return true; + // 라벨로 매칭 (폴백) if (m.categoryValueLabel === selectedValueStr) return true; return false; }); + // 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도 + if (!mapping) { + try { + const pool = getPool(); + const [catTableName, catColumnName] = categoryKey.includes(".") + ? categoryKey.split(".") + : [categoryKey, categoryKey]; + const cvResult = await pool.query( + `SELECT value_id, value_code, value_label FROM category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [catTableName, catColumnName, selectedValueStr] + ); + if (cvResult.rows.length > 0) { + const resolvedId = cvResult.rows[0].value_id; + const resolvedLabel = cvResult.rows[0].value_label; + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(resolvedId)) return true; + if (m.categoryValueLabel === resolvedLabel) return true; + return false; + }); + if (mapping) { + logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", { + valueCode: selectedValueStr, + resolvedId, + resolvedLabel, + format: mapping.format, + }); + } + } + } catch (lookupError: any) { + logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message }); + } + } + if (mapping) { logger.info("카테고리 매핑 적용", { selectedValue, @@ -1016,7 +1051,7 @@ class NumberingRuleService { logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } - }); + })); const previewCode = parts.join(rule.separator || ""); logger.info("코드 미리보기 생성", { @@ -1059,9 +1094,9 @@ class NumberingRuleService { if (manualParts.length > 0 && userInputCode) { // 프리뷰 코드를 생성해서 ____ 위치 파악 // 🔧 category 파트도 처리하여 올바른 템플릿 생성 - const previewParts = rule.parts + const previewParts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) - .map((part: any) => { + .map(async (part: any) => { if (part.generationMethod === "manual") { return "____"; } @@ -1077,36 +1112,57 @@ class NumberingRuleService { return "DATEPART"; // 날짜 자리 표시 case "category": { // 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용 - const categoryKey = autoConfig.categoryKey; - const categoryMappings = autoConfig.categoryMappings || []; + const catKey2 = autoConfig.categoryKey; + const catMappings2 = autoConfig.categoryMappings || []; - if (!categoryKey || !formData) { + if (!catKey2 || !formData) { return "CATEGORY"; // 폴백 } - const columnName = categoryKey.includes(".") - ? categoryKey.split(".")[1] - : categoryKey; - const selectedValue = formData[columnName]; + const colName2 = catKey2.includes(".") + ? catKey2.split(".")[1] + : catKey2; + const selVal2 = formData[colName2]; - if (!selectedValue) { + if (!selVal2) { return "CATEGORY"; // 폴백 } - const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find((m: any) => { - if (m.categoryValueId?.toString() === selectedValueStr) - return true; - if (m.categoryValueLabel === selectedValueStr) return true; + const selValStr2 = String(selVal2); + let catMapping2 = catMappings2.find((m: any) => { + if (m.categoryValueId?.toString() === selValStr2) return true; + if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true; + if (m.categoryValueLabel === selValStr2) return true; return false; }); - return mapping?.format || "CATEGORY"; + // valueCode → valueId 역변환 시도 + if (!catMapping2) { + try { + const pool2 = getPool(); + const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2]; + const cvr2 = await pool2.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [ct2, cc2, selValStr2] + ); + if (cvr2.rows.length > 0) { + const rid2 = cvr2.rows[0].value_id; + const rlabel2 = cvr2.rows[0].value_label; + catMapping2 = catMappings2.find((m: any) => { + if (m.categoryValueId?.toString() === String(rid2)) return true; + if (m.categoryValueLabel === rlabel2) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + return catMapping2?.format || "CATEGORY"; } default: return ""; } - }); + })); const separator = rule.separator || ""; const previewTemplate = previewParts.join(separator); @@ -1150,9 +1206,9 @@ class NumberingRuleService { } let manualPartIndex = 0; - const parts = rule.parts + const parts = await Promise.all(rule.parts .sort((a: any, b: any) => a.order - b.order) - .map((part: any) => { + .map(async (part: any) => { if (part.generationMethod === "manual") { // 추출된 수동 입력 값 사용, 없으면 기본값 사용 const manualValue = @@ -1267,28 +1323,53 @@ class NumberingRuleService { // 카테고리 매핑에서 해당 값에 대한 형식 찾기 const selectedValueStr = String(selectedValue); - const mapping = categoryMappings.find((m: any) => { - // ID로 매칭 - if (m.categoryValueId?.toString() === selectedValueStr) - return true; - // 라벨로 매칭 + let allocMapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) return true; + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; if (m.categoryValueLabel === selectedValueStr) return true; return false; }); - if (mapping) { + // valueCode → valueId 역변환 시도 + if (!allocMapping) { + try { + const pool3 = getPool(); + const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; + const cvr3 = await pool3.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [ct3, cc3, selectedValueStr] + ); + if (cvr3.rows.length > 0) { + const rid3 = cvr3.rows[0].value_id; + const rlabel3 = cvr3.rows[0].value_label; + allocMapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(rid3)) return true; + if (m.categoryValueLabel === rlabel3) return true; + return false; + }); + if (allocMapping) { + logger.info("allocateCode: 카테고리 매핑 역변환 성공", { + valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format, + }); + } + } + } catch { /* ignore */ } + } + + if (allocMapping) { logger.info("allocateCode: 카테고리 매핑 적용", { selectedValue, - format: mapping.format, - categoryValueLabel: mapping.categoryValueLabel, + format: allocMapping.format, + categoryValueLabel: allocMapping.categoryValueLabel, }); - return mapping.format || ""; + return allocMapping.format || ""; } logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", { selectedValue, availableMappings: categoryMappings.map((m: any) => ({ id: m.categoryValueId, + code: m.categoryValueCode, label: m.categoryValueLabel, })), }); @@ -1299,7 +1380,7 @@ class NumberingRuleService { logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } - }); + })); const allocatedCode = parts.join(rule.separator || ""); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 77c82a91..244f2b2a 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -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 { + 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 { + let layers; + + if (companyCode === "*") { + layers = await query( + `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( + `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( + `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( + `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 { + 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 { + 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 { + 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, + ); } // ======================================== diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index a902327f..b51ea500 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -685,6 +685,7 @@ const CategoryConfigPanel: React.FC = ({ return { valueId: selectedId, + valueCode: node.valueCode, // valueCode 추가 (V2Select 호환) valueLabel: node.valueLabel, valuePath: pathParts.join(" > "), }; @@ -698,6 +699,7 @@ const CategoryConfigPanel: React.FC = ({ const newMapping: CategoryFormatMapping = { categoryValueId: selectedInfo.valueId, + categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장 categoryValueLabel: selectedInfo.valueLabel, categoryValuePath: selectedInfo.valuePath, format: newFormat.trim(), diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 6b9a092b..4f295878 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -284,6 +284,60 @@ export const InteractiveScreenViewer: React.FC = ( }); }, [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 = ( } : 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 = ( > = ( > = ( 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 ( +
+ +
+ ); + })} ); - }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]); + }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]); return ( diff --git a/frontend/components/screen/LayerConditionPanel.tsx b/frontend/components/screen/LayerConditionPanel.tsx index 4304aa55..e74cc4a0 100644 --- a/frontend/components/screen/LayerConditionPanel.tsx +++ b/frontend/components/screen/LayerConditionPanel.tsx @@ -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 = ({ layer, components, + baseLayerComponents, onUpdateCondition, + onUpdateDisplayRegion, onClose, }) => { // 조건 설정 상태 @@ -51,75 +66,246 @@ export const LayerConditionPanel: React.FC = ({ Array.isArray(layer.condition?.value) ? layer.condition.value : [] ); - // 코드 목록 로딩 상태 - const [codeOptions, setCodeOptions] = useState([]); - const [isLoadingCodes, setIsLoadingCodes] = useState(false); - const [codeLoadError, setCodeLoadError] = useState(null); + // 옵션 목록 로딩 상태 (코드/엔티티 통합) + const [options, setOptions] = useState([]); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + const [loadError, setLoadError] = useState(null); - // 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등) + // 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등) const triggerableComponents = useMemo(() => { - return components.filter((comp) => { + // 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용 + const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0 + ? baseLayerComponents + : components; + + const isTriggerComponent = (comp: ComponentData): boolean => { const componentType = (comp.componentType || "").toLowerCase(); const widgetType = ((comp as any).widgetType || "").toLowerCase(); const webType = ((comp as any).webType || "").toLowerCase(); const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase(); // 셀렉트, 라디오, 코드 타입 컴포넌트만 허용 - const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"]; - const isTriggerType = triggerTypes.some((type) => + const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"]; + return triggerTypes.some((type) => componentType.includes(type) || widgetType.includes(type) || webType.includes(type) || inputType.includes(type) ); - - return isTriggerType; - }); - }, [components]); + }; + + // 기본 레이어 컴포넌트 ID Set (그룹 구분용) + const baseLayerIds = new Set( + (baseLayerComponents || []).map((c) => c.id) + ); + + // 기본 레이어 트리거 컴포넌트 + const baseLayerTriggers = sourceComponents.filter(isTriggerComponent); + + // 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시) + // 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가 + const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0 + ? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp)) + : []; + + return { baseLayerTriggers, otherLayerTriggers }; + }, [components, baseLayerComponents]); // 선택된 컴포넌트 정보 const selectedComponent = useMemo(() => { return components.find((c) => c.id === targetComponentId); }, [components, targetComponentId]); - // 선택된 컴포넌트의 코드 카테고리 - const codeCategory = useMemo(() => { - if (!selectedComponent) return null; + // 선택된 컴포넌트의 데이터 소스 정보 추출 + const dataSourceInfo = useMemo<{ + type: DataSourceType; + codeCategory?: string; + // 엔티티: 원본 테이블.컬럼 (entity-reference API용) + originTable?: string; + originColumn?: string; + // 엔티티: 참조 대상 정보 (직접 조회용 폴백) + referenceTable?: string; + referenceColumn?: string; + categoryTable?: string; + categoryColumn?: string; + staticOptions?: any[]; + }>(() => { + if (!selectedComponent) return { type: "none" }; - // codeCategory 확인 (다양한 위치에 있을 수 있음) - const category = - (selectedComponent as any).codeCategory || - (selectedComponent as any).componentConfig?.codeCategory || - (selectedComponent as any).webTypeConfig?.codeCategory; + const comp = selectedComponent as any; + const config = comp.componentConfig || comp.webTypeConfig || {}; + const detailSettings = comp.detailSettings || {}; - return category || null; + // V2 컴포넌트: config.source 확인 + const source = config.source; + + // 1. 카테고리 소스 (V2: source === "category", category_values 테이블) + if (source === "category") { + const categoryTable = config.categoryTable || comp.tableName; + const categoryColumn = config.categoryColumn || comp.columnName; + return { type: "category", categoryTable, categoryColumn }; + } + + // 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory) + const codeCategory = + config.codeGroup || // V2 컴포넌트 + config.codeCategory || + comp.codeCategory || + detailSettings.codeCategory; + + if (source === "code" || codeCategory) { + return { type: "code", codeCategory }; + } + + // 3. 엔티티 참조 확인 (V2: source === "entity") + // entity-reference API는 원본 테이블.컬럼으로 호출해야 함 + // (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑) + const originTable = comp.tableName || config.tableName; + const originColumn = comp.columnName || config.columnName; + + const referenceTable = + config.entityTable || + config.referenceTable || + comp.referenceTable || + detailSettings.referenceTable; + + const referenceColumn = + config.entityValueColumn || + config.referenceColumn || + comp.referenceColumn || + detailSettings.referenceColumn; + + if (source === "entity" || referenceTable) { + return { type: "entity", originTable, originColumn, referenceTable, referenceColumn }; + } + + // 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재) + const staticOptions = config.options; + if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) { + return { type: "static", staticOptions }; + } + + return { type: "none" }; }, [selectedComponent]); - // 컴포넌트 선택 시 코드 목록 로드 + // 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적) useEffect(() => { - if (!codeCategory) { - setCodeOptions([]); + if (dataSourceInfo.type === "none") { + setOptions([]); return; } - const loadCodes = async () => { - setIsLoadingCodes(true); - setCodeLoadError(null); + // 정적 옵션은 즉시 설정 + if (dataSourceInfo.type === "static") { + const staticOpts = dataSourceInfo.staticOptions || []; + setOptions(staticOpts.map((opt: any) => ({ + value: opt.value || "", + label: opt.label || opt.value || "", + }))); + return; + } + + const loadOptions = async () => { + setIsLoadingOptions(true); + setLoadError(null); try { - const codes = await getCodesByCategory(codeCategory); - setCodeOptions(codes); + if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) { + // 카테고리 값에서 옵션 로드 (category_values 테이블) + const response = await apiClient.get( + `/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values` + ); + const data = response.data; + if (data.success && data.data) { + // 트리 구조를 평탄화 + const flattenTree = (items: any[], depth = 0): ConditionOption[] => { + const result: ConditionOption[] = []; + for (const item of items) { + const prefix = depth > 0 ? " ".repeat(depth) : ""; + result.push({ + value: item.valueCode || item.valueLabel, + label: `${prefix}${item.valueLabel}`, + }); + if (item.children && item.children.length > 0) { + result.push(...flattenTree(item.children, depth + 1)); + } + } + return result; + }; + setOptions(flattenTree(Array.isArray(data.data) ? data.data : [])); + } else { + setOptions([]); + } + } else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) { + // 코드 카테고리에서 옵션 로드 + const codes = await getCodesByCategory(dataSourceInfo.codeCategory); + setOptions(codes.map((code) => ({ + value: code.code, + label: code.name, + }))); + } else if (dataSourceInfo.type === "entity") { + // 엔티티 참조에서 옵션 로드 + // 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출 + // (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑) + // 방법 2: 직접 참조 테이블로 폴백 + let entityLoaded = false; + + if (dataSourceInfo.originTable && dataSourceInfo.originColumn) { + try { + const entityData = await EntityReferenceAPI.getEntityReferenceData( + dataSourceInfo.originTable, + dataSourceInfo.originColumn, + { limit: 100 } + ); + setOptions(entityData.options.map((opt) => ({ + value: opt.value, + label: opt.label, + }))); + entityLoaded = true; + } catch { + // 원본 테이블.컬럼으로 실패 시 폴백 + console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백"); + } + } + + // 폴백: 참조 테이블에서 직접 조회 + if (!entityLoaded && dataSourceInfo.referenceTable) { + try { + const refColumn = dataSourceInfo.referenceColumn || "id"; + const entityData = await EntityReferenceAPI.getEntityReferenceData( + dataSourceInfo.referenceTable, + refColumn, + { limit: 100 } + ); + setOptions(entityData.options.map((opt) => ({ + value: opt.value, + label: opt.label, + }))); + entityLoaded = true; + } catch { + console.warn("직접 참조 테이블로도 엔티티 조회 실패"); + } + } + + // 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음 + if (!entityLoaded) { + // 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환 + setOptions([]); + } + } else { + setOptions([]); + } } catch (error: any) { - console.error("코드 목록 로드 실패:", error); - setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다."); - setCodeOptions([]); + console.error("옵션 목록 로드 실패:", error); + setLoadError(error.message || "옵션 목록을 불러올 수 없습니다."); + setOptions([]); } finally { - setIsLoadingCodes(false); + setIsLoadingOptions(false); } }; - loadCodes(); - }, [codeCategory]); + loadOptions(); + }, [dataSourceInfo]); // 조건 저장 const handleSave = useCallback(() => { @@ -180,36 +366,91 @@ export const LayerConditionPanel: React.FC = ({ - {triggerableComponents.length === 0 ? ( + {triggerableComponents.baseLayerTriggers.length === 0 && + triggerableComponents.otherLayerTriggers.length === 0 ? (
조건 설정 가능한 컴포넌트가 없습니다.
(셀렉트, 라디오, 코드 타입)
) : ( - triggerableComponents.map((comp) => ( - -
- {getComponentLabel(comp)} - - {comp.componentType || (comp as any).widgetType} - -
-
- )) + <> + {/* 기본 레이어 컴포넌트 (우선 표시) */} + {triggerableComponents.baseLayerTriggers.length > 0 && ( + <> + {triggerableComponents.otherLayerTriggers.length > 0 && ( +
+ 기본 레이어 +
+ )} + {triggerableComponents.baseLayerTriggers.map((comp) => ( + +
+ {getComponentLabel(comp)} + + {comp.componentType || (comp as any).widgetType} + +
+
+ ))} + + )} + {/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */} + {triggerableComponents.otherLayerTriggers.length > 0 && ( + <> +
+ 다른 레이어 +
+ {triggerableComponents.otherLayerTriggers.map((comp) => ( + +
+ {getComponentLabel(comp)} + + {comp.componentType || (comp as any).widgetType} + +
+
+ ))} + + )} + )}
- {/* 코드 카테고리 표시 */} - {codeCategory && ( + {/* 데이터 소스 표시 */} + {dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
+ + 코드: + + {dataSourceInfo.codeCategory} + +
+ )} + {dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && ( +
+ + 엔티티: + + {dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`} + +
+ )} + {dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && ( +
+ 카테고리: - {codeCategory} + {dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
)} + {dataSourceInfo.type === "static" && ( +
+ 정적 옵션 +
+ )} {/* 연산자 선택 */} @@ -241,42 +482,41 @@ export const LayerConditionPanel: React.FC = ({ {operator === "in" ? "값 선택 (복수)" : "값"} - {isLoadingCodes ? ( + {isLoadingOptions ? (
- 코드 목록 로딩 중... + 옵션 목록 로딩 중...
- ) : codeLoadError ? ( + ) : loadError ? (
- {codeLoadError} + {loadError}
- ) : codeOptions.length > 0 ? ( - // 코드 카테고리가 있는 경우 - 선택 UI + ) : options.length > 0 ? ( + // 옵션이 있는 경우 - 선택 UI operator === "in" ? ( // 다중 선택 (in 연산자)
- {codeOptions.map((code) => ( + {options.map((opt) => (
toggleMultiValue(code.codeValue)} + onClick={() => toggleMultiValue(opt.value)} >
- {multiValues.includes(code.codeValue) && ( + {multiValues.includes(opt.value) && ( )}
- {code.codeName} - ({code.codeValue}) + {opt.label}
))}
@@ -287,20 +527,20 @@ export const LayerConditionPanel: React.FC = ({ - {codeOptions.map((code) => ( + {options.map((opt) => ( - {code.codeName} ({code.codeValue}) + {opt.label} ))} ) ) : ( - // 코드 카테고리가 없는 경우 - 직접 입력 + // 옵션이 없는 경우 - 직접 입력 setValue(e.target.value)} @@ -313,14 +553,14 @@ export const LayerConditionPanel: React.FC = ({ {operator === "in" && multiValues.length > 0 && (
{multiValues.map((val) => { - const code = codeOptions.find((c) => c.codeValue === val); + const opt = options.find((o) => o.value === val); return ( - {code?.codeName || val} + {opt?.label || val} toggleMultiValue(val)} @@ -339,14 +579,60 @@ export const LayerConditionPanel: React.FC = ({ 요약: "{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(", ")}] 중 하나이면`} {" "}이 레이어 표시
)} + {/* 표시 영역 설정 */} +
+ + + {layer.displayRegion ? ( + <> + {/* 현재 영역 정보 표시 */} +
+
+ + {layer.displayRegion.width} x {layer.displayRegion.height} + + + ({layer.displayRegion.x}, {layer.displayRegion.y}) + +
+ +
+

+ 캔버스에서 점선 영역을 드래그하여 이동/리사이즈할 수 있습니다. +

+ + ) : ( +
+
+

+ 좌측의 레이어 항목을 캔버스로 +

+

+ 드래그&드롭하여 영역을 배치하세요 +

+
+

+ 영역을 추가하면 조건 미충족 시 해당 영역이 사라지고 아래 컴포넌트가 위로 이동합니다. +

+
+ )} +
+ {/* 버튼 */}
- )} - - - - - - {layer.type !== "base" && ( - - )} -
- - - {/* 조건 설정 패널 (조건부 레이어만) */} - {isConditionalLayer && isConditionOpen && ( -
- setIsConditionOpen(false)} - /> -
- )} - - ); -}; - interface LayerManagerPanelProps { - components?: ComponentData[]; // layout.components를 전달받음 + screenId: number | null; + activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id) + onLayerChange: (layerId: number) => void; // 레이어 전환 + components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용) } -export const LayerManagerPanel: React.FC = ({ components = [] }) => { - const { - layers, - activeLayerId, - setActiveLayerId, - addLayer, - removeLayer, - toggleLayerVisibility, - toggleLayerLock, - updateLayer, - } = useLayer(); +export const LayerManagerPanel: React.FC = ({ + screenId, + activeLayerId, + onLayerChange, + components = [], +}) => { + const [layers, setLayers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [conditionOpenLayerId, setConditionOpenLayerId] = useState(null); + // 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상) + const [baseLayerComponents, setBaseLayerComponents] = useState([]); - // 레이어 조건 업데이트 핸들러 - const handleUpdateCondition = useCallback((layerId: string, condition: LayerCondition | undefined) => { - updateLayer(layerId, { condition }); - }, [updateLayer]); + // 레이어 목록 로드 + const loadLayers = useCallback(async () => { + if (!screenId) return; + setIsLoading(true); + try { + const data = await screenApi.getScreenLayers(screenId); + setLayers(data); + } catch (error) { + console.error("레이어 목록 로드 실패:", error); + } finally { + setIsLoading(false); + } + }, [screenId]); - // 🆕 각 레이어별 컴포넌트 수 계산 (layout.components 기반) - const componentCountByLayer = useMemo(() => { - const counts: Record = {}; - - // 모든 레이어를 0으로 초기화 - layers.forEach(layer => { - counts[layer.id] = 0; - }); - - // layout.components에서 layerId별로 카운트 - components.forEach(comp => { - const layerId = comp.layerId || "default-layer"; - if (counts[layerId] !== undefined) { - counts[layerId]++; - } else { - // layerId가 존재하지 않는 레이어인 경우 default-layer로 카운트 - if (counts["default-layer"] !== undefined) { - counts["default-layer"]++; + // 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용) + const loadBaseLayerComponents = useCallback(async () => { + if (!screenId) return; + try { + const data = await screenApi.getLayerLayout(screenId, 1); + if (data && data.components) { + const legacy = convertV2ToLegacy(data); + if (legacy) { + setBaseLayerComponents(legacy.components as ComponentData[]); + return; } } - }); - - return counts; - }, [components, layers]); + 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 ( -
+
{/* 헤더 */}

레이어

- + {layers.length}
- - {/* 레이어 추가 드롭다운 */} - - - - - - addLayer("conditional", "조건부 레이어")}> - - 조건부 레이어 - - - addLayer("modal", "모달 레이어")}> - - 모달 레이어 - - addLayer("drawer", "드로어 레이어")}> - - 드로어 레이어 - - - + +
{/* 레이어 목록 */}
- {layers.length === 0 ? ( -
- 레이어가 없습니다. -
- 위의 + 버튼으로 추가하세요. + {isLoading ? ( +
+ + 로딩 중... +
+ ) : layers.length === 0 ? ( +
+

레이어를 로드하는 중...

+

먼저 화면을 저장하면 기본 레이어가 생성됩니다.

) : ( layers .slice() - .reverse() // 상위 레이어가 위에 표시 - .map((layer) => ( - setActiveLayerId(layer.id)} - onToggleVisibility={() => toggleLayerVisibility(layer.id)} - onToggleLock={() => toggleLayerLock(layer.id)} - onRemove={() => removeLayer(layer.id)} - onUpdateName={(name) => updateLayer(layer.id, { name })} - onUpdateCondition={(condition) => handleUpdateCondition(layer.id, condition)} - /> - )) + .reverse() + .map((layer) => { + const isActive = activeLayerId === layer.layer_id; + const isBase = layer.layer_id === 1; + const hasCondition = !!layer.condition_config; + const isConditionOpen = conditionOpenLayerId === layer.layer_id; + + return ( +
+
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"; + }} + > + + +
+
+ + {isBase ? : } + + {layer.layer_name} +
+
+ + {isBase ? "기본" : "조건부"} + + + {layer.component_count}개 컴포넌트 + + {hasCondition && ( + + + 조건 + + )} +
+
+ + {/* 액션 버튼 */} +
+ {!isBase && ( + + )} + {!isBase && ( + + )} +
+
+ + {/* 조건 설정 패널 */} + {!isBase && isConditionOpen && ( +
+ handleUpdateCondition(layer.layer_id, condition)} + onUpdateDisplayRegion={() => {}} + onClose={() => setConditionOpenLayerId(null)} + /> +
+ )} +
+ ); + }) )}
{/* 도움말 */}
-

더블클릭: 이름 편집 | 드래그: 순서 변경

+

레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정

); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 5a96a959..bb2820f1 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -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 { @@ -511,25 +512,49 @@ export default function ScreenDesigner({ return lines; }, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]); - // 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리) - const [activeLayerId, setActiveLayerIdLocal] = useState("default-layer"); + // 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어) + const [activeLayerId, setActiveLayerIdLocal] = useState(1); + const activeLayerIdRef = useRef(1); + const setActiveLayerIdWithRef = useCallback((id: number) => { + setActiveLayerIdLocal(id); + activeLayerIdRef.current = id; + }, []); - // 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반) - // 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시 - // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 + // 🆕 좌측 패널 탭 상태 관리 + const [leftPanelTab, setLeftPanelTab] = useState("components"); + + // 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시) + const [layerRegions, setLayerRegions] = useState>({}); + + // 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정) + const [regionDrag, setRegionDrag] = useState<{ + isDrawing: boolean; // 새 영역 그리기 모드 + isDragging: boolean; // 기존 영역 이동 모드 + isResizing: boolean; // 기존 영역 리사이즈 모드 + targetLayerId: string | null; // 대상 레이어 ID + startX: number; + startY: number; + currentX: number; + currentY: number; + resizeHandle: string | null; // 리사이즈 핸들 위치 + originalRegion: { x: number; y: number; width: number; height: number } | null; + }>({ + isDrawing: false, + isDragging: false, + isResizing: false, + targetLayerId: null, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + resizeHandle: null, + originalRegion: null, + }); + + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) const visibleComponents = useMemo(() => { - // 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시 - if (!activeLayerId) { - return layout.components; - } - - // 활성 레이어에 속한 컴포넌트만 필터링 - return layout.components.filter((comp) => { - // layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리 - const compLayerId = comp.layerId || "default-layer"; - return compLayerId === activeLayerId; - }); - }, [layout.components, activeLayerId]); + return layout.components; + }, [layout.components]); // 이미 배치된 컬럼 목록 계산 const placedColumns = useMemo(() => { @@ -1549,6 +1574,21 @@ export default function ScreenDesigner({ // 파일 컴포넌트 데이터 복원 (비동기) restoreFileComponentsData(layoutWithDefaultGrid.components); + + // 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion) + try { + const layers = await screenApi.getScreenLayers(selectedScreen.screenId); + const regions: Record = {}; + 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); @@ -1970,30 +2010,12 @@ export default function ScreenDesigner({ // 현재 선택된 테이블을 화면의 기본 테이블로 저장 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/POP API 사용 여부에 따라 분기 const v2Layout = convertLegacyToV2(layoutWithResolution); @@ -2001,9 +2023,12 @@ export default function ScreenDesigner({ // POP 모드: screen_layouts_pop 테이블에 저장 await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - // 데스크톱 V2 모드: screen_layouts_v2 테이블에 저장 - await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout); - // console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트"); + // 레이어 기반 저장: 현재 활성 레이어의 layout만 저장 + const currentLayerId = activeLayerIdRef.current || 1; + await screenApi.saveLayoutV2(selectedScreen.screenId, { + ...v2Layout, + layerId: currentLayerId, + }); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } @@ -2539,10 +2564,10 @@ export default function ScreenDesigner({ } }); - // 🆕 현재 활성 레이어에 컴포넌트 추가 + // 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지) const componentsWithLayerId = newComponents.map((comp) => ({ ...comp, - layerId: activeLayerId || "default-layer", + layerId: activeLayerIdRef.current || 1, })); // 레이아웃에 새 컴포넌트들 추가 @@ -2561,7 +2586,7 @@ export default function ScreenDesigner({ toast.success(`${template.name} 템플릿이 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory, activeLayerId], + [layout, selectedScreen, saveToHistory], ); // 레이아웃 드래그 처리 @@ -2615,7 +2640,7 @@ export default function ScreenDesigner({ label: layoutData.label, allowedComponentTypes: layoutData.allowedComponentTypes, dropZoneConfig: layoutData.dropZoneConfig, - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) } as ComponentData; // 레이아웃에 새 컴포넌트 추가 @@ -2632,7 +2657,7 @@ export default function ScreenDesigner({ toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`); }, - [layout, screenResolution, saveToHistory, zoomLevel, activeLayerId], + [layout, screenResolution, saveToHistory, zoomLevel], ); // handleZoneComponentDrop은 handleComponentDrop으로 대체됨 @@ -3223,7 +3248,7 @@ export default function ScreenDesigner({ position: snappedPosition, size: componentSize, gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용 - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) componentConfig: { type: component.id, // 새 컴포넌트 시스템의 ID 사용 webType: component.webType, // 웹타입 정보 추가 @@ -3257,7 +3282,7 @@ export default function ScreenDesigner({ toast.success(`${component.name} 컴포넌트가 추가되었습니다.`); }, - [layout, selectedScreen, saveToHistory, activeLayerId], + [layout, selectedScreen, saveToHistory], ); // 드래그 앤 드롭 처리 @@ -3266,7 +3291,7 @@ export default function ScreenDesigner({ }, []); const handleDrop = useCallback( - (e: React.DragEvent) => { + async (e: React.DragEvent) => { e.preventDefault(); const dragData = e.dataTransfer.getData("application/json"); @@ -3298,6 +3323,41 @@ export default function ScreenDesigner({ 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; @@ -3629,7 +3689,7 @@ export default function ScreenDesigner({ 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", @@ -3880,7 +3940,7 @@ export default function ScreenDesigner({ 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 && { @@ -3947,7 +4007,7 @@ export default function ScreenDesigner({ 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 && { @@ -4770,7 +4830,7 @@ export default function ScreenDesigner({ z: clipComponent.position.z || 1, } as Position, parentId: undefined, // 붙여넣기 시 부모 관계 해제 - layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기 + layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용) }; newComponents.push(newComponent); }); @@ -4791,7 +4851,7 @@ export default function ScreenDesigner({ // console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개"); toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`); - }, [clipboard, layout, saveToHistory, activeLayerId]); + }, [clipboard, layout, saveToHistory]); // 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로) // 🆕 플로우 버튼 그룹 다이얼로그 상태 @@ -5693,21 +5753,152 @@ export default function ScreenDesigner({ }; }, [layout, selectedComponent]); + // 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반) + const handleRegionMouseDown = useCallback(( + e: React.MouseEvent, + layerId: string, + mode: "move" | "resize", + handle?: string, + ) => { + e.stopPropagation(); + e.preventDefault(); + const lid = Number(layerId); + const region = layerRegions[lid]; + if (!region) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + const x = (e.clientX - canvasRect.left) / zoomLevel; + const y = (e.clientY - canvasRect.top) / zoomLevel; + + setRegionDrag({ + isDrawing: false, + isDragging: mode === "move", + isResizing: mode === "resize", + targetLayerId: layerId, + startX: x, + startY: y, + currentX: x, + currentY: y, + resizeHandle: handle || null, + originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height }, + }); + }, [layerRegions, zoomLevel]); + + // 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈) + const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => { + if (!regionDrag.isDragging && !regionDrag.isResizing) return; + if (!regionDrag.targetLayerId) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + const x = (e.clientX - canvasRect.left) / zoomLevel; + const y = (e.clientY - canvasRect.top) / zoomLevel; + + if (regionDrag.isDragging && regionDrag.originalRegion) { + const dx = x - regionDrag.startX; + const dy = y - regionDrag.startY; + const newRegion = { + x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)), + y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)), + width: regionDrag.originalRegion.width, + height: regionDrag.originalRegion.height, + }; + const lid = Number(regionDrag.targetLayerId); + setLayerRegions((prev) => ({ + ...prev, + [lid]: { ...prev[lid], ...newRegion }, + })); + } else if (regionDrag.isResizing && regionDrag.originalRegion) { + const dx = x - regionDrag.startX; + const dy = y - regionDrag.startY; + const orig = regionDrag.originalRegion; + const newRegion = { ...orig }; + + const handle = regionDrag.resizeHandle; + if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx)); + if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy)); + if (handle?.includes("w")) { + newRegion.x = Math.max(0, Math.round(orig.x + dx)); + newRegion.width = Math.max(50, Math.round(orig.width - dx)); + } + if (handle?.includes("n")) { + newRegion.y = Math.max(0, Math.round(orig.y + dy)); + newRegion.height = Math.max(30, Math.round(orig.height - dy)); + } + + const lid = Number(regionDrag.targetLayerId); + setLayerRegions((prev) => ({ + ...prev, + [lid]: { ...prev[lid], ...newRegion }, + })); + } + }, [regionDrag, zoomLevel]); + + const handleRegionCanvasMouseUp = useCallback(async () => { + // 드래그 완료 시 DB에 영역 저장 + if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) { + const lid = Number(regionDrag.targetLayerId); + const region = layerRegions[lid]; + if (region) { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid); + const existingCondition = layerData?.conditionConfig || {}; + await screenApi.updateLayerCondition( + selectedScreen.screenId, lid, + { ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } } + ); + } catch { + console.error("영역 저장 실패"); + } + } + } + // 드래그 상태 초기화 + setRegionDrag({ + isDrawing: false, + isDragging: false, + isResizing: false, + targetLayerId: null, + startX: 0, startY: 0, currentX: 0, currentY: 0, + resizeHandle: null, + originalRegion: null, + }); + }, [regionDrag, layerRegions, selectedScreen]); + // 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영 - // 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음 + // 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존 const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => { - setLayout((prevLayout) => ({ - ...prevLayout, - layers: newLayers, - // components는 그대로 유지 - layerId 속성으로 레이어 구분 - // components: prevLayout.components (기본값으로 유지됨) - })); + setLayout((prevLayout) => { + // 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합 + const mergedLayers = newLayers.map((newLayer) => { + const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id); + if (!existingLayer) return newLayer; + + // LayerContext에서 온 데이터(condition 등)를 우선하되, + // layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존 + return { + ...existingLayer, // 기존 메타데이터 보존 (displayRegion 등) + ...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등) + // displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선 + displayRegion: newLayer.displayRegion !== undefined + ? newLayer.displayRegion + : existingLayer.displayRegion, + }; + }); + + return { + ...prevLayout, + layers: mergedLayers, + }; + }); }, []); // 🆕 활성 레이어 변경 핸들러 - const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => { - setActiveLayerIdLocal(newActiveLayerId); - }, []); + const handleActiveLayerChange = useCallback((newActiveLayerId: number) => { + setActiveLayerIdWithRef(newActiveLayerId); + }, [setActiveLayerIdWithRef]); // 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성 // 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠 @@ -5788,7 +5979,7 @@ export default function ScreenDesigner({
- + 컴포넌트 @@ -5821,9 +6012,41 @@ export default function ScreenDesigner({ /> - {/* 🆕 레이어 관리 탭 */} + {/* 🆕 레이어 관리 탭 (DB 기반) */} - + { + 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} + /> @@ -6396,6 +6619,14 @@ export default function ScreenDesigner({
); })()} + {/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */} + {activeLayerId > 1 && ( +
+
+ 레이어 {activeLayerId} 편집 중 +
+ )} + {/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{ + // 영역 이동/리사이즈 처리 + 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"; @@ -6504,6 +6751,79 @@ export default function ScreenDesigner({ 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 = { + 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 = { + 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 ( +
handleRegionMouseDown(e, String(layerId), "move")} + > + + 레이어 {layerId} - {region.layerName} + + {/* 리사이즈 핸들 */} + {resizeHandles.map((handle) => ( +
handleRegionMouseDown(e, String(layerId), "resize", handle)} + /> + ))} + {/* 삭제 버튼 */} + +
+ ); + })} + + {/* 일반 컴포넌트들 */} {regularComponents.map((component) => { const children = diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 3723b5f1..2271c96f 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr id: screenToPreview!.screenId, tableName: screenToPreview?.tableName, }} + layers={previewLayout.layers || []} />
))} diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 8867f96f..0721fb38 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -38,7 +38,7 @@ export const API_BASE_URL = getApiBaseUrl(); export const getFullImageUrl = (imagePath: string): string => { // 빈 값 체크 if (!imagePath) return ""; - + // 이미 전체 URL인 경우 그대로 반환 if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { return imagePath; @@ -49,18 +49,18 @@ export const getFullImageUrl = (imagePath: string): string => { // 런타임에 현재 hostname 확인 (SSR 시점이 아닌 클라이언트에서 실행될 때) if (typeof window !== "undefined") { const currentHost = window.location.hostname; - + // 프로덕션 환경: v1.vexplor.com → api.vexplor.com if (currentHost === "v1.vexplor.com") { return `https://api.vexplor.com${imagePath}`; } - + // 로컬 개발환경 if (currentHost === "localhost" || currentHost === "127.0.0.1") { return `http://localhost:8080${imagePath}`; } } - + // SSR 또는 알 수 없는 환경에서는 API_BASE_URL 사용 (fallback) // 주의: 프로덕션 URL이 https://api.vexplor.com/api 이므로 // 단순 .replace("/api", "")는 호스트명의 /api까지 제거하는 버그 발생 @@ -69,7 +69,7 @@ export const getFullImageUrl = (imagePath: string): string => { if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://")) { return `${baseUrl}${imagePath}`; } - + // 최종 fallback return imagePath; } @@ -157,7 +157,7 @@ const refreshToken = async (): Promise => { headers: { Authorization: `Bearer ${currentToken}`, }, - } + }, ); if (response.data?.success && response.data?.data?.token) { @@ -192,13 +192,16 @@ const startAutoRefresh = (): void => { } // 10분마다 토큰 상태 확인 - tokenRefreshTimer = setInterval(async () => { - const token = TokenManager.getToken(); - if (token && TokenManager.isTokenExpiringSoon(token)) { - console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작..."); - await refreshToken(); - } - }, 10 * 60 * 1000); // 10분 + tokenRefreshTimer = setInterval( + async () => { + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작..."); + await refreshToken(); + } + }, + 10 * 60 * 1000, + ); // 10분 // 페이지 로드 시 즉시 확인 const token = TokenManager.getToken(); @@ -230,14 +233,18 @@ const setupActivityBasedRefresh = (): void => { ["click", "keydown", "scroll", "mousemove"].forEach((event) => { // 너무 잦은 호출 방지를 위해 throttle 적용 let throttleTimer: NodeJS.Timeout | null = null; - window.addEventListener(event, () => { - if (!throttleTimer) { - throttleTimer = setTimeout(() => { - handleActivity(); - throttleTimer = null; - }, 1000); // 1초 throttle - } - }, { passive: true }); + window.addEventListener( + event, + () => { + if (!throttleTimer) { + throttleTimer = setTimeout(() => { + handleActivity(); + throttleTimer = null; + }, 1000); // 1초 throttle + } + }, + { passive: true }, + ); }); }; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 597e9795..f6aad934 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -213,6 +213,28 @@ export const screenApi = { await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData); }, + // 레이어 목록 조회 + getScreenLayers: async (screenId: number): Promise => { + const response = await apiClient.get(`/screen-management/screens/${screenId}/layers`); + return response.data.data || []; + }, + + // 특정 레이어 레이아웃 조회 + getLayerLayout: async (screenId: number, layerId: number): Promise => { + const response = await apiClient.get(`/screen-management/screens/${screenId}/layers/${layerId}/layout`); + return response.data.data; + }, + + // 레이어 삭제 + deleteLayer: async (screenId: number, layerId: number): Promise => { + await apiClient.delete(`/screen-management/screens/${screenId}/layers/${layerId}`); + }, + + // 레이어 조건 설정 업데이트 + updateLayerCondition: async (screenId: number, layerId: number, conditionConfig: any, layerName?: string): Promise => { + await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName }); + }, + // ======================================== // POP 레이아웃 관리 (모바일/태블릿) // ======================================== diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 82037cd0..5908e5f3 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -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({ diff --git a/frontend/lib/utils/layoutV2Converter.ts b/frontend/lib/utils/layoutV2Converter.ts index fff56bf9..6adc3526 100644 --- a/frontend/lib/utils/layoutV2Converter.ts +++ b/frontend/lib/utils/layoutV2Converter.ts @@ -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): // 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; } // ============================================ diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 7f21fa44..49264541 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -37,6 +37,7 @@ export type DateFormat = */ export interface CategoryFormatMapping { categoryValueId: number; // 카테고리 값 ID + categoryValueCode?: string; // 카테고리 값 코드 (V2Select에서 valueCode 사용 시 매칭용) categoryValueLabel: string; // 카테고리 값 라벨 (표시용) categoryValuePath?: string; // 전체 경로 (예: "원자재/벌크/가스켓") format: string; // 생성할 형식 (예: "ITM", "VLV") diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 4fa22259..0687e8d3 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -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;