diff --git a/.cursor/rules/ai-developer-collaboration-rules.mdc b/.cursor/rules/ai-developer-collaboration-rules.mdc
index ccdcc9fc..b1da651a 100644
--- a/.cursor/rules/ai-developer-collaboration-rules.mdc
+++ b/.cursor/rules/ai-developer-collaboration-rules.mdc
@@ -278,4 +278,117 @@ const hiddenColumns = new Set([
---
+## 11. 화면관리 시스템 위젯 개발 가이드
+
+### 위젯 크기 설정의 핵심 원칙
+
+화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
+
+#### ✅ 올바른 크기 설정 패턴
+
+```tsx
+// 위젯 컴포넌트 내부
+export function YourWidget({ component }: YourWidgetProps) {
+ return (
+
+ {/* 위젯 내용 */}
+
+ );
+}
+```
+
+#### ❌ 잘못된 크기 설정 패턴
+
+```tsx
+// 이렇게 하면 안 됩니다!
+
+```
+
+### 이유
+
+1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
+
+ ```tsx
+ const baseStyle = {
+ left: `${position.x}px`,
+ top: `${position.y}px`,
+ width: getWidth(), // size.width 사용
+ height: getHeight(), // size.height 사용
+ };
+ ```
+
+2. 위젯 내부에서 크기를 다시 설정하면:
+ - 중복 설정으로 인한 충돌
+ - 내부 컨텐츠가 설정한 크기보다 작게 표시됨
+ - 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
+
+### 위젯이 관리해야 할 스타일
+
+위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
+
+- ✅ `padding`: 내부 여백
+- ✅ `backgroundColor`: 배경색
+- ✅ `border`, `borderRadius`: 테두리
+- ✅ `gap`: 자식 요소 간격
+- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
+
+### 위젯 등록 시 defaultSize
+
+```tsx
+ComponentRegistry.registerComponent({
+ id: "your-widget",
+ name: "위젯 이름",
+ category: "utility",
+ defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
+ component: YourWidget,
+ defaultProps: {
+ style: {
+ padding: "0.75rem",
+ // width, height는 defaultSize로 제어되므로 여기 불필요
+ },
+ },
+});
+```
+
+### 레이아웃 구조
+
+```tsx
+// 전체 높이를 차지하고 내부 요소를 정렬
+
+ {/* 왼쪽 컨텐츠 */}
+
{/* ... */}
+
+ {/* 오른쪽 버튼들 */}
+
+ {/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
+
+
+```
+
+### 체크리스트
+
+위젯 개발 시 다음을 확인하세요:
+
+- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
+- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
+- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
+- [ ] `defaultSize`에 적절한 기본 크기 설정
+- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
+- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
+
+---
+
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts
index 3f599fa5..85159dc2 100644
--- a/backend-node/src/controllers/tableManagementController.ts
+++ b/backend-node/src/controllers/tableManagementController.ts
@@ -1617,10 +1617,11 @@ export async function getCategoryColumnsByMenu(
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
- return res.status(400).json({
+ res.status(400).json({
success: false,
message: "메뉴 OBJID가 필요합니다.",
});
+ return;
}
// 1. 형제 메뉴 조회
@@ -1648,11 +1649,12 @@ export async function getCategoryColumnsByMenu(
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
- return res.json({
+ res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
+ return;
}
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)
diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts
index 40c05861..8cbd8a29 100644
--- a/backend-node/src/services/commonCodeService.ts
+++ b/backend-node/src/services/commonCodeService.ts
@@ -23,7 +23,8 @@ export interface CodeInfo {
description?: string | null;
sort_order: number;
is_active: string;
- company_code: string; // 추가
+ company_code: string;
+ menu_objid?: number | null; // 메뉴 기반 코드 관리용
created_date?: Date | null;
created_by?: string | null;
updated_date?: Date | null;
diff --git a/backend-node/src/services/ddlExecutionService.ts b/backend-node/src/services/ddlExecutionService.ts
index 2ed01231..c7a611d3 100644
--- a/backend-node/src/services/ddlExecutionService.ts
+++ b/backend-node/src/services/ddlExecutionService.ts
@@ -104,7 +104,7 @@ export class DDLExecutionService {
await this.saveTableMetadata(client, tableName, description);
// 5-3. 컬럼 메타데이터 저장
- await this.saveColumnMetadata(client, tableName, columns);
+ await this.saveColumnMetadata(client, tableName, columns, userCompanyCode);
});
// 6. 성공 로그 기록
@@ -272,7 +272,7 @@ export class DDLExecutionService {
await client.query(ddlQuery);
// 6-2. 컬럼 메타데이터 저장
- await this.saveColumnMetadata(client, tableName, [column]);
+ await this.saveColumnMetadata(client, tableName, [column], userCompanyCode);
});
// 7. 성공 로그 기록
@@ -446,7 +446,8 @@ CREATE TABLE "${tableName}" (${baseColumns},
private async saveColumnMetadata(
client: any,
tableName: string,
- columns: CreateColumnDefinition[]
+ columns: CreateColumnDefinition[],
+ companyCode: string
): Promise {
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
await client.query(
@@ -508,19 +509,19 @@ CREATE TABLE "${tableName}" (${baseColumns},
await client.query(
`
INSERT INTO table_type_columns (
- table_name, column_name, input_type, detail_settings,
+ table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
- $1, $2, $3, '{}',
- 'Y', $4, now(), now()
+ $1, $2, $3, $4, '{}',
+ 'Y', $5, now(), now()
)
- ON CONFLICT (table_name, column_name)
+ ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
- input_type = $3,
- display_order = $4,
+ input_type = $4,
+ display_order = $5,
updated_date = now()
`,
- [tableName, defaultCol.name, defaultCol.inputType, defaultCol.order]
+ [tableName, defaultCol.name, companyCode, defaultCol.inputType, defaultCol.order]
);
}
@@ -535,20 +536,20 @@ CREATE TABLE "${tableName}" (${baseColumns},
await client.query(
`
INSERT INTO table_type_columns (
- table_name, column_name, input_type, detail_settings,
+ table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
- $1, $2, $3, $4,
- 'Y', $5, now(), now()
+ $1, $2, $3, $4, $5,
+ 'Y', $6, now(), now()
)
- ON CONFLICT (table_name, column_name)
+ ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
- input_type = $3,
- detail_settings = $4,
- display_order = $5,
+ input_type = $4,
+ detail_settings = $5,
+ display_order = $6,
updated_date = now()
`,
- [tableName, column.name, inputType, detailSettings, i]
+ [tableName, column.name, companyCode, inputType, detailSettings, i]
);
}
diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts
index 6877fedd..f1795ba0 100644
--- a/backend-node/src/services/entityJoinService.ts
+++ b/backend-node/src/services/entityJoinService.ts
@@ -24,20 +24,19 @@ export class EntityJoinService {
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
- // column_labels에서 entity 타입인 컬럼들 조회
+ // column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
const entityColumns = await query<{
column_name: string;
+ input_type: string;
reference_table: string;
reference_column: string;
display_column: string | null;
}>(
- `SELECT column_name, reference_table, reference_column, display_column
+ `SELECT column_name, input_type, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1
- AND web_type = $2
- AND reference_table IS NOT NULL
- AND reference_column IS NOT NULL`,
- [tableName, "entity"]
+ AND input_type IN ('entity', 'category')`,
+ [tableName]
);
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
@@ -77,18 +76,34 @@ export class EntityJoinService {
}
for (const column of entityColumns) {
+ // 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정
+ let referenceTable = column.reference_table;
+ let referenceColumn = column.reference_column;
+ let displayColumn = column.display_column;
+
+ if (column.input_type === 'category') {
+ // 카테고리 타입: reference 정보가 비어있어도 자동 설정
+ referenceTable = referenceTable || 'table_column_category_values';
+ referenceColumn = referenceColumn || 'value_code';
+ displayColumn = displayColumn || 'value_label';
+
+ logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
+ referenceTable,
+ referenceColumn,
+ displayColumn,
+ });
+ }
+
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
- reference_table: column.reference_table,
- reference_column: column.reference_column,
- display_column: column.display_column,
+ input_type: column.input_type,
+ reference_table: referenceTable,
+ reference_column: referenceColumn,
+ display_column: displayColumn,
});
- if (
- !column.column_name ||
- !column.reference_table ||
- !column.reference_column
- ) {
+ if (!column.column_name || !referenceTable || !referenceColumn) {
+ logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`);
continue;
}
@@ -112,27 +127,28 @@ export class EntityJoinService {
separator,
screenConfig,
});
- } else if (column.display_column && column.display_column !== "none") {
+ } else if (displayColumn && displayColumn !== "none") {
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
- displayColumns = [column.display_column];
+ displayColumns = [displayColumn];
logger.info(
- `🔧 기존 display_column 사용: ${column.column_name} → ${column.display_column}`
+ `🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
);
} else {
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
- // 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
- let defaultDisplayColumn = column.reference_column;
- if (column.reference_table === "dept_info") {
+ let defaultDisplayColumn = referenceColumn;
+ if (referenceTable === "dept_info") {
defaultDisplayColumn = "dept_name";
- } else if (column.reference_table === "company_info") {
+ } else if (referenceTable === "company_info") {
defaultDisplayColumn = "company_name";
- } else if (column.reference_table === "user_info") {
+ } else if (referenceTable === "user_info") {
defaultDisplayColumn = "user_name";
+ } else if (referenceTable === "category_values") {
+ defaultDisplayColumn = "category_name";
}
displayColumns = [defaultDisplayColumn];
logger.info(
- `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${column.reference_table})`
+ `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})`
);
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
}
@@ -143,8 +159,8 @@ export class EntityJoinService {
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: column.column_name,
- referenceTable: column.reference_table,
- referenceColumn: column.reference_column,
+ referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
+ referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
displayColumns: displayColumns,
displayColumn: displayColumns[0], // 하위 호환성
aliasColumn: aliasColumn,
@@ -245,11 +261,14 @@ export class EntityJoinService {
config.displayColumn,
];
const separator = config.separator || " - ";
+
+ // 결과 컬럼 배열 (aliasColumn + _label 필드)
+ const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
- return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
+ resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
@@ -265,12 +284,18 @@ export class EntityJoinService {
"company_name",
"sales_yn",
"status",
+ "value_label", // table_column_category_values
+ "user_name", // user_info
].includes(col);
if (isJoinTableColumn) {
- return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`;
+ resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
+
+ // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
+ // sourceColumn_label 형식으로 추가
+ resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
} else {
- return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
+ resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
}
} else {
// 여러 컬럼인 경우 CONCAT으로 연결
@@ -291,6 +316,8 @@ export class EntityJoinService {
"company_name",
"sales_yn",
"status",
+ "value_label", // table_column_category_values
+ "user_name", // user_info
].includes(col);
if (isJoinTableColumn) {
@@ -303,8 +330,11 @@ export class EntityJoinService {
})
.join(` || '${separator}' || `);
- return `(${concatParts}) AS ${config.aliasColumn}`;
+ resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
}
+
+ // 모든 resultColumns를 반환
+ return resultColumns.join(", ");
})
.join(", ");
@@ -320,6 +350,12 @@ export class EntityJoinService {
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
+
+ // table_column_category_values는 특별한 조인 조건 필요
+ if (config.referenceTable === 'table_column_category_values') {
+ return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`;
+ }
+
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
@@ -380,6 +416,14 @@ export class EntityJoinService {
return "join";
}
+ // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
+ if (config.referenceTable === 'table_column_category_values') {
+ logger.info(
+ `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
+ );
+ return "join";
+ }
+
// 참조 테이블의 캐시 가능성 확인
const displayCol =
config.displayColumn ||
diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts
index b22beb88..86df579c 100644
--- a/backend-node/src/services/menuService.ts
+++ b/backend-node/src/services/menuService.ts
@@ -36,29 +36,61 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise
try {
logger.debug("메뉴 스코프 조회 시작", { menuObjid });
- // 1. 현재 메뉴 자신을 포함
- const menuObjids = [menuObjid];
+ // 1. 현재 메뉴 정보 조회 (부모 ID 확인)
+ const currentMenuQuery = `
+ SELECT parent_obj_id FROM menu_info
+ WHERE objid = $1
+ `;
+ const currentMenuResult = await pool.query(currentMenuQuery, [menuObjid]);
- // 2. 현재 메뉴의 자식 메뉴들 조회
- const childrenQuery = `
+ if (currentMenuResult.rows.length === 0) {
+ logger.warn("메뉴를 찾을 수 없음, 자기 자신만 반환", { menuObjid });
+ return [menuObjid];
+ }
+
+ const parentObjId = Number(currentMenuResult.rows[0].parent_obj_id);
+
+ // 2. 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환
+ if (parentObjId === 0) {
+ logger.debug("최상위 메뉴, 자기 자신만 반환", { menuObjid });
+ return [menuObjid];
+ }
+
+ // 3. 형제 메뉴들 조회 (같은 부모를 가진 메뉴들)
+ const siblingsQuery = `
SELECT objid FROM menu_info
WHERE parent_obj_id = $1
ORDER BY objid
`;
- const childrenResult = await pool.query(childrenQuery, [menuObjid]);
+ const siblingsResult = await pool.query(siblingsQuery, [parentObjId]);
- const childObjids = childrenResult.rows.map((row) => Number(row.objid));
+ const siblingObjids = siblingsResult.rows.map((row) => Number(row.objid));
- // 3. 자신 + 자식을 합쳐서 정렬
- const allObjids = Array.from(new Set([...menuObjids, ...childObjids])).sort((a, b) => a - b);
+ // 4. 각 형제 메뉴(자기 자신 포함)의 자식 메뉴들도 조회
+ const allObjids = [...siblingObjids];
+
+ for (const siblingObjid of siblingObjids) {
+ const childrenQuery = `
+ SELECT objid FROM menu_info
+ WHERE parent_obj_id = $1
+ ORDER BY objid
+ `;
+ const childrenResult = await pool.query(childrenQuery, [siblingObjid]);
+ const childObjids = childrenResult.rows.map((row) => Number(row.objid));
+ allObjids.push(...childObjids);
+ }
+
+ // 5. 중복 제거 및 정렬
+ const uniqueObjids = Array.from(new Set(allObjids)).sort((a, b) => a - b);
logger.debug("메뉴 스코프 조회 완료", {
- menuObjid,
- childCount: childObjids.length,
- totalCount: allObjids.length
+ menuObjid,
+ parentObjId,
+ siblingCount: siblingObjids.length,
+ totalCount: uniqueObjids.length
});
- return allObjids;
+ return uniqueObjids;
} catch (error: any) {
logger.error("메뉴 스코프 조회 실패", {
menuObjid,
diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts
index db76bbee..368559df 100644
--- a/backend-node/src/services/numberingRuleService.ts
+++ b/backend-node/src/services/numberingRuleService.ts
@@ -161,6 +161,8 @@ class NumberingRuleService {
companyCode: string,
menuObjid?: number
): Promise {
+ let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
+
try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode,
@@ -170,7 +172,6 @@ class NumberingRuleService {
const pool = getPool();
// 1. 형제 메뉴 OBJID 조회
- let siblingObjids: number[] = [];
if (menuObjid) {
siblingObjids = await getSiblingMenuObjids(menuObjid);
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts
index 29cad453..e60d6cd2 100644
--- a/backend-node/src/services/tableCategoryValueService.ts
+++ b/backend-node/src/services/tableCategoryValueService.ts
@@ -117,127 +117,64 @@ class TableCategoryValueService {
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
- if (menuObjid && siblingObjids.length > 0) {
- // 메뉴 스코프 적용
- query = `
- SELECT
- value_id AS "valueId",
- table_name AS "tableName",
- column_name AS "columnName",
- value_code AS "valueCode",
- value_label AS "valueLabel",
- value_order AS "valueOrder",
- parent_value_id AS "parentValueId",
- depth,
- description,
- color,
- icon,
- is_active AS "isActive",
- is_default AS "isDefault",
- company_code AS "companyCode",
- menu_objid AS "menuObjid",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- created_by AS "createdBy",
- updated_by AS "updatedBy"
- FROM table_column_category_values
- WHERE table_name = $1
- AND column_name = $2
- AND menu_objid = ANY($3)
- `;
- params = [tableName, columnName, siblingObjids];
- } else {
- // 테이블 스코프 (하위 호환성)
- query = `
- SELECT
- value_id AS "valueId",
- table_name AS "tableName",
- column_name AS "columnName",
- value_code AS "valueCode",
- value_label AS "valueLabel",
- value_order AS "valueOrder",
- parent_value_id AS "parentValueId",
- depth,
- description,
- color,
- icon,
- is_active AS "isActive",
- is_default AS "isDefault",
- company_code AS "companyCode",
- menu_objid AS "menuObjid",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- created_by AS "createdBy",
- updated_by AS "updatedBy"
- FROM table_column_category_values
- WHERE table_name = $1
- AND column_name = $2
- `;
- params = [tableName, columnName];
- }
+ // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
+ query = `
+ SELECT
+ value_id AS "valueId",
+ table_name AS "tableName",
+ column_name AS "columnName",
+ value_code AS "valueCode",
+ value_label AS "valueLabel",
+ value_order AS "valueOrder",
+ parent_value_id AS "parentValueId",
+ depth,
+ description,
+ color,
+ icon,
+ is_active AS "isActive",
+ is_default AS "isDefault",
+ company_code AS "companyCode",
+ menu_objid AS "menuObjid",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt",
+ created_by AS "createdBy",
+ updated_by AS "updatedBy"
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ `;
+ params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
} else {
// 일반 회사: 자신의 카테고리 값만 조회
- if (menuObjid && siblingObjids.length > 0) {
- // 메뉴 스코프 적용
- query = `
- SELECT
- value_id AS "valueId",
- table_name AS "tableName",
- column_name AS "columnName",
- value_code AS "valueCode",
- value_label AS "valueLabel",
- value_order AS "valueOrder",
- parent_value_id AS "parentValueId",
- depth,
- description,
- color,
- icon,
- is_active AS "isActive",
- is_default AS "isDefault",
- company_code AS "companyCode",
- menu_objid AS "menuObjid",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- created_by AS "createdBy",
- updated_by AS "updatedBy"
- FROM table_column_category_values
- WHERE table_name = $1
- AND column_name = $2
- AND menu_objid = ANY($3)
- AND company_code = $4
- `;
- params = [tableName, columnName, siblingObjids, companyCode];
- } else {
- // 테이블 스코프 (하위 호환성)
- query = `
- SELECT
- value_id AS "valueId",
- table_name AS "tableName",
- column_name AS "columnName",
- value_code AS "valueCode",
- value_label AS "valueLabel",
- value_order AS "valueOrder",
- parent_value_id AS "parentValueId",
- depth,
- description,
- color,
- icon,
- is_active AS "isActive",
- is_default AS "isDefault",
- company_code AS "companyCode",
- menu_objid AS "menuObjid",
- created_at AS "createdAt",
- updated_at AS "updatedAt",
- created_by AS "createdBy",
- updated_by AS "updatedBy"
- FROM table_column_category_values
- WHERE table_name = $1
- AND column_name = $2
- AND company_code = $3
- `;
- params = [tableName, columnName, companyCode];
- }
+ // 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
+ query = `
+ SELECT
+ value_id AS "valueId",
+ table_name AS "tableName",
+ column_name AS "columnName",
+ value_code AS "valueCode",
+ value_label AS "valueLabel",
+ value_order AS "valueOrder",
+ parent_value_id AS "parentValueId",
+ depth,
+ description,
+ color,
+ icon,
+ is_active AS "isActive",
+ is_default AS "isDefault",
+ company_code AS "companyCode",
+ menu_objid AS "menuObjid",
+ created_at AS "createdAt",
+ updated_at AS "updatedAt",
+ created_by AS "createdBy",
+ updated_by AS "updatedBy"
+ FROM table_column_category_values
+ WHERE table_name = $1
+ AND column_name = $2
+ AND company_code = $3
+ `;
+ params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
}
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index b45a0424..ac8b62fd 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -1069,12 +1069,28 @@ export class TableManagementService {
paramCount: number;
} | null> {
try {
+ // 🔧 {value, operator} 형태의 필터 객체 처리
+ let actualValue = value;
+ let operator = "contains"; // 기본값
+
+ if (typeof value === "object" && value !== null && "value" in value) {
+ actualValue = value.value;
+ operator = value.operator || "contains";
+
+ logger.info("🔍 필터 객체 처리:", {
+ columnName,
+ originalValue: value,
+ actualValue,
+ operator,
+ });
+ }
+
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if (
- value === "__ALL__" ||
- value === "" ||
- value === null ||
- value === undefined
+ actualValue === "__ALL__" ||
+ actualValue === "" ||
+ actualValue === null ||
+ actualValue === undefined
) {
return null;
}
@@ -1083,12 +1099,22 @@ export class TableManagementService {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (!columnInfo) {
- // 컬럼 정보가 없으면 기본 문자열 검색
- return {
- whereClause: `${columnName}::text ILIKE $${paramIndex}`,
- values: [`%${value}%`],
- paramCount: 1,
- };
+ // 컬럼 정보가 없으면 operator에 따른 기본 검색
+ switch (operator) {
+ case "equals":
+ return {
+ whereClause: `${columnName}::text = $${paramIndex}`,
+ values: [actualValue],
+ paramCount: 1,
+ };
+ case "contains":
+ default:
+ return {
+ whereClause: `${columnName}::text ILIKE $${paramIndex}`,
+ values: [`%${actualValue}%`],
+ paramCount: 1,
+ };
+ }
}
const webType = columnInfo.webType;
@@ -1097,17 +1123,17 @@ export class TableManagementService {
switch (webType) {
case "date":
case "datetime":
- return this.buildDateRangeCondition(columnName, value, paramIndex);
+ return this.buildDateRangeCondition(columnName, actualValue, paramIndex);
case "number":
case "decimal":
- return this.buildNumberRangeCondition(columnName, value, paramIndex);
+ return this.buildNumberRangeCondition(columnName, actualValue, paramIndex);
case "code":
return await this.buildCodeSearchCondition(
tableName,
columnName,
- value,
+ actualValue,
paramIndex
);
@@ -1115,15 +1141,15 @@ export class TableManagementService {
return await this.buildEntitySearchCondition(
tableName,
columnName,
- value,
+ actualValue,
paramIndex
);
default:
- // 기본 문자열 검색
+ // 기본 문자열 검색 (actualValue 사용)
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
- values: [`%${value}%`],
+ values: [`%${actualValue}%`],
paramCount: 1,
};
}
@@ -1133,9 +1159,14 @@ export class TableManagementService {
error
);
// 오류 시 기본 검색으로 폴백
+ let fallbackValue = value;
+ if (typeof value === "object" && value !== null && "value" in value) {
+ fallbackValue = value.value;
+ }
+
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
- values: [`%${value}%`],
+ values: [`%${fallbackValue}%`],
paramCount: 1,
};
}
@@ -1494,6 +1525,7 @@ export class TableManagementService {
search?: Record;
sortBy?: string;
sortOrder?: string;
+ companyCode?: string;
}
): Promise<{
data: any[];
@@ -1503,7 +1535,7 @@ export class TableManagementService {
totalPages: number;
}> {
try {
- const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
+ const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options);
@@ -1517,6 +1549,14 @@ export class TableManagementService {
let searchValues: any[] = [];
let paramIndex = 1;
+ // 멀티테넌시 필터 추가 (company_code)
+ if (companyCode) {
+ whereConditions.push(`company_code = $${paramIndex}`);
+ searchValues.push(companyCode);
+ paramIndex++;
+ logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`);
+ }
+
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== "") {
@@ -2048,6 +2088,7 @@ export class TableManagementService {
sortBy?: string;
sortOrder?: string;
enableEntityJoin?: boolean;
+ companyCode?: string; // 멀티테넌시 필터용
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
@@ -2213,11 +2254,20 @@ export class TableManagementService {
const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성
- const whereClause = await this.buildWhereClause(
+ let whereClause = await this.buildWhereClause(
tableName,
options.search
);
+ // 멀티테넌시 필터 추가 (company_code)
+ if (options.companyCode) {
+ const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
+ whereClause = whereClause
+ ? `${whereClause} AND ${companyFilter}`
+ : companyFilter;
+ logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`);
+ }
+
// ORDER BY 절 구성
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
@@ -2343,6 +2393,7 @@ export class TableManagementService {
search?: Record;
sortBy?: string;
sortOrder?: string;
+ companyCode?: string;
},
startTime: number
): Promise {
@@ -2530,11 +2581,11 @@ export class TableManagementService {
);
}
- basicResult = await this.getTableData(tableName, fallbackOptions);
+ basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
}
} else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
- basicResult = await this.getTableData(tableName, options);
+ basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
}
// Entity 값들을 캐시에서 룩업하여 변환
@@ -2807,10 +2858,14 @@ export class TableManagementService {
}
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
else {
+ // whereClause에서 company_code 추출 (멀티테넌시 필터)
+ const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/);
+ const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
+
return await this.executeCachedLookup(
tableName,
cacheableJoins,
- { page: Math.floor(offset / limit) + 1, size: limit, search: {} },
+ { page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
startTime
);
}
@@ -2831,6 +2886,13 @@ export class TableManagementService {
const dbJoins: EntityJoinConfig[] = [];
for (const config of joinConfigs) {
+ // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
+ if (config.referenceTable === 'table_column_category_values') {
+ dbJoins.push(config);
+ console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
+ continue;
+ }
+
// 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,
diff --git a/docs/테이블_검색필터_컴포넌트_분리_계획서.md b/docs/테이블_검색필터_컴포넌트_분리_계획서.md
new file mode 100644
index 00000000..28bd54b8
--- /dev/null
+++ b/docs/테이블_검색필터_컴포넌트_분리_계획서.md
@@ -0,0 +1,2016 @@
+# 테이블 검색 필터 컴포넌트 분리 및 통합 계획서
+
+## 📋 목차
+
+1. [현황 분석](#1-현황-분석)
+2. [목표 및 요구사항](#2-목표-및-요구사항)
+3. [아키텍처 설계](#3-아키텍처-설계)
+4. [구현 계획](#4-구현-계획)
+5. [파일 구조](#5-파일-구조)
+6. [통합 시나리오](#6-통합-시나리오)
+7. [주요 기능 및 개선 사항](#7-주요-기능-및-개선-사항)
+8. [예상 장점](#8-예상-장점)
+9. [구현 우선순위](#9-구현-우선순위)
+10. [체크리스트](#10-체크리스트)
+
+---
+
+## 1. 현황 분석
+
+### 1.1 현재 구조
+
+- **테이블 리스트 컴포넌트**에 테이블 옵션이 내장되어 있음
+- 각 테이블 컴포넌트마다 개별적으로 옵션 기능 구현
+- 코드 중복 및 유지보수 어려움
+
+### 1.2 현재 제공 기능
+
+#### 테이블 옵션
+
+- 컬럼 표시/숨김 설정
+- 컬럼 순서 변경 (드래그앤드롭)
+- 컬럼 너비 조정
+- 고정 컬럼 설정
+
+#### 필터 설정
+
+- 컬럼별 검색 필터 적용
+- 다중 필터 조건 지원
+- 연산자 선택 (같음, 포함, 시작, 끝)
+
+#### 그룹 설정
+
+- 컬럼별 데이터 그룹화
+- 다중 그룹 레벨 지원
+- 그룹별 집계 표시
+
+### 1.3 적용 대상 컴포넌트
+
+1. **TableList**: 기본 테이블 리스트 컴포넌트
+2. **SplitPanel**: 좌/우 분할 테이블 (마스터-디테일 관계)
+3. **FlowWidget**: 플로우 스텝별 데이터 테이블
+
+---
+
+## 2. 목표 및 요구사항
+
+### 2.1 핵심 목표
+
+1. 테이블 옵션 기능을 **재사용 가능한 공통 컴포넌트**로 분리
+2. 화면에 있는 테이블 컴포넌트를 **자동 감지**하여 검색 가능
+3. 각 컴포넌트의 테이블 데이터와 **독립적으로 연동**
+4. 기존 기능을 유지하면서 확장 가능한 구조 구축
+
+### 2.2 기능 요구사항
+
+#### 자동 감지
+
+- 화면 로드 시 테이블 컴포넌트 자동 식별
+- 컴포넌트 추가/제거 시 동적 반영
+- 테이블 ID 기반 고유 식별
+
+#### 다중 테이블 지원
+
+- 한 화면에 여러 테이블이 있을 경우 선택 가능
+- 테이블 간 독립적인 설정 관리
+- 선택된 테이블에만 옵션 적용
+
+#### 실시간 적용
+
+- 필터/그룹 설정 시 즉시 테이블 업데이트
+- 불필요한 전체 화면 리렌더링 방지
+- 최적화된 데이터 조회
+
+#### 상태 독립성
+
+- 각 테이블의 설정이 독립적으로 유지
+- 한 테이블의 설정이 다른 테이블에 영향 없음
+- 화면 전환 시 설정 보존 (선택사항)
+
+### 2.3 비기능 요구사항
+
+- **성능**: 100개 이상의 컬럼도 부드럽게 처리
+- **접근성**: 키보드 네비게이션 지원
+- **반응형**: 모바일/태블릿 대응
+- **확장성**: 새로운 테이블 타입 추가 용이
+
+---
+
+## 3. 아키텍처 설계
+
+### 3.1 컴포넌트 구조
+
+```
+TableOptionsToolbar (신규 - 메인 툴바)
+├── TableSelector (다중 테이블 선택 드롭다운)
+├── ColumnVisibilityButton (테이블 옵션 버튼)
+├── FilterButton (필터 설정 버튼)
+└── GroupingButton (그룹 설정 버튼)
+
+패널 컴포넌트들 (Dialog 형태)
+├── ColumnVisibilityPanel (컬럼 표시/숨김 설정)
+├── FilterPanel (검색 필터 설정)
+└── GroupingPanel (그룹화 설정)
+
+Context & Provider
+├── TableOptionsContext (테이블 등록 및 관리)
+└── TableOptionsProvider (전역 상태 관리)
+
+화면 컴포넌트들 (기존 수정)
+├── TableList → TableOptionsContext 연동
+├── SplitPanel → 좌/우 각각 등록
+└── FlowWidget → 스텝별 등록
+```
+
+### 3.2 데이터 흐름
+
+```mermaid
+graph TD
+ A[화면 컴포넌트] --> B[registerTable 호출]
+ B --> C[TableOptionsContext에 등록]
+ C --> D[TableOptionsToolbar에서 목록 조회]
+ D --> E[사용자가 테이블 선택]
+ E --> F[옵션 버튼 클릭]
+ F --> G[패널 열림]
+ G --> H[설정 변경]
+ H --> I[선택된 테이블의 콜백 호출]
+ I --> J[테이블 컴포넌트 업데이트]
+ J --> K[데이터 재조회/재렌더링]
+```
+
+### 3.3 상태 관리 구조
+
+```typescript
+// Context에서 관리하는 전역 상태
+{
+ registeredTables: Map {
+ "table-list-123": {
+ tableId: "table-list-123",
+ label: "품목 관리",
+ tableName: "item_info",
+ columns: [...],
+ onFilterChange: (filters) => {},
+ onGroupChange: (groups) => {},
+ onColumnVisibilityChange: (columns) => {}
+ },
+ "split-panel-left-456": {
+ tableId: "split-panel-left-456",
+ label: "분할 패널 (좌측)",
+ tableName: "category_values",
+ columns: [...],
+ ...
+ }
+ }
+}
+
+// 각 테이블 컴포넌트가 관리하는 로컬 상태
+{
+ filters: [
+ { columnName: "item_name", operator: "contains", value: "나사" }
+ ],
+ grouping: ["category_id", "material"],
+ columnVisibility: [
+ { columnName: "item_name", visible: true, width: 200, order: 1 },
+ { columnName: "status", visible: false, width: 100, order: 2 }
+ ]
+}
+```
+
+---
+
+## 4. 구현 계획
+
+### Phase 1: Context 및 Provider 구현
+
+#### 4.1.1 타입 정의
+
+**파일**: `types/table-options.ts`
+
+```typescript
+/**
+ * 테이블 필터 조건
+ */
+export interface TableFilter {
+ columnName: string;
+ operator:
+ | "equals"
+ | "contains"
+ | "startsWith"
+ | "endsWith"
+ | "gt"
+ | "lt"
+ | "gte"
+ | "lte"
+ | "notEquals";
+ value: string | number | boolean;
+}
+
+/**
+ * 컬럼 표시 설정
+ */
+export interface ColumnVisibility {
+ columnName: string;
+ visible: boolean;
+ width?: number;
+ order?: number;
+ fixed?: boolean; // 좌측 고정 여부
+}
+
+/**
+ * 테이블 컬럼 정보
+ */
+export interface TableColumn {
+ columnName: string;
+ columnLabel: string;
+ inputType: string;
+ visible: boolean;
+ width: number;
+ sortable?: boolean;
+ filterable?: boolean;
+}
+
+/**
+ * 테이블 등록 정보
+ */
+export interface TableRegistration {
+ tableId: string; // 고유 ID (예: "table-list-123")
+ label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
+ tableName: string; // 실제 DB 테이블명 (예: "item_info")
+ columns: TableColumn[];
+
+ // 콜백 함수들
+ onFilterChange: (filters: TableFilter[]) => void;
+ onGroupChange: (groups: string[]) => void;
+ onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
+}
+
+/**
+ * Context 값 타입
+ */
+export interface TableOptionsContextValue {
+ registeredTables: Map;
+ registerTable: (registration: TableRegistration) => void;
+ unregisterTable: (tableId: string) => void;
+ getTable: (tableId: string) => TableRegistration | undefined;
+ selectedTableId: string | null;
+ setSelectedTableId: (tableId: string | null) => void;
+}
+```
+
+#### 4.1.2 Context 생성
+
+**파일**: `contexts/TableOptionsContext.tsx`
+
+```typescript
+import React, {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ ReactNode,
+} from "react";
+import {
+ TableRegistration,
+ TableOptionsContextValue,
+} from "@/types/table-options";
+
+const TableOptionsContext = createContext(
+ undefined
+);
+
+export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ const [registeredTables, setRegisteredTables] = useState<
+ Map
+ >(new Map());
+ const [selectedTableId, setSelectedTableId] = useState(null);
+
+ /**
+ * 테이블 등록
+ */
+ const registerTable = useCallback((registration: TableRegistration) => {
+ setRegisteredTables((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(registration.tableId, registration);
+
+ // 첫 번째 테이블이면 자동 선택
+ if (newMap.size === 1) {
+ setSelectedTableId(registration.tableId);
+ }
+
+ return newMap;
+ });
+
+ console.log(
+ `[TableOptions] 테이블 등록: ${registration.label} (${registration.tableId})`
+ );
+ }, []);
+
+ /**
+ * 테이블 등록 해제
+ */
+ const unregisterTable = useCallback(
+ (tableId: string) => {
+ setRegisteredTables((prev) => {
+ const newMap = new Map(prev);
+ const removed = newMap.delete(tableId);
+
+ if (removed) {
+ console.log(`[TableOptions] 테이블 해제: ${tableId}`);
+
+ // 선택된 테이블이 제거되면 첫 번째 테이블 선택
+ if (selectedTableId === tableId) {
+ const firstTableId = newMap.keys().next().value;
+ setSelectedTableId(firstTableId || null);
+ }
+ }
+
+ return newMap;
+ });
+ },
+ [selectedTableId]
+ );
+
+ /**
+ * 특정 테이블 조회
+ */
+ const getTable = useCallback(
+ (tableId: string) => {
+ return registeredTables.get(tableId);
+ },
+ [registeredTables]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * Context Hook
+ */
+export const useTableOptions = () => {
+ const context = useContext(TableOptionsContext);
+ if (!context) {
+ throw new Error("useTableOptions must be used within TableOptionsProvider");
+ }
+ return context;
+};
+```
+
+---
+
+### Phase 2: TableOptionsToolbar 컴포넌트 구현
+
+**파일**: `components/screen/table-options/TableOptionsToolbar.tsx`
+
+```typescript
+import React, { useState } from "react";
+import { useTableOptions } from "@/contexts/TableOptionsContext";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Settings, Filter, Layers } from "lucide-react";
+import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel";
+import { FilterPanel } from "./FilterPanel";
+import { GroupingPanel } from "./GroupingPanel";
+
+export const TableOptionsToolbar: React.FC = () => {
+ const { registeredTables, selectedTableId, setSelectedTableId } =
+ useTableOptions();
+
+ const [columnPanelOpen, setColumnPanelOpen] = useState(false);
+ const [filterPanelOpen, setFilterPanelOpen] = useState(false);
+ const [groupPanelOpen, setGroupPanelOpen] = useState(false);
+
+ const tableList = Array.from(registeredTables.values());
+ const selectedTable = selectedTableId
+ ? registeredTables.get(selectedTableId)
+ : null;
+
+ // 테이블이 없으면 표시하지 않음
+ if (tableList.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {/* 테이블 선택 (2개 이상일 때만 표시) */}
+ {tableList.length > 1 && (
+
+ )}
+
+ {/* 테이블이 1개일 때는 이름만 표시 */}
+ {tableList.length === 1 && (
+
- )}
-
- {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
-
- {/* Pan 모드 안내 - 제거됨 */}
- {/* 줌 레벨 표시 */}
-
- 🔍 {Math.round(zoomLevel * 100)}%
-
- {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
- {(() => {
- // 선택된 컴포넌트들
- const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
-
- // 버튼 컴포넌트만 필터링
- const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
-
- // 플로우 그룹에 속한 버튼이 있는지 확인
- const hasFlowGroupButton = selectedButtons.some((btn) => {
- const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
- return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
- });
-
- // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
- const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
-
- if (!shouldShow) return null;
-
- return (
-
-
-
-
- {selectedButtons.length}개 버튼 선택됨
-
-
- {/* 그룹 생성 버튼 (2개 이상 선택 시) */}
- {selectedButtons.length >= 2 && (
-
- )}
-
- {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
- {hasFlowGroupButton && (
-
- )}
-
- {/* 상태 표시 */}
- {hasFlowGroupButton &&
✓ 플로우 그룹 버튼
}
-
+ {/* 통합 패널 */}
+ {panelStates.unified?.isOpen && (
+
+
+
패널
+
- );
- })()}
- {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
-
+ )}
+
+ {/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
+
+ {/* Pan 모드 안내 - 제거됨 */}
+ {/* 줌 레벨 표시 */}
+
+ 🔍 {Math.round(zoomLevel * 100)}%
+
+ {/* 🆕 플로우 버튼 그룹 제어 (버튼 선택 시 표시) */}
+ {(() => {
+ // 선택된 컴포넌트들
+ const selectedComps = layout.components.filter((c) => groupState.selectedComponents.includes(c.id));
+
+ // 버튼 컴포넌트만 필터링
+ const selectedButtons = selectedComps.filter((comp) => areAllButtons([comp]));
+
+ // 플로우 그룹에 속한 버튼이 있는지 확인
+ const hasFlowGroupButton = selectedButtons.some((btn) => {
+ const flowConfig = (btn as any).webTypeConfig?.flowVisibilityConfig;
+ return flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId;
+ });
+
+ // 버튼이 선택되었거나 플로우 그룹 버튼이 있으면 표시
+ const shouldShow = selectedButtons.length >= 1 && (selectedButtons.length >= 2 || hasFlowGroupButton);
+
+ if (!shouldShow) return null;
+
+ return (
+
+
+
+
+ {selectedButtons.length}개 버튼 선택됨
+
+
+ {/* 그룹 생성 버튼 (2개 이상 선택 시) */}
+ {selectedButtons.length >= 2 && (
+
+ )}
+
+ {/* 그룹 해제 버튼 (플로우 그룹 버튼이 있으면 항상 표시) */}
+ {hasFlowGroupButton && (
+
+ )}
+
+ {/* 상태 표시 */}
+ {hasFlowGroupButton &&
✓ 플로우 그룹 버튼
}
+
+
+ );
+ })()}
+ {/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}